fix(backend): 修复审核决策请求体契约不一致导致的 400

审核操作统一发送 {comment, reasonCodes[]}(数组),但 ReviewController 的
approve / need-evidence / approve-local-eligibility / reject-local-eligibility /
evidences/{id}/access 将 @RequestBody 声明为 Map<String,String>,数组值无法反序列
化成 String,Jackson 抛 HttpMessageNotReadableException,经 GlobalExceptionHandler
映射为 400「请求体格式错误」。

- 5 处 @RequestBody Map<String,String> 改为 Map<String,Object>,comment/purpose
  读取加 (String) 强转(与原本正确的 needEdit/reject 一致)。
- 新增回归守卫 ReviewControllerRequestBodyContractTest(standalone MockMvc +
  GlobalExceptionHandler + 自定义 @AuthenticationPrincipal 解析器),注入前端真实
  载荷 {comment, reasonCodes:[]} 断言 200;直接 new 控制器测不到 HTTP/绑定层缺陷。
- 接入后端契约门禁 scripts/run-backend-contracts.mjs(CURATED 13 -> 14)。
- 负向注入已自证判别力:临时改回 Map<String,String> 精确判红 400,恢复后 5/5 绿。
This commit is contained in:
WorkBuddy
2026-10-09 19:33:16 +08:00
parent 9fc3385d6c
commit dfb74f3386
3 changed files with 163 additions and 10 deletions
@@ -95,9 +95,9 @@ public class ReviewController {
public ApiResponse<ReviewResultDTO> approve(
@AuthenticationPrincipal OperatorPrincipal principal,
@PathVariable String taskId,
@RequestBody Map<String, String> body) {
@RequestBody Map<String, Object> 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<ReviewResultDTO> needEvidence(
@AuthenticationPrincipal OperatorPrincipal principal,
@PathVariable String taskId,
@RequestBody Map<String, String> body) {
return ApiResponse.ok(reviewService.needEvidence(taskId, principal.id(), body.get("comment")));
@RequestBody Map<String, Object> 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<ReviewResultDTO> approveLocalEligibility(
@AuthenticationPrincipal OperatorPrincipal principal,
@PathVariable String taskId,
@RequestBody Map<String, String> body) {
return ApiResponse.ok(reviewService.approveLocalEligibility(taskId, principal.id(), body.get("comment")));
@RequestBody Map<String, Object> 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<ReviewResultDTO> rejectLocalEligibility(
@AuthenticationPrincipal OperatorPrincipal principal,
@PathVariable String taskId,
@RequestBody Map<String, String> body) {
return ApiResponse.ok(reviewService.rejectLocalEligibility(taskId, principal.id(), body.get("comment")));
@RequestBody Map<String, Object> 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<String, String> body) {
@RequestBody Map<String, Object> body) {
return ApiResponse.ok(reviewService.accessLocalEligibilityEvidence(
taskId, evidenceId, principal.id(), body.get("purpose")));
taskId, evidenceId, principal.id(), (String) body.get("purpose")));
}
/**
@@ -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 反序列化层。
*
* <p>背景(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,String>},数组值无法反序列化成 String → Jackson 抛
* {@code HttpMessageNotReadableException} → 400「请求体格式错误」。表现为
* 「小程序提交资料后,管理员点『通过』直接报错」。
*
* <p><b>为什么用 standalone MockMvc 而不是 {@code @SpringBootTest}</b>:本缺陷发生在
* **参数解析 / 反序列化层**。先例 {@code BannerOperatorPhase19IntegrationTest} 直接
* {@code new} 控制器再调方法,**绕过了该层**,因此天然抓不到这类缺陷。standalone MockMvc
* 走真实 DispatcherServlet + 消息转换器 + ControllerAdvice,又无需 DB / Redis / 安全链,
* 确定性强、可离线跑。
*
* <p><b>判别力自证</b>:若有人把端点改回 {@code Map<String,String>},本测试立即变红(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);
}
}
}
+5
View File
@@ -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<String,String>,数组值反序列化失败即 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)