From dfb74f33867ac69cca24620fd5de5414685b225a Mon Sep 17 00:00:00 2001 From: WorkBuddy Date: Fri, 9 Oct 2026 19:33:16 +0800 Subject: [PATCH] =?UTF-8?q?fix(backend):=20=E4=BF=AE=E5=A4=8D=E5=AE=A1?= =?UTF-8?q?=E6=A0=B8=E5=86=B3=E7=AD=96=E8=AF=B7=E6=B1=82=E4=BD=93=E5=A5=91?= =?UTF-8?q?=E7=BA=A6=E4=B8=8D=E4=B8=80=E8=87=B4=E5=AF=BC=E8=87=B4=E7=9A=84?= =?UTF-8?q?=20400?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 审核操作统一发送 {comment, reasonCodes[]}(数组),但 ReviewController 的 approve / need-evidence / approve-local-eligibility / reject-local-eligibility / evidences/{id}/access 将 @RequestBody 声明为 Map,数组值无法反序列 化成 String,Jackson 抛 HttpMessageNotReadableException,经 GlobalExceptionHandler 映射为 400「请求体格式错误」。 - 5 处 @RequestBody Map 改为 Map,comment/purpose 读取加 (String) 强转(与原本正确的 needEdit/reject 一致)。 - 新增回归守卫 ReviewControllerRequestBodyContractTest(standalone MockMvc + GlobalExceptionHandler + 自定义 @AuthenticationPrincipal 解析器),注入前端真实 载荷 {comment, reasonCodes:[]} 断言 200;直接 new 控制器测不到 HTTP/绑定层缺陷。 - 接入后端契约门禁 scripts/run-backend-contracts.mjs(CURATED 13 -> 14)。 - 负向注入已自证判别力:临时改回 Map 精确判红 400,恢复后 5/5 绿。 --- .../review/controller/ReviewController.java | 20 +-- ...viewControllerRequestBodyContractTest.java | 148 ++++++++++++++++++ scripts/run-backend-contracts.mjs | 5 + 3 files changed, 163 insertions(+), 10 deletions(-) create mode 100644 backend/src/test/java/com/danyang/match/platform/review/controller/ReviewControllerRequestBodyContractTest.java diff --git a/backend/src/main/java/com/danyang/match/platform/review/controller/ReviewController.java b/backend/src/main/java/com/danyang/match/platform/review/controller/ReviewController.java index dfed119..01d0fa9 100644 --- a/backend/src/main/java/com/danyang/match/platform/review/controller/ReviewController.java +++ b/backend/src/main/java/com/danyang/match/platform/review/controller/ReviewController.java @@ -95,9 +95,9 @@ public class ReviewController { public ApiResponse approve( @AuthenticationPrincipal OperatorPrincipal principal, @PathVariable String taskId, - @RequestBody Map body) { + @RequestBody Map body) { String operatorId = principal.id(); - String comment = body.getOrDefault("comment", ""); + String comment = (String) body.getOrDefault("comment", ""); return ApiResponse.ok(reviewService.approve(taskId, operatorId, comment)); } @@ -140,8 +140,8 @@ public class ReviewController { public ApiResponse needEvidence( @AuthenticationPrincipal OperatorPrincipal principal, @PathVariable String taskId, - @RequestBody Map body) { - return ApiResponse.ok(reviewService.needEvidence(taskId, principal.id(), body.get("comment"))); + @RequestBody Map body) { + return ApiResponse.ok(reviewService.needEvidence(taskId, principal.id(), (String) body.get("comment"))); } @PostMapping("/{taskId}/approve-local-eligibility") @@ -149,8 +149,8 @@ public class ReviewController { public ApiResponse approveLocalEligibility( @AuthenticationPrincipal OperatorPrincipal principal, @PathVariable String taskId, - @RequestBody Map body) { - return ApiResponse.ok(reviewService.approveLocalEligibility(taskId, principal.id(), body.get("comment"))); + @RequestBody Map body) { + return ApiResponse.ok(reviewService.approveLocalEligibility(taskId, principal.id(), (String) body.get("comment"))); } @PostMapping("/{taskId}/reject-local-eligibility") @@ -158,8 +158,8 @@ public class ReviewController { public ApiResponse rejectLocalEligibility( @AuthenticationPrincipal OperatorPrincipal principal, @PathVariable String taskId, - @RequestBody Map body) { - return ApiResponse.ok(reviewService.rejectLocalEligibility(taskId, principal.id(), body.get("comment"))); + @RequestBody Map body) { + return ApiResponse.ok(reviewService.rejectLocalEligibility(taskId, principal.id(), (String) body.get("comment"))); } @PostMapping("/{taskId}/evidences/{evidenceId}/access") @@ -168,9 +168,9 @@ public class ReviewController { @AuthenticationPrincipal OperatorPrincipal principal, @PathVariable String taskId, @PathVariable String evidenceId, - @RequestBody Map body) { + @RequestBody Map body) { return ApiResponse.ok(reviewService.accessLocalEligibilityEvidence( - taskId, evidenceId, principal.id(), body.get("purpose"))); + taskId, evidenceId, principal.id(), (String) body.get("purpose"))); } /** diff --git a/backend/src/test/java/com/danyang/match/platform/review/controller/ReviewControllerRequestBodyContractTest.java b/backend/src/test/java/com/danyang/match/platform/review/controller/ReviewControllerRequestBodyContractTest.java new file mode 100644 index 0000000..435d8d2 --- /dev/null +++ b/backend/src/test/java/com/danyang/match/platform/review/controller/ReviewControllerRequestBodyContractTest.java @@ -0,0 +1,148 @@ +package com.danyang.match.platform.review.controller; + +import com.danyang.match.platform.review.dto.ReviewResultDTO; +import com.danyang.match.platform.review.service.ReviewService; +import com.danyang.match.platform.shared.exception.GlobalExceptionHandler; +import com.danyang.match.platform.shared.security.principal.OperatorPrincipal; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.core.MethodParameter; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * ReviewController 请求体契约测试 —— 锁定 HTTP / JSON 反序列化层。 + * + *

背景(2026-10-09 实测缺陷):admin-web 的审核决策统一发送 + * {@code {comment, reasonCodes}},其中 {@code reasonCodes} 是**数组**。而 ReviewController 的 + * {@code approve} / {@code need-evidence} / {@code approve-local-eligibility} / + * {@code reject-local-eligibility} 等端点此前把 {@code @RequestBody} 声明为 + * {@code Map},数组值无法反序列化成 String → Jackson 抛 + * {@code HttpMessageNotReadableException} → 400「请求体格式错误」。表现为 + * 「小程序提交资料后,管理员点『通过』直接报错」。 + * + *

为什么用 standalone MockMvc 而不是 {@code @SpringBootTest}:本缺陷发生在 + * **参数解析 / 反序列化层**。先例 {@code BannerOperatorPhase19IntegrationTest} 直接 + * {@code new} 控制器再调方法,**绕过了该层**,因此天然抓不到这类缺陷。standalone MockMvc + * 走真实 DispatcherServlet + 消息转换器 + ControllerAdvice,又无需 DB / Redis / 安全链, + * 确定性强、可离线跑。 + * + *

判别力自证:若有人把端点改回 {@code Map},本测试立即变红(400)。 + * 已用负向注入验证过(见提交说明)。 + */ +class ReviewControllerRequestBodyContractTest { + + private static final String OPERATOR_ID = "00000000-0000-0000-0000-000000000002"; + private static final String TASK_ID = "t-0001"; + + /** 与 admin-web {@code ReviewView.vue#runDecision} 的实际请求体完全一致(含 reasonCodes 数组)。 */ + private static final String FRONTEND_PAYLOAD = "{\"comment\":\"已核验\",\"reasonCodes\":[]}"; + + private ReviewService reviewService; + private MockMvc mockMvc; + + @BeforeEach + void setUp() { + reviewService = Mockito.mock(ReviewService.class); + mockMvc = MockMvcBuilders.standaloneSetup(new ReviewController(reviewService)) + .setControllerAdvice(new GlobalExceptionHandler()) + .setCustomArgumentResolvers(new OperatorPrincipalResolver()) + .build(); + } + + private ReviewResultDTO stubResult(String result) { + ReviewResultDTO dto = new ReviewResultDTO(); + dto.setTaskId(TASK_ID); + dto.setResult(result); + return dto; + } + + @Test + void approveAcceptsCommentAndReasonCodesArray() throws Exception { + Mockito.when(reviewService.approve(eq(TASK_ID), eq(OPERATOR_ID), any())).thenReturn(stubResult("approved")); + + mockMvc.perform(post("/operator/reviews/{taskId}/approve", TASK_ID) + .contentType(MediaType.APPLICATION_JSON) + .content(FRONTEND_PAYLOAD)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.result").value("approved")); + } + + @Test + void approveLocalEligibilityAcceptsCommentAndReasonCodesArray() throws Exception { + Mockito.when(reviewService.approveLocalEligibility(eq(TASK_ID), eq(OPERATOR_ID), any())) + .thenReturn(stubResult("approved")); + + mockMvc.perform(post("/operator/reviews/{taskId}/approve-local-eligibility", TASK_ID) + .contentType(MediaType.APPLICATION_JSON) + .content(FRONTEND_PAYLOAD)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.result").value("approved")); + } + + @Test + void rejectLocalEligibilityAcceptsCommentAndReasonCodesArray() throws Exception { + Mockito.when(reviewService.rejectLocalEligibility(eq(TASK_ID), eq(OPERATOR_ID), any())) + .thenReturn(stubResult("rejected")); + + mockMvc.perform(post("/operator/reviews/{taskId}/reject-local-eligibility", TASK_ID) + .contentType(MediaType.APPLICATION_JSON) + .content(FRONTEND_PAYLOAD)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.result").value("rejected")); + } + + @Test + void needEvidenceAcceptsCommentAndReasonCodesArray() throws Exception { + Mockito.when(reviewService.needEvidence(eq(TASK_ID), eq(OPERATOR_ID), any())) + .thenReturn(stubResult("need_evidence")); + + mockMvc.perform(post("/operator/reviews/{taskId}/need-evidence", TASK_ID) + .contentType(MediaType.APPLICATION_JSON) + .content(FRONTEND_PAYLOAD)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.result").value("need_evidence")); + } + + /** 只带 comment、不带 reasonCodes 也必须可用(前端未选原因标签时的实际载荷)。 */ + @Test + void approveWithoutReasonCodesStillWorks() throws Exception { + Mockito.when(reviewService.approve(eq(TASK_ID), eq(OPERATOR_ID), any())).thenReturn(stubResult("approved")); + + mockMvc.perform(post("/operator/reviews/{taskId}/approve", TASK_ID) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"comment\":\"通过\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)); + } + + /** 提供 {@code @AuthenticationPrincipal OperatorPrincipal} 的解析器(standalone 无安全链)。 */ + private static final class OperatorPrincipalResolver implements HandlerMethodArgumentResolver { + @Override + public boolean supportsParameter(MethodParameter parameter) { + return OperatorPrincipal.class.isAssignableFrom(parameter.getParameterType()); + } + + @Override + public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, WebDataBinderFactory binderFactory) { + return new OperatorPrincipal(OPERATOR_ID); + } + } +} diff --git a/scripts/run-backend-contracts.mjs b/scripts/run-backend-contracts.mjs index e90609f..dcfc55b 100644 --- a/scripts/run-backend-contracts.mjs +++ b/scripts/run-backend-contracts.mjs @@ -41,6 +41,10 @@ const backendTestRoot = resolve(root, 'backend', 'src', 'test', 'java') // MeRouteContractTest / CanonicalRouteContractTest // PhotoLimitGuardTest / PhotoLimitConcurrencyGuardTest / PhotoUploadValidationTest // OperatorAuthServiceTest +// 第 14 项(2026-10-09):审核决策端点的「请求体契约」守卫 —— 前端统一发送 {comment, reasonCodes[]}, +// 若 @RequestBody 被再度收窄为 Map,数组值反序列化失败即 400 +// (现象:「小程序提交资料后,管理员点通过直接报错」)。 +// ReviewControllerRequestBodyContractTest // 新增/删减请同步修改本数组(下限随之变化)。 const CURATED = [ 'AuthServiceRefreshTtlTest', @@ -56,6 +60,7 @@ const CURATED = [ 'PhotoLimitConcurrencyGuardTest', 'PhotoUploadValidationTest', 'OperatorAuthServiceTest', + 'ReviewControllerRequestBodyContractTest', ] const MIN_GUARDS = Number(process.env.BACKEND_GUARDS_MIN ?? CURATED.length)