修复 V4 确认白名单和嵌套目录校验

This commit is contained in:
andy
2026-07-19 12:48:08 +07:00
parent 39518b3cf2
commit 10c28f0acb
7 changed files with 395 additions and 35 deletions

View File

@@ -84,6 +84,16 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
"task_subtype",
"task_type",
"validation_errors");
private static final Set<String> BUSINESS_WRITABLE_ROOT_FIELDS = Set.of(
"arrival_date",
"attachment_ids",
"booking_scenario",
"departure_date",
"evidence_url",
"guest_name",
"rate_code",
"room_items",
"trace_items");
private final ReservationV4WorkflowRepository workflowRepository;
private final ReservationV4SourceNotificationRepository sourceNotificationRepository;
@@ -466,13 +476,138 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
}
private ObjectNode confirmedPayloadObject(ReservationV4TaskCardSnapshot card, JsonNode confirmedPayload) {
ObjectNode payload = mutableDisplayPayload(card);
if (confirmedPayload != null && !confirmedPayload.isNull() && !confirmedPayload.isMissingNode()) {
if (!confirmedPayload.isObject()) {
throw error(HttpStatus.BAD_REQUEST, "V4_CONFIRMED_PAYLOAD_NOT_OBJECT", "确认 payload 必须是对象结构。");
}
return ((ObjectNode) confirmedPayload).deepCopy();
overlayConfirmedPayload(card, payload, (ObjectNode) confirmedPayload);
}
return mutableDisplayPayload(card);
return payload;
}
/**
* 按当前卡片白名单合并前端确认值,避免未开放字段污染 confirmed_payload_json。
*/
private void overlayConfirmedPayload(ReservationV4TaskCardSnapshot card, ObjectNode payload, ObjectNode submittedPayload) {
if (ReservationV4CardType.BASIC_INFORMATION.name().equals(card.cardType())) {
overlayBasicInformationPayload(payload, submittedPayload);
return;
}
overlayBusinessPayload(payload, submittedPayload);
}
/**
* Basic Information 第一版只允许前端改 Account Code其它字段由后端展示快照或目录派生。
*/
private void overlayBasicInformationPayload(ObjectNode payload, ObjectNode submittedPayload) {
ObjectNode basicInformation = ensureBasicInformationObject(payload);
JsonNode submittedBasic = submittedPayload.path("basic_information");
JsonNode accountCode = submittedBasic.isObject()
? submittedBasic.get("account_code")
: submittedPayload.get("account_code");
if (accountCode != null && !accountCode.isMissingNode() && !accountCode.isContainerNode()) {
basicInformation.set("account_code", accountCode);
}
}
/**
* 业务卡只合并展示快照中已经存在且后端允许编辑的叶子字段,不接受新增字段或整体替换对象 / 数组。
*/
private void overlayBusinessPayload(ObjectNode payload, ObjectNode submittedPayload) {
JsonNode businessFields = payload.path("business_fields");
JsonNode submittedBusinessFields = submittedPayload.path("business_fields");
JsonNode submittedRoot = submittedBusinessFields.isObject() ? submittedBusinessFields : submittedPayload;
if (businessFields.isObject()) {
overlayEditableBusinessLeaves((ObjectNode) businessFields, submittedRoot, List.of(), true);
return;
}
overlayEditableBusinessLeaves(payload, submittedRoot, List.of(), false);
}
private void overlayEditableBusinessLeaves(
ObjectNode target,
JsonNode submitted,
List<String> path,
boolean wrappedBusinessFields) {
if (submitted == null || !submitted.isObject()) {
return;
}
Iterator<Map.Entry<String, JsonNode>> fields = target.fields();
while (fields.hasNext()) {
Map.Entry<String, JsonNode> field = fields.next();
List<String> childPath = appendPath(path, field.getKey());
if (!isBusinessPathWritable(childPath, wrappedBusinessFields)) {
continue;
}
JsonNode submittedValue = submitted.get(field.getKey());
overlayEditableBusinessValue(target, field.getKey(), field.getValue(), submittedValue, childPath, wrappedBusinessFields);
}
}
private void overlayEditableBusinessArray(
ArrayNode target,
JsonNode submitted,
List<String> path,
boolean wrappedBusinessFields) {
if (submitted == null || !submitted.isArray()) {
return;
}
int size = Math.min(target.size(), submitted.size());
for (int index = 0; index < size; index++) {
List<String> childPath = appendPath(path, String.valueOf(index));
overlayEditableBusinessValue(target, index, target.get(index), submitted.get(index), childPath, wrappedBusinessFields);
}
}
private void overlayEditableBusinessValue(
ObjectNode parent,
String fieldName,
JsonNode currentValue,
JsonNode submittedValue,
List<String> path,
boolean wrappedBusinessFields) {
if (currentValue != null && currentValue.isObject()) {
overlayEditableBusinessLeaves((ObjectNode) currentValue, submittedValue, path, wrappedBusinessFields);
return;
}
if (currentValue != null && currentValue.isArray()) {
overlayEditableBusinessArray((ArrayNode) currentValue, submittedValue, path, wrappedBusinessFields);
return;
}
if (submittedValue != null && !submittedValue.isMissingNode() && !submittedValue.isContainerNode()) {
parent.set(fieldName, submittedValue);
}
}
private void overlayEditableBusinessValue(
ArrayNode parent,
int index,
JsonNode currentValue,
JsonNode submittedValue,
List<String> path,
boolean wrappedBusinessFields) {
if (currentValue != null && currentValue.isObject()) {
overlayEditableBusinessLeaves((ObjectNode) currentValue, submittedValue, path, wrappedBusinessFields);
return;
}
if (currentValue != null && currentValue.isArray()) {
overlayEditableBusinessArray((ArrayNode) currentValue, submittedValue, path, wrappedBusinessFields);
return;
}
if (submittedValue != null && !submittedValue.isMissingNode() && !submittedValue.isContainerNode()) {
parent.set(index, submittedValue);
}
}
private boolean isBusinessPathWritable(List<String> path, boolean wrappedBusinessFields) {
if (path == null || path.isEmpty()) {
return false;
}
if (path.stream().anyMatch(REVIEW_READONLY_ROOT_FIELDS::contains)) {
return false;
}
return wrappedBusinessFields || BUSINESS_WRITABLE_ROOT_FIELDS.contains(path.get(0));
}
private void validateAndEnrichConfirmedPayload(ReservationV4TaskCardSnapshot card, ObjectNode payload) {
@@ -531,34 +666,59 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
private void validateBusinessCardPayload(ObjectNode payload, List<String> details) {
JsonNode businessFields = payload.path("business_fields");
JsonNode fieldRoot = businessFields.isObject() ? businessFields : payload;
validateRateCode(fieldRoot.path("rate_code"), details);
validateRoomItems(fieldRoot.path("room_items"), details);
validateBusinessCatalogFields(fieldRoot, "business_fields", details);
}
private void validateRateCode(JsonNode rateCode, List<String> details) {
String code = textValue(rateCode);
if (code != null && !directoryService.isKnownRateCode(code)) {
details.add("business_fields.rate_code: Rate Code 不在第一版目录中。");
private void validateBusinessCatalogFields(JsonNode node, String fieldPath, List<String> details) {
if (node == null || node.isMissingNode() || node.isNull()) {
return;
}
if (node.isObject()) {
Iterator<Map.Entry<String, JsonNode>> fields = node.fields();
while (fields.hasNext()) {
Map.Entry<String, JsonNode> field = fields.next();
String childPath = fieldPath + "." + field.getKey();
if ("rate_code".equals(field.getKey())) {
validateRateCode(field.getValue(), childPath, details);
} else if ("room_items".equals(field.getKey())) {
validateRoomItems(field.getValue(), childPath, details);
} else {
validateBusinessCatalogFields(field.getValue(), childPath, details);
}
}
return;
}
if (node.isArray()) {
for (int index = 0; index < node.size(); index++) {
validateBusinessCatalogFields(node.get(index), fieldPath + "." + index, details);
}
}
}
private void validateRoomItems(JsonNode roomItems, List<String> details) {
private void validateRateCode(JsonNode rateCode, String fieldPath, List<String> details) {
String code = textValue(rateCode);
if (code != null && !directoryService.isKnownRateCode(code)) {
details.add(fieldPath + ": Rate Code 不在第一版目录中。");
}
}
private void validateRoomItems(JsonNode roomItems, String fieldPath, List<String> details) {
if (roomItems == null || roomItems.isMissingNode() || roomItems.isNull()) {
return;
}
if (!roomItems.isArray()) {
details.add("business_fields.room_items: 房型明细必须是数组。");
details.add(fieldPath + ": 房型明细必须是数组。");
return;
}
for (int index = 0; index < roomItems.size(); index++) {
JsonNode item = roomItems.get(index);
if (item == null || !item.isObject()) {
details.add("business_fields.room_items." + index + ": 房型明细必须是对象。");
details.add(fieldPath + "." + index + ": 房型明细必须是对象。");
continue;
}
String roomTypeCode = textAt(item, "room_type_code");
if (roomTypeCode != null && !directoryService.isKnownRoomTypeCode(roomTypeCode)) {
details.add("business_fields.room_items." + index + ".room_type_code: 房型代码不在第一版目录中。");
details.add(fieldPath + "." + index + ".room_type_code: 房型代码不在第一版目录中。");
}
}
}
@@ -670,7 +830,7 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
if (segments.stream().anyMatch(REVIEW_READONLY_ROOT_FIELDS::contains)) {
throw error(HttpStatus.BAD_REQUEST, "V4_REVIEW_POINTER_READONLY", "该复核字段为只读字段,不允许修改。");
}
ensureReviewPointerInsideEditableContainer(card, segments);
ensureReviewPointerInsideEditableContainer(card, confirmedPayload, segments);
if (value != null && value.isContainerNode()) {
throw error(HttpStatus.BAD_REQUEST, "V4_REVIEW_VALUE_INVALID", "复核字段值必须是标量或 null不能替换对象或数组。");
}
@@ -688,6 +848,7 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
private void ensureReviewPointerInsideEditableContainer(
ReservationV4TaskCardSnapshot card,
ObjectNode confirmedPayload,
List<String> segments) {
if (segments.size() < 2) {
throw error(HttpStatus.BAD_REQUEST, "V4_REVIEW_POINTER_NOT_ALLOWED", "复核字段不在当前卡允许编辑字段内。");
@@ -699,6 +860,12 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
}
return;
}
if ("business_fields".equals(root)) {
return;
}
if (!confirmedPayload.path("business_fields").isObject() && BUSINESS_WRITABLE_ROOT_FIELDS.contains(root)) {
return;
}
if (!"business_fields".equals(root)) {
throw error(HttpStatus.BAD_REQUEST, "V4_REVIEW_POINTER_NOT_ALLOWED", "复核字段不在当前业务卡允许编辑字段内。");
}
@@ -774,6 +941,13 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
}
}
private List<String> appendPath(List<String> path, String segment) {
List<String> appended = new ArrayList<>(path.size() + 1);
appended.addAll(path);
appended.add(segment);
return appended;
}
/**
* 判断复核字段是否属于本次允许修正的字段范围,优先使用显式缺失字段清单。
*/

View File

@@ -24,6 +24,7 @@ import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@@ -400,35 +401,60 @@ public class ReservationV4TaskIntakeServiceImpl implements ReservationV4TaskInta
List<String> details = new ArrayList<>();
JsonNode businessFields = displayPayload.path("business_fields");
JsonNode fieldRoot = businessFields.isObject() ? businessFields : displayPayload;
validateRateCode(fieldRoot.path("rate_code"), details);
validateRoomItems(fieldRoot.path("room_items"), details);
validateBusinessCatalogFields(fieldRoot, "business_fields", details);
return details;
}
private void validateRateCode(JsonNode rateCode, List<String> details) {
String code = trimToNull(rateCode == null || !rateCode.isTextual() ? null : rateCode.asText());
if (code != null && !directoryService.isKnownRateCode(code)) {
details.add("business_fields.rate_code: Rate Code 不在第一版目录中。");
private void validateBusinessCatalogFields(JsonNode node, String fieldPath, List<String> details) {
if (node == null || node.isMissingNode() || node.isNull()) {
return;
}
if (node.isObject()) {
Iterator<Map.Entry<String, JsonNode>> fields = node.fields();
while (fields.hasNext()) {
Map.Entry<String, JsonNode> field = fields.next();
String childPath = fieldPath + "." + field.getKey();
if ("rate_code".equals(field.getKey())) {
validateRateCode(field.getValue(), childPath, details);
} else if ("room_items".equals(field.getKey())) {
validateRoomItems(field.getValue(), childPath, details);
} else {
validateBusinessCatalogFields(field.getValue(), childPath, details);
}
}
return;
}
if (node.isArray()) {
for (int index = 0; index < node.size(); index++) {
validateBusinessCatalogFields(node.get(index), fieldPath + "." + index, details);
}
}
}
private void validateRoomItems(JsonNode roomItems, List<String> details) {
private void validateRateCode(JsonNode rateCode, String fieldPath, List<String> details) {
String code = trimToNull(rateCode == null || !rateCode.isTextual() ? null : rateCode.asText());
if (code != null && !directoryService.isKnownRateCode(code)) {
details.add(fieldPath + ": Rate Code 不在第一版目录中。");
}
}
private void validateRoomItems(JsonNode roomItems, String fieldPath, List<String> details) {
if (roomItems == null || roomItems.isMissingNode() || roomItems.isNull()) {
return;
}
if (!roomItems.isArray()) {
details.add("business_fields.room_items: 房型明细必须是数组。");
details.add(fieldPath + ": 房型明细必须是数组。");
return;
}
for (int index = 0; index < roomItems.size(); index++) {
JsonNode item = roomItems.get(index);
if (item == null || !item.isObject()) {
details.add("business_fields.room_items." + index + ": 房型明细必须是对象。");
details.add(fieldPath + "." + index + ": 房型明细必须是对象。");
continue;
}
String roomTypeCode = trimToNull(textAt(item, "room_type_code"));
if (roomTypeCode != null && !directoryService.isKnownRoomTypeCode(roomTypeCode)) {
details.add("business_fields.room_items." + index + ".room_type_code: 房型代码不在第一版目录中。");
details.add(fieldPath + "." + index + ".room_type_code: 房型代码不在第一版目录中。");
}
}
}

View File

@@ -160,8 +160,7 @@ class ReservationV4CommandControllerTest {
.andExpect(status().isOk())
.andExpect(jsonPath("$.basic_information_card.card_status").value("CONFIRMED"))
.andExpect(jsonPath("$.basic_information_card.confirmed_by").value("v4-command-admin"))
.andExpect(jsonPath("$.basic_information_card.confirmed_payload.group_code")
.value("GRP-V4-COMMAND-001"))
.andExpect(jsonPath("$.basic_information_card.confirmed_payload.group_code").doesNotExist())
.andExpect(jsonPath("$.basic_information_card.confirmed_payload.basic_information.market_code")
.value("LEISURE"))
.andExpect(jsonPath("$.basic_information_card.confirmed_payload.basic_information.source_code")
@@ -178,7 +177,8 @@ class ReservationV4CommandControllerTest {
{
"version": 0,
"confirmed_payload": {
"room_items": [{"room_type_code": "TWN", "room_count": 2}]
"room_items": [{"room_type_code": "TWN", "room_count": 2}],
"injected_debug_field": "SHOULD-NOT-PERSIST"
}
}
"""))
@@ -187,6 +187,7 @@ class ReservationV4CommandControllerTest {
.andExpect(jsonPath("$.business_cards[0].card_status").value("CONFIRMED"))
.andExpect(jsonPath("$.business_cards[0].confirmed_payload.room_items[0].room_type_code")
.value("TWN"))
.andExpect(jsonPath("$.business_cards[0].confirmed_payload.injected_debug_field").doesNotExist())
.andExpect(jsonPath("$.business_cards[0].availability.confirmable").value(false))
.andExpect(jsonPath("$.business_cards[0].availability.readonly_reason_code").value("CARD_LOCKED"));
assertAuditCount("V4_CARD_CONFIRM", "v4-command-admin", seeded.orderTask().id().toString(), 2);
@@ -219,6 +220,84 @@ class ReservationV4CommandControllerTest {
.andExpect(jsonPath("$.details[0]").value("basic_information.account_code: Account Code 不在信息系统目录中。"));
}
@Test
void shouldRejectBusinessCardConfirmWhenNestedRoomTypeCodeUnknown() throws Exception {
SeededOrderTask seeded = seedOrderTaskWithBusinessCard(
HOTEL_ID,
"mail-v4-command-confirm-unknown-nested-room-001",
Instant.parse("2026-07-19T01:13:00Z"),
null,
"""
{"card_type":"ROOM_INFORMATION","event_type":"UPDATE_BOOKING","business_fields":{"order_ref":"ORDER-COMMAND","event_type":"UPDATE_BOOKING","after":{"room_items":[{"room_type_code":"UNKNOWN_ROOM","room_count":2}]}}}
""");
confirmBasicCard(seeded);
performAuthorized(mockMvc, adminToken(), post("/api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/confirm",
seeded.orderTask().id(),
seeded.businessCard().id())
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"version": 0
}
"""))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.error_code").value("V4_FIELD_VALIDATION_FAILED"))
.andExpect(jsonPath("$.details[0]").value("business_fields.after.room_items.0.room_type_code: 房型代码不在第一版目录中。"));
}
@Test
void shouldConfirmWrappedBusinessFieldsAndDropInjectedFields() throws Exception {
SeededOrderTask seeded = seedOrderTaskWithBusinessCard(
HOTEL_ID,
"mail-v4-command-confirm-wrapped-whitelist-001",
Instant.parse("2026-07-19T01:13:10Z"),
null,
"""
{"card_type":"ROOM_INFORMATION","event_type":"UPDATE_BOOKING","business_fields":{"order_ref":"ORDER-COMMAND","event_type":"UPDATE_BOOKING","after":{"room_items":[{"room_type_code":"TWN","room_count":2}]}}}
""");
confirmBasicCard(seeded);
performAuthorized(mockMvc, adminToken(), post("/api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/confirm",
seeded.orderTask().id(),
seeded.businessCard().id())
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"version": 0,
"confirmed_payload": {
"business_fields": {
"order_ref": "MUTATED-ORDER",
"event_type": "CANCEL_BOOKING",
"after": {
"room_items": [
{
"room_type_code": "DBL",
"room_count": 1,
"injected_debug_field": "SHOULD-NOT-PERSIST"
}
]
},
"injected_debug_field": "SHOULD-NOT-PERSIST"
}
}
}
"""))
.andExpect(status().isOk())
.andExpect(jsonPath("$.business_cards[0].confirmed_payload.business_fields.order_ref")
.value("ORDER-COMMAND"))
.andExpect(jsonPath("$.business_cards[0].confirmed_payload.business_fields.event_type")
.value("UPDATE_BOOKING"))
.andExpect(jsonPath("$.business_cards[0].confirmed_payload.business_fields.after.room_items[0].room_type_code")
.value("DBL"))
.andExpect(jsonPath("$.business_cards[0].confirmed_payload.business_fields.after.room_items[0].room_count")
.value(1))
.andExpect(jsonPath("$.business_cards[0].confirmed_payload.business_fields.after.room_items[0].injected_debug_field")
.doesNotExist())
.andExpect(jsonPath("$.business_cards[0].confirmed_payload.business_fields.injected_debug_field")
.doesNotExist());
}
@Test
void shouldRejectRepeatedCardConfirm() throws Exception {
SeededOrderTask seeded = seedOrderTask(
@@ -957,6 +1036,17 @@ class ReservationV4CommandControllerTest {
String externalMessageId,
Instant receivedAt,
Long orderId) {
return seedOrderTaskWithBusinessCard(hotelId, externalMessageId, receivedAt, orderId, """
{"card_type":"ROOM_INFORMATION","event_type":"NEW_BOOKING","room_items":[{"room_type_code":"TWN","room_count":2}]}
""");
}
private SeededOrderTask seedOrderTaskWithBusinessCard(
String hotelId,
String externalMessageId,
Instant receivedAt,
Long orderId,
String businessDisplayPayloadJson) {
SourceMessageCaptureResult source = captureSourceMessage(
hotelId,
externalMessageId,
@@ -989,9 +1079,7 @@ class ReservationV4CommandControllerTest {
""");
ReservationV4TaskCardSnapshot businessCard = insertCard(orderTask, hotelId,
ReservationV4CardType.ROOM_INFORMATION.name(), "NEW_BOOKING", 1, 30,
ReservationV4CardStatus.PENDING_CONFIRM.name(), null, """
{"card_type":"ROOM_INFORMATION","event_type":"NEW_BOOKING","room_items":[{"room_type_code":"TWN","room_count":2}]}
""");
ReservationV4CardStatus.PENDING_CONFIRM.name(), null, businessDisplayPayloadJson);
return new SeededOrderTask(orderTask, sourceCard, basicCard, businessCard);
}

View File

@@ -1256,6 +1256,29 @@ class SuperAgentTaskResultControllerTest {
.contains("房型代码不在第一版目录中");
}
@Test
void shouldMarkV4UpdateBookingReviewRequiredWhenNestedRoomTypeCodeUnknown() throws Exception {
SourceMessageCaptureResult source = captureSourceMessage("mail-v4-update-unknown-nested-room-001");
String body = v4UpdateWithUnknownNestedRoomTypeBody("mail-v4-update-unknown-nested-room-001");
mockMvc.perform(signedPost(body, "nonce-v4-update-unknown-nested-room-001"))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.accepted_count").value(1));
String roomValidationErrors = jdbcTemplate.queryForObject("""
SELECT validation_errors_json
FROM workflow_reservation_v4_task_card
WHERE source_message_id = ?
AND card_type = 'ROOM_INFORMATION'
AND card_status = 'REVIEW_REQUIRED'
AND review_status = 'PENDING'
LIMIT 1
""", String.class, source.inboxId());
assertThat(roomValidationErrors)
.contains("business_fields.after.room_items.0.room_type_code")
.contains("房型代码不在第一版目录中");
}
@Test
void shouldCreateV4CancelTraceAndRoomingListTasksInEventOrder() throws Exception {
SourceMessageCaptureResult source = captureSourceMessage("mail-v4-cancel-trace-rooming-001");
@@ -4349,6 +4372,53 @@ class SuperAgentTaskResultControllerTest {
""".formatted(externalSourceMessageId);
}
private String v4UpdateWithUnknownNestedRoomTypeBody(String externalSourceMessageId) {
return """
{
"route_code": null,
"source_message": {
"source_message_id": "%s",
"conversation_id": "thread-v4-update-unknown-room-001",
"subject": "Update booking room type",
"sender": "agent@example.test",
"sent_at": "2026-07-18T02:10:00Z",
"body": "Please update room type.",
"body_content_type": "text/plain",
"attachments": []
},
"order_contexts": [
{
"order_ref": "order-1",
"basic_information": {
"account_code": "QBD_TRAVEL",
"manual_review": null
}
}
],
"message_events": [
{
"order_ref": "order-1",
"event_type": "UPDATE_BOOKING",
"target_order": {
"booking_type": "GROUP",
"locator_type": "GROUP_CODE",
"locator_value": "GRP-V4-UPD-UNKNOWN-ROOM-001"
},
"after": {
"room_items": [
{
"room_type_code": "UNKNOWN_ROOM",
"room_count": 2
}
]
},
"manual_review": null
}
]
}
""".formatted(externalSourceMessageId);
}
private String[] createReadyTaskWithTwoOperaOperations(
String externalMessageId,
String nonce,