修复V4复核字段指针运行时校验
This commit is contained in:
@@ -1696,8 +1696,12 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
|
||||
logReviewPointerRejected(card, pointer, "V4_REVIEW_VALUE_INVALID", confirmedPayload);
|
||||
throw error(HttpStatus.BAD_REQUEST, "V4_REVIEW_VALUE_INVALID", "复核字段值必须是标量或 null,不能替换对象或数组。");
|
||||
}
|
||||
boolean stableRoomInformationPointer = isStableRoomInformationReviewPointer(confirmedPayload, pointer);
|
||||
JsonNode current = findPointerValue(confirmedPayload, segments);
|
||||
if (current == null || current.isMissingNode()) {
|
||||
if (stableRoomInformationPointer) {
|
||||
return;
|
||||
}
|
||||
logReviewPointerRejected(card, pointer, "CURRENT_POINTER_MISSING", confirmedPayload);
|
||||
throw error(HttpStatus.BAD_REQUEST, "V4_REVIEW_POINTER_NOT_ALLOWED", "复核字段不在当前卡允许编辑字段内。");
|
||||
}
|
||||
@@ -1780,11 +1784,13 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
|
||||
}
|
||||
List<String> querySideEditablePointers = roomInformationQuerySideEditablePointers(card, confirmedPayload);
|
||||
List<String> commandSideAllowedPointers = roomInformationCommandSideAllowedPointers(card, confirmedPayload);
|
||||
Set<String> validationErrorPointers = collectValidationErrorPointers(card.validationErrorsJson());
|
||||
log.warn(
|
||||
"V4 review pointer rejected. review_pointer_policy=m002_v4_review_pointer_runtime_trace_v1, "
|
||||
"V4 review pointer rejected. review_pointer_policy=m002_v4_review_pointer_runtime_fix_v1, "
|
||||
+ "order_task_id={}, card_id={}, card_type={}, event_type={}, card_status={}, review_status={}, "
|
||||
+ "incoming_pointer={}, query_side_editable_pointers={}, command_side_allowed_pointers={}, "
|
||||
+ "reject_reason={}, stable_room_information_payload={}",
|
||||
+ "validation_error_pointers={}, reject_reason={}, "
|
||||
+ "display_payload_has_room_information_final_values={}, stable_room_information_payload={}",
|
||||
card.v4OrderTaskId(),
|
||||
card.id(),
|
||||
card.cardType(),
|
||||
@@ -1794,10 +1800,18 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
|
||||
safeLogPointer(pointer),
|
||||
querySideEditablePointers,
|
||||
commandSideAllowedPointers,
|
||||
validationErrorPointers,
|
||||
reasonCode,
|
||||
isStableRoomInformationPayload(confirmedPayload),
|
||||
isStableRoomInformationPayload(confirmedPayload));
|
||||
}
|
||||
|
||||
private boolean isStableRoomInformationReviewPointer(ObjectNode confirmedPayload, String pointer) {
|
||||
return isStableRoomInformationPayload(confirmedPayload)
|
||||
&& pointer != null
|
||||
&& pointer.startsWith("/room_information/final_values/");
|
||||
}
|
||||
|
||||
private List<String> roomInformationQuerySideEditablePointers(
|
||||
ReservationV4TaskCardSnapshot card,
|
||||
ObjectNode confirmedPayload) {
|
||||
@@ -1814,77 +1828,7 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
|
||||
private List<String> roomInformationCommandSideAllowedPointers(
|
||||
ReservationV4TaskCardSnapshot card,
|
||||
ObjectNode confirmedPayload) {
|
||||
if (!isStableRoomInformationPayload(confirmedPayload)) {
|
||||
return List.of();
|
||||
}
|
||||
JsonNode roomInformation = confirmedPayload.path("room_information");
|
||||
JsonNode finalValues = roomInformation.path("final_values");
|
||||
if (!finalValues.isObject()) {
|
||||
return List.of();
|
||||
}
|
||||
List<String> pointers = new ArrayList<>();
|
||||
collectRoomInformationCommandAllowedPointers(
|
||||
pointers,
|
||||
card,
|
||||
confirmedPayload,
|
||||
finalValues,
|
||||
List.of());
|
||||
return List.copyOf(pointers);
|
||||
}
|
||||
|
||||
private void collectRoomInformationCommandAllowedPointers(
|
||||
List<String> pointers,
|
||||
ReservationV4TaskCardSnapshot card,
|
||||
ObjectNode confirmedPayload,
|
||||
JsonNode current,
|
||||
List<String> finalValuePath) {
|
||||
if (current == null || current.isMissingNode() || current.isNull()) {
|
||||
addRoomInformationCommandAllowedPointer(pointers, card, confirmedPayload, current, finalValuePath);
|
||||
return;
|
||||
}
|
||||
if (current.isObject()) {
|
||||
current.fields().forEachRemaining(field -> collectRoomInformationCommandAllowedPointers(
|
||||
pointers,
|
||||
card,
|
||||
confirmedPayload,
|
||||
field.getValue(),
|
||||
appendPath(finalValuePath, field.getKey())));
|
||||
return;
|
||||
}
|
||||
if (current.isArray()) {
|
||||
for (int index = 0; index < current.size(); index++) {
|
||||
collectRoomInformationCommandAllowedPointers(
|
||||
pointers,
|
||||
card,
|
||||
confirmedPayload,
|
||||
current.get(index),
|
||||
appendPath(finalValuePath, String.valueOf(index)));
|
||||
}
|
||||
return;
|
||||
}
|
||||
addRoomInformationCommandAllowedPointer(pointers, card, confirmedPayload, current, finalValuePath);
|
||||
}
|
||||
|
||||
private void addRoomInformationCommandAllowedPointer(
|
||||
List<String> pointers,
|
||||
ReservationV4TaskCardSnapshot card,
|
||||
ObjectNode confirmedPayload,
|
||||
JsonNode current,
|
||||
List<String> finalValuePath) {
|
||||
if (finalValuePath.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
JsonNode roomInformation = confirmedPayload.path("room_information");
|
||||
ReservationV4RoomInformationFieldPolicy.RoomInformationWriteDecision decision =
|
||||
ReservationV4RoomInformationFieldPolicy.finalValueWriteDecision(
|
||||
card.eventType(),
|
||||
textAt(roomInformation, "booking_type"),
|
||||
finalValuePath,
|
||||
roomInformation.path("final_values"));
|
||||
String pointer = "/room_information/final_values/" + toJsonPointer(finalValuePath);
|
||||
if (decision.writable() && isReviewPointerAllowedForResolution(card, confirmedPayload, pointer, current)) {
|
||||
pointers.add(pointer);
|
||||
}
|
||||
return roomInformationQuerySideEditablePointers(card, confirmedPayload);
|
||||
}
|
||||
|
||||
private String safeLogPointer(String pointer) {
|
||||
|
||||
@@ -1183,6 +1183,86 @@ class ReservationV4CommandControllerTest {
|
||||
.value("RM2"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldResolveEditableRoomInformationPointerWhenLeafMissingInPayload() throws Exception {
|
||||
SeededOrderTask seeded = seedReviewOrderTaskWithBusinessCard(
|
||||
HOTEL_ID,
|
||||
"mail-v4-command-review-pointer-missing-leaf-001",
|
||||
Instant.parse("2026-07-19T01:22:19Z"),
|
||||
990000000000070117L,
|
||||
ReservationV4TargetResolutionStatus.RESOLVED.name(),
|
||||
ReservationV4CardStatus.PENDING_CONFIRM.name(),
|
||||
ReservationV4CardStatus.REVIEW_REQUIRED.name(),
|
||||
ReservationV4CardType.ROOM_INFORMATION.name(),
|
||||
"UPDATE_BOOKING",
|
||||
"""
|
||||
{
|
||||
"card_type":"ROOM_INFORMATION",
|
||||
"event_type":"UPDATE_BOOKING",
|
||||
"target_order":{"booking_type":"GROUP","locator_type":"GROUP_CODE","locator_value":"GRP-V4-REVIEW-MISSING-LEAF-001"},
|
||||
"business_fields":{
|
||||
"event_type":"UPDATE_BOOKING",
|
||||
"after":{
|
||||
"arrival_date":"2026-08-01",
|
||||
"departure_date":"2026-08-03",
|
||||
"room_items":[{"room_count":1}]
|
||||
}
|
||||
}
|
||||
}
|
||||
""",
|
||||
"""
|
||||
[
|
||||
{
|
||||
"field_path": "room_information.final_values.room_items.0.room_type_code",
|
||||
"field_pointer": "/room_information/final_values/room_items/0/room_type_code",
|
||||
"message": "房型代码不能为空。",
|
||||
"detail": "room_information.final_values.room_items.0.room_type_code: 房型代码不能为空。"
|
||||
}
|
||||
]
|
||||
""");
|
||||
confirmBasicCard(seeded);
|
||||
|
||||
MvcResult detailResult = performAuthorized(mockMvc, adminToken(),
|
||||
get("/api/reservation/order-tasks/{orderTaskId}", seeded.orderTask().id())
|
||||
.param("hotel_id", HOTEL_ID))
|
||||
.andExpect(status().isOk())
|
||||
.andReturn();
|
||||
JsonNode detail = objectMapper.readTree(detailResult.getResponse().getContentAsString());
|
||||
JsonNode editableField = null;
|
||||
for (JsonNode field : detail.path("business_cards").get(0).path("fields")) {
|
||||
if ("/room_information/final_values/room_items/0/room_type_code"
|
||||
.equals(field.path("field_pointer").asText())) {
|
||||
editableField = field;
|
||||
break;
|
||||
}
|
||||
}
|
||||
org.assertj.core.api.Assertions.assertThat(editableField)
|
||||
.as("任务详情必须返回缺失叶子字段的可复核 pointer")
|
||||
.isNotNull();
|
||||
org.assertj.core.api.Assertions.assertThat(editableField.path("editable").asBoolean(false)).isTrue();
|
||||
org.assertj.core.api.Assertions.assertThat(editableField.path("write_target").asText())
|
||||
.isEqualTo("review_resolution.field_overrides");
|
||||
|
||||
ObjectNode request = objectMapper.createObjectNode();
|
||||
request.put("version", 0);
|
||||
request.put("reason", "按详情页返回的缺失叶子字段补房型");
|
||||
ArrayNode overrides = request.putArray("field_overrides");
|
||||
ObjectNode override = overrides.addObject();
|
||||
override.put("field_pointer", editableField.path("field_pointer").asText());
|
||||
override.put("value", "TWN");
|
||||
|
||||
performAuthorized(mockMvc, adminToken(), post(
|
||||
"/api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/review-resolution",
|
||||
seeded.orderTask().id(),
|
||||
seeded.businessCard().id())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.business_cards[0].card_status").value("CONFIRMED"))
|
||||
.andExpect(jsonPath("$.business_cards[0].confirmed_payload.room_information.final_values.room_items[0].room_type_code")
|
||||
.value("TWN"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldResolveRoomInformationReviewWhenDisplayPayloadAlreadyUsesStableModel() throws Exception {
|
||||
SeededOrderTask seeded = seedReviewOrderTaskWithBusinessCard(
|
||||
@@ -1303,14 +1383,17 @@ class ReservationV4CommandControllerTest {
|
||||
.andExpect(jsonPath("$.error_code").value("V4_REVIEW_POINTER_READONLY"));
|
||||
|
||||
org.assertj.core.api.Assertions.assertThat(output.getOut())
|
||||
.contains("review_pointer_policy=m002_v4_review_pointer_runtime_trace_v1")
|
||||
.contains("review_pointer_policy=m002_v4_review_pointer_runtime_fix_v1")
|
||||
.contains("order_task_id=" + seeded.orderTask().id())
|
||||
.contains("card_id=" + seeded.businessCard().id())
|
||||
.contains("incoming_pointer=/room_information/final_values/nights")
|
||||
.contains("query_side_editable_pointers=")
|
||||
.contains("/room_information/final_values/room_items/0/room_type_code")
|
||||
.contains("command_side_allowed_pointers=")
|
||||
.contains("validation_error_pointers=")
|
||||
.contains("/room_information/final_values/nights")
|
||||
.contains("reject_reason=DERIVED_OR_SYSTEM_FIELD_READONLY")
|
||||
.contains("display_payload_has_room_information_final_values=true")
|
||||
.doesNotContain("target_order")
|
||||
.doesNotContain("business_fields")
|
||||
.doesNotContain("raw_evidence")
|
||||
@@ -1530,7 +1613,7 @@ class ReservationV4CommandControllerTest {
|
||||
.andExpect(jsonPath("$.error_code").value("V4_REVIEW_POINTER_INVALID"));
|
||||
|
||||
org.assertj.core.api.Assertions.assertThat(output.getOut())
|
||||
.contains("review_pointer_policy=m002_v4_review_pointer_runtime_trace_v1")
|
||||
.contains("review_pointer_policy=m002_v4_review_pointer_runtime_fix_v1")
|
||||
.contains("order_task_id=" + seeded.orderTask().id())
|
||||
.contains("card_id=" + seeded.businessCard().id())
|
||||
.contains("incoming_pointer=business_fields/room_items/0/pms_room_type_code")
|
||||
@@ -1570,7 +1653,7 @@ class ReservationV4CommandControllerTest {
|
||||
.andExpect(jsonPath("$.error_code").value("V4_REVIEW_POINTER_INVALID"));
|
||||
|
||||
org.assertj.core.api.Assertions.assertThat(output.getOut())
|
||||
.contains("review_pointer_policy=m002_v4_review_pointer_runtime_trace_v1")
|
||||
.contains("review_pointer_policy=m002_v4_review_pointer_runtime_fix_v1")
|
||||
.contains("incoming_pointer=bad/[EMAIL]/[TOKEN]/[URL]")
|
||||
.contains("reject_reason=V4_REVIEW_POINTER_INVALID")
|
||||
.doesNotContain("user@example.test")
|
||||
|
||||
Reference in New Issue
Block a user