diff --git a/docs/project/frontend-backend/backend-to-frontend-notes.md b/docs/project/frontend-backend/backend-to-frontend-notes.md index 63233df..1852fc9 100644 --- a/docs/project/frontend-backend/backend-to-frontend-notes.md +++ b/docs/project/frontend-backend/backend-to-frontend-notes.md @@ -168,6 +168,7 @@ POST /api/auth/logout ``` - `field_pointer` 必须是 RFC 6901 JSON Pointer,并且只能指向当前任务卡可编辑字段;后端会映射到矩阵 `field_path`。非法或只读字段会返回 `TASK_REVIEW_POINTER_INVALID`。 +- 同一次请求不能重复提交同一字段;重复 `field_pointer` 或重复映射到同一 `field_path` 会返回 `TASK_REVIEW_POINTER_DUPLICATE`。 - `confirmed_order_id` 第一版必须等于当前任务的 `order_id`;如果前端需要选择其他订单,仍属于后续“复核场景订单归属选择”细化,不要复用普通任务切换订单能力。 - 解阻成功后返回 `task_status=READY`、`review_status=RESOLVED`、`review_resolution.field_overrides[]`、`confirmed_payload` 和两条 `opera_operations[]`。前端应刷新任务详情并显示 OPERA 模拟操作入口。 - 解阻过程不改写 `ai_payload_json`;用户修正值保存在 `review_resolution` 和 `confirmed_payload.field_values` 中。 diff --git a/docs/project/frontend-backend/frontend-to-backend-api-requests.md b/docs/project/frontend-backend/frontend-to-backend-api-requests.md index c4a697c..731686e 100644 --- a/docs/project/frontend-backend/frontend-to-backend-api-requests.md +++ b/docs/project/frontend-backend/frontend-to-backend-api-requests.md @@ -560,6 +560,7 @@ Content-Type: application/json 前端注意: - `field_pointer` 必须是 RFC 6901 JSON Pointer,并且只能指向任务详情 `fields[]` 中当前可编辑字段;只读字段或未知字段会返回 `TASK_REVIEW_POINTER_INVALID`。 +- 同一次请求不能重复提交同一字段;重复 `field_pointer` 或重复映射到同一 `field_path` 会返回 `TASK_REVIEW_POINTER_DUPLICATE`。 - `confirmed_order_id` 第一版必须等于当前任务 `order_id`;普通任务任意切换订单继续后置。 - type-known manual review 不能调用通用 `POST /api/reservation/tasks/{taskId}/confirm`;必须调用本节解阻接口,否则后端返回 `TASK_REVIEW_RESOLUTION_REQUIRED`。 - `review_resolution.resolved_at` 是 UTC `Z` 时间点。 diff --git a/docs/project/requirements/M002-order-task-workflow-v3.md b/docs/project/requirements/M002-order-task-workflow-v3.md index f2d5321..ce655d9 100644 --- a/docs/project/requirements/M002-order-task-workflow-v3.md +++ b/docs/project/requirements/M002-order-task-workflow-v3.md @@ -284,6 +284,7 @@ Agent payload 不可变。本系统在同一张卡上维护复核状态: - `missing_fields[]` 必须是 RFC 6901 JSON Pointer。 - 入站阶段 `missing_fields[]` 不完整或不是 RFC 6901 Pointer 时,按 `adapter_contract_error` fail closed,不创建业务任务。 - 解阻接口提交的 Pointer 必须能映射到该业务卡当前可展示且可编辑字段,否则返回 `TASK_REVIEW_POINTER_INVALID`。 +- 同一次解阻请求中 `field_overrides[]` 不允许重复指向同一 `field_pointer` 或同一矩阵 `field_path`,否则返回 `TASK_REVIEW_POINTER_DUPLICATE`。 - 订单归属确认可作为复核解阻的一部分保存;当前第一版只允许确认当前任务所属订单,不开放普通任务随意切换订单能力。 - 全部缺失字段、订单归属、目录值和依赖校验通过后,才进入 Preflight / READY。 diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationTaskWorkflowServiceImpl.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationTaskWorkflowServiceImpl.java index f1475a6..f04ae46 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationTaskWorkflowServiceImpl.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationTaskWorkflowServiceImpl.java @@ -807,9 +807,14 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl Map definitionByPath = new LinkedHashMap<>(); activeDefinitions.forEach(definition -> definitionByPath.put(definition.fieldPath(), definition)); List results = new ArrayList<>(); + Set seenPointers = new LinkedHashSet<>(); + Set seenFieldPaths = new LinkedHashSet<>(); for (ManualReviewResolutionFieldOverrideRequest request : requests) { String fieldPointer = trimToNull(request == null ? null : request.fieldPointer()); ReservationTaskCardFieldDefinition definition = findEditableDefinitionByPointer(definitionByPath, fieldPointer); + if (!seenPointers.add(fieldPointer) || !seenFieldPaths.add(definition.fieldPath())) { + throw reviewPointerDuplicateError(fieldPointer, definition.fieldPath()); + } results.add(new ManualReviewResolutionFieldOverrideResult( fieldPointer, definition.fieldPath(), @@ -818,6 +823,17 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl return results; } + /** + * 构建重复复核字段错误,避免 confirmed payload 后值覆盖前值但审计保留多条冲突记录。 + */ + private ReservationTaskWorkflowException reviewPointerDuplicateError(String fieldPointer, String fieldPath) { + return new ReservationTaskWorkflowException( + HttpStatus.BAD_REQUEST, + "TASK_REVIEW_POINTER_DUPLICATE", + "复核字段指针重复。", + List.of(fieldPointer + ": 重复指向字段 " + fieldPath + "。")); + } + /** * 复核 pointer 白名单使用当前卡型的可编辑字段全集;展示条件由合并后的 activeDefinitions 再兜底校验。 */ diff --git a/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/SuperAgentTaskResultControllerTest.java b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/SuperAgentTaskResultControllerTest.java index 82b2b71..2987e11 100644 --- a/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/SuperAgentTaskResultControllerTest.java +++ b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/SuperAgentTaskResultControllerTest.java @@ -1912,6 +1912,53 @@ class SuperAgentTaskResultControllerTest { assertThat(sourceTaskCount).isEqualTo(1L); } + @Test + void shouldRejectManualReviewResolutionWhenPointerIsDuplicated() throws Exception { + captureSourceMessage("mail-v3-manual-review-duplicate-pointer-001"); + String body = typeKnownManualReviewBody( + "mail-v3-manual-review-duplicate-pointer-001", + "CNF-V3-MR-DUP-001", + "2026-09-05", + "2026-09-07"); + MvcResult createResult = mockMvc.perform(signedPost(body, "nonce-v3-manual-review-duplicate-pointer-001")) + .andExpect(status().isCreated()) + .andReturn(); + String taskId = com.jayway.jsonpath.JsonPath.read( + createResult.getResponse().getContentAsString(), + "$.items[0].task_id"); + String orderId = com.jayway.jsonpath.JsonPath.read( + createResult.getResponse().getContentAsString(), + "$.items[0].order_id"); + + mockMvc.perform(post("/api/reservation/tasks/{taskId}/manual-review-resolutions", taskId) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "confirmed_order_id": "%s", + "field_overrides": [ + { + "field_pointer": "/extracted_fields/pms_room_type_code", + "value": "RM3" + }, + { + "field_pointer": "/extracted_fields/pms_room_type_code", + "value": "RM4" + } + ] + } + """.formatted(orderId))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error_code").value("TASK_REVIEW_POINTER_DUPLICATE")) + .andExpect(jsonPath("$.details[0]").value(containsString("/extracted_fields/pms_room_type_code"))); + + String taskStatus = jdbcTemplate.queryForObject(""" + SELECT task_status + FROM workflow_reservation_task + WHERE id = ? + """, String.class, Long.valueOf(taskId)); + assertThat(taskStatus).isEqualTo("PENDING_CONFIRM"); + } + @Test void shouldRejectManualReviewResolutionWhenMissingFieldsAreNotResolved() throws Exception { captureSourceMessage("mail-v3-manual-review-missing-001");