收紧 V4 复核解阻边界

This commit is contained in:
andy
2026-07-19 11:56:12 +07:00
parent d0d26c7fbe
commit b4f1b3c856
6 changed files with 160 additions and 7 deletions

View File

@@ -161,6 +161,7 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
ReservationV4TaskCardSnapshot card = requireTaskCard(orderTask.hotelId(), cardId, orderTask.id());
validateCardReviewable(orderTask, card);
Long confirmedOrderId = parseConfirmedOrderId(request == null ? null : request.confirmedOrderId());
ensureReviewOrderRebindAllowed(orderTask, confirmedOrderId);
Long targetOrderId = resolveReviewTargetOrderId(orderTask, confirmedOrderId);
ensureNoPriorOrderTaskBlocking(orderTask, targetOrderId);
ensureBasicInformationConfirmed(orderTask, card);
@@ -495,7 +496,7 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
String pointer = trimToNull(override == null ? null : override.fieldPointer());
List<String> segments = decodeJsonPointer(pointer);
JsonNode value = override == null || override.value() == null ? objectMapper.nullNode() : override.value();
ensureReviewPointerWritable(card, confirmedPayload, segments, value);
ensureReviewPointerWritable(card, confirmedPayload, pointer, segments, value);
if (!seenPointers.add(pointer)) {
throw error(HttpStatus.BAD_REQUEST, "V4_REVIEW_POINTER_DUPLICATED", "复核字段不能重复提交。");
}
@@ -546,6 +547,7 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
private void ensureReviewPointerWritable(
ReservationV4TaskCardSnapshot card,
ObjectNode confirmedPayload,
String pointer,
List<String> segments,
JsonNode value) {
if (segments.isEmpty()) {
@@ -565,6 +567,9 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
if (current.isContainerNode()) {
throw error(HttpStatus.BAD_REQUEST, "V4_REVIEW_POINTER_NOT_ALLOWED", "复核字段只能指向当前卡允许编辑的叶子字段。");
}
if (!isReviewPointerAllowedForResolution(confirmedPayload, pointer, current)) {
throw error(HttpStatus.BAD_REQUEST, "V4_REVIEW_POINTER_NOT_ALLOWED", "复核字段不在当前卡允许编辑字段内。");
}
}
private void ensureReviewPointerInsideEditableContainer(
@@ -655,6 +660,69 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
}
}
/**
* 判断复核字段是否属于本次允许修正的字段范围,优先使用显式缺失字段清单。
*/
private boolean isReviewPointerAllowedForResolution(
ObjectNode confirmedPayload,
String pointer,
JsonNode current) {
Set<String> explicitPointers = collectExplicitReviewFieldPointers(confirmedPayload);
if (!explicitPointers.isEmpty()) {
return explicitPointers.contains(pointer);
}
return isUnresolvedReviewLeaf(current);
}
/**
* 从当前卡展示 payload 中收集兼容用的 missing_fields 指针清单。
*/
private Set<String> collectExplicitReviewFieldPointers(JsonNode node) {
Set<String> pointers = new HashSet<>();
collectExplicitReviewFieldPointers(node, pointers);
return pointers;
}
/**
* 递归收集嵌套对象里的 missing_fields避免前端只能处理某一种历史摆放位置。
*/
private void collectExplicitReviewFieldPointers(JsonNode node, Set<String> pointers) {
if (node == null || node.isMissingNode()) {
return;
}
if (node.isObject()) {
JsonNode missingFields = node.get("missing_fields");
if (missingFields != null && missingFields.isArray()) {
for (JsonNode missingField : missingFields) {
String pointer = trimToNull(missingField == null ? null : missingField.asText());
if (pointer != null && pointer.startsWith("/")) {
pointers.add(pointer);
}
}
}
Iterator<Map.Entry<String, JsonNode>> fields = node.fields();
while (fields.hasNext()) {
collectExplicitReviewFieldPointers(fields.next().getValue(), pointers);
}
return;
}
if (node.isArray()) {
for (JsonNode item : node) {
collectExplicitReviewFieldPointers(item, pointers);
}
}
}
/**
* 判断当前叶子字段是否仍是未解决值,第一版只允许修正 null 或空字符串。
*/
private boolean isUnresolvedReviewLeaf(JsonNode current) {
if (current == null || current.isNull()) {
return true;
}
return current.isTextual() && !hasText(current.asText());
}
private Long parseConfirmedOrderId(String confirmedOrderId) {
String normalized = trimToNull(confirmedOrderId);
if (normalized == null) {
@@ -671,6 +739,22 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
}
}
/**
* 阻止已确认归属的 V4 订单任务借复核接口切换到其它订单。
*/
private void ensureReviewOrderRebindAllowed(
ReservationV4OrderTaskSnapshot orderTask,
Long confirmedOrderId) {
if (confirmedOrderId == null) {
return;
}
boolean alreadyResolvedToOrder = orderTask.orderId() != null
&& ReservationV4TargetResolutionStatus.RESOLVED.name().equals(orderTask.targetResolutionStatus());
if (alreadyResolvedToOrder && !Objects.equals(orderTask.orderId(), confirmedOrderId)) {
throw error(HttpStatus.CONFLICT, "V4_ORDER_REBIND_NOT_ALLOWED", "已确认归属的 V4 订单任务不能通过复核接口切换到其他订单。");
}
}
private Long resolveReviewTargetOrderId(ReservationV4OrderTaskSnapshot orderTask, Long confirmedOrderId) {
boolean ownershipUnresolved = orderTask.orderId() == null
|| !ReservationV4TargetResolutionStatus.RESOLVED.name().equals(orderTask.targetResolutionStatus());

View File

@@ -319,6 +319,73 @@ class ReservationV4CommandControllerTest {
.andExpect(jsonPath("$.business_cards[0].review_resolution.reason").value("确认房型映射"));
}
@Test
void shouldRejectReviewResolutionWhenResolvedOrderTaskRebindsToDifferentOrder() throws Exception {
Long currentOrderId = 990000000000070111L;
Long otherOrderId = 990000000000070112L;
seedReservationOrder(HOTEL_ID, currentOrderId);
seedReservationOrder(HOTEL_ID, otherOrderId);
SeededOrderTask seeded = seedReviewOrderTask(
HOTEL_ID,
"mail-v4-command-review-rebind-001",
Instant.parse("2026-07-19T01:22:10Z"),
currentOrderId,
ReservationV4CardStatus.PENDING_CONFIRM.name(),
ReservationV4CardStatus.REVIEW_REQUIRED.name());
confirmBasicCard(seeded);
performAuthorized(mockMvc, adminToken(), post(
"/api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/review-resolution",
seeded.orderTask().id(),
seeded.businessCard().id())
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"version": 0,
"confirmed_order_id": "990000000000070112",
"field_overrides": [
{"field_pointer": "/business_fields/room_items/0/pms_room_type_code", "value": "RM2"}
]
}
"""))
.andExpect(status().isConflict())
.andExpect(jsonPath("$.error_code").value("V4_ORDER_REBIND_NOT_ALLOWED"));
Long persistedOrderId = jdbcTemplate.queryForObject(
"SELECT order_id FROM workflow_reservation_v4_order_task WHERE id = ?",
Long.class,
seeded.orderTask().id());
org.assertj.core.api.Assertions.assertThat(persistedOrderId).isEqualTo(currentOrderId);
}
@Test
void shouldRejectReviewResolutionForFieldNotMarkedUnresolved() throws Exception {
SeededOrderTask seeded = seedReviewOrderTask(
HOTEL_ID,
"mail-v4-command-review-resolved-field-001",
Instant.parse("2026-07-19T01:22:20Z"),
990000000000070113L,
ReservationV4CardStatus.PENDING_CONFIRM.name(),
ReservationV4CardStatus.REVIEW_REQUIRED.name());
confirmBasicCard(seeded);
performAuthorized(mockMvc, adminToken(), post(
"/api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/review-resolution",
seeded.orderTask().id(),
seeded.businessCard().id())
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"version": 0,
"field_overrides": [
{"field_pointer": "/business_fields/room_items/0/room_count", "value": 3}
]
}
"""))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.error_code").value("V4_REVIEW_POINTER_NOT_ALLOWED"));
}
@Test
void shouldRejectReviewResolutionWhenOrderOwnershipUnresolvedAndConfirmedOrderMissing() throws Exception {
SeededOrderTask seeded = seedReviewOrderTask(