收紧 V4 复核解阻边界
This commit is contained in:
@@ -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());
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user