完成 V4 目录校验和字段白名单

This commit is contained in:
andy
2026-07-19 12:25:06 +07:00
parent b4f1b3c856
commit 39518b3cf2
17 changed files with 1124 additions and 53 deletions

View File

@@ -149,7 +149,10 @@ class ReservationV4CommandControllerTest {
{
"version": 0,
"confirmed_payload": {
"company": "Q.B.D. TRAVEL GROUP CO., LTD",
"card_type": "BASIC_INFORMATION",
"basic_information": {
"account_code": "QBD_TRAVEL"
},
"group_code": "GRP-V4-COMMAND-001"
}
}
@@ -159,6 +162,10 @@ class ReservationV4CommandControllerTest {
.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.basic_information.market_code")
.value("LEISURE"))
.andExpect(jsonPath("$.basic_information_card.confirmed_payload.basic_information.source_code")
.value("TRAVEL_AGENT"))
.andExpect(jsonPath("$.business_cards[0].availability.confirmable").value(true))
.andExpect(jsonPath("$.business_cards[0].availability.read_only").value(false))
.andExpect(content().string(not(org.hamcrest.Matchers.containsString("private.example.test"))));
@@ -185,6 +192,33 @@ class ReservationV4CommandControllerTest {
assertAuditCount("V4_CARD_CONFIRM", "v4-command-admin", seeded.orderTask().id().toString(), 2);
}
@Test
void shouldRejectBasicInformationConfirmWhenAccountCodeUnknown() throws Exception {
SeededOrderTask seeded = seedOrderTask(
HOTEL_ID,
"mail-v4-command-confirm-unknown-account-001",
Instant.parse("2026-07-19T01:12:00Z"));
performAuthorized(mockMvc, adminToken(), post("/api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/confirm",
seeded.orderTask().id(),
seeded.basicCard().id())
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"version": 0,
"confirmed_payload": {
"card_type": "BASIC_INFORMATION",
"basic_information": {
"account_code": "UNKNOWN_ACCOUNT"
}
}
}
"""))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.error_code").value("V4_FIELD_VALIDATION_FAILED"))
.andExpect(jsonPath("$.details[0]").value("basic_information.account_code: Account Code 不在信息系统目录中。"));
}
@Test
void shouldRejectRepeatedCardConfirm() throws Exception {
SeededOrderTask seeded = seedOrderTask(
@@ -244,12 +278,44 @@ class ReservationV4CommandControllerTest {
.andExpect(jsonPath("$.basic_information_card.review_status").value("RESOLVED"))
.andExpect(jsonPath("$.basic_information_card.confirmed_by").value("v4-command-admin"))
.andExpect(jsonPath("$.basic_information_card.confirmed_payload.basic_information.account_code").value("QBD_TRAVEL"))
.andExpect(jsonPath("$.basic_information_card.confirmed_payload.basic_information.market_code").value("LEISURE"))
.andExpect(jsonPath("$.basic_information_card.confirmed_payload.basic_information.source_code").value("TRAVEL_AGENT"))
.andExpect(jsonPath("$.basic_information_card.review_resolution.field_overrides[0].field_pointer")
.value("/basic_information/account_code"))
.andExpect(jsonPath("$.business_cards[0].availability.confirmable").value(true));
assertAuditCount("V4_CARD_REVIEW_RESOLVE", "v4-command-admin", seeded.orderTask().id().toString(), 1);
}
@Test
void shouldRejectBasicInformationReviewWhenAccountCodeUnknown() throws Exception {
Long confirmedOrderId = 990000000000070012L;
seedReservationOrder(HOTEL_ID, confirmedOrderId);
SeededOrderTask seeded = seedReviewOrderTask(
HOTEL_ID,
"mail-v4-command-review-unknown-account-001",
Instant.parse("2026-07-19T01:21:10Z"),
null,
ReservationV4CardStatus.REVIEW_REQUIRED.name(),
ReservationV4CardStatus.PENDING_CONFIRM.name());
performAuthorized(mockMvc, adminToken(), post(
"/api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/review-resolution",
seeded.orderTask().id(),
seeded.basicCard().id())
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"version": 0,
"confirmed_order_id": "990000000000070012",
"field_overrides": [
{"field_pointer": "/basic_information/account_code", "value": "UNKNOWN_ACCOUNT"}
]
}
"""))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.error_code").value("V4_FIELD_VALIDATION_FAILED"));
}
@Test
void shouldResolveOrderOwnershipStatusWhenConfirmedOrderIdAlreadyBound() throws Exception {
Long confirmedOrderId = 990000000000070009L;
@@ -319,6 +385,54 @@ class ReservationV4CommandControllerTest {
.andExpect(jsonPath("$.business_cards[0].review_resolution.reason").value("确认房型映射"));
}
@Test
void shouldResolveBusinessCardDirectoryValidationError() throws Exception {
SeededOrderTask seeded = seedReviewOrderTaskWithBusinessCard(
HOTEL_ID,
"mail-v4-command-review-directory-001",
Instant.parse("2026-07-19T01:22:05Z"),
990000000000070004L,
ReservationV4CardStatus.PENDING_CONFIRM.name(),
ReservationV4CardStatus.REVIEW_REQUIRED.name(),
"""
{"event_type":"NEW_BOOKING","route_code":"S01","business_fields":{"order_ref":"ORDER-REVIEW","event_type":"NEW_BOOKING","manual_review":true,"room_items":[{"room_type_code":"UNKNOWN_TYPE","room_count":2}]}}
""",
"""
[
{
"field_path": "business_fields.room_items.0.room_type_code",
"field_pointer": "/business_fields/room_items/0/room_type_code",
"message": "房型代码不在第一版目录中。",
"detail": "business_fields.room_items.0.room_type_code: 房型代码不在第一版目录中。"
}
]
""");
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,
"reason": "修正房型目录",
"field_overrides": [
{
"field_pointer": "/business_fields/room_items/0/room_type_code",
"value": "TWN"
}
]
}
"""))
.andExpect(status().isOk())
.andExpect(jsonPath("$.business_cards[0].card_status").value("CONFIRMED"))
.andExpect(jsonPath("$.business_cards[0].confirmed_payload.business_fields.room_items[0].room_type_code")
.value("TWN"))
.andExpect(jsonPath("$.business_cards[0].review_resolution.reason").value("修正房型目录"));
}
@Test
void shouldRejectReviewResolutionWhenResolvedOrderTaskRebindsToDifferentOrder() throws Exception {
Long currentOrderId = 990000000000070111L;
@@ -805,7 +919,13 @@ class ReservationV4CommandControllerTest {
.content("""
{
"version": 0,
"confirmed_payload": {"group_code": "GRP-V4-COMMAND-001"}
"confirmed_payload": {
"card_type": "BASIC_INFORMATION",
"basic_information": {
"account_code": "QBD_TRAVEL"
},
"group_code": "GRP-V4-COMMAND-001"
}
}
"""))
.andExpect(status().isOk());
@@ -865,7 +985,7 @@ class ReservationV4CommandControllerTest {
ReservationV4TaskCardSnapshot basicCard = insertCard(orderTask, hotelId,
ReservationV4CardType.BASIC_INFORMATION.name(), null, 0, 20,
ReservationV4CardStatus.PENDING_CONFIRM.name(), null, """
{"card_type":"BASIC_INFORMATION","company":"Q.B.D. TRAVEL GROUP CO., LTD","group_code":"GRP-V4-COMMAND-001"}
{"card_type":"BASIC_INFORMATION","order_ref":"ORDER-COMMAND","basic_information":{"account_code":"QBD_TRAVEL","manual_review":null}}
""");
ReservationV4TaskCardSnapshot businessCard = insertCard(orderTask, hotelId,
ReservationV4CardType.ROOM_INFORMATION.name(), "NEW_BOOKING", 1, 30,
@@ -902,6 +1022,53 @@ class ReservationV4CommandControllerTest {
String targetResolutionStatus,
String basicStatus,
String businessStatus) {
return seedReviewOrderTaskWithBusinessCard(
hotelId,
externalMessageId,
receivedAt,
orderId,
targetResolutionStatus,
basicStatus,
businessStatus,
"""
{"event_type":"NEW_BOOKING","route_code":"S01","business_fields":{"order_ref":"ORDER-REVIEW","event_type":"NEW_BOOKING","manual_review":true,"room_items":[{"room_type_code":"TWN","room_count":2,"pms_room_type_code":null}]}}
""",
null);
}
private SeededOrderTask seedReviewOrderTaskWithBusinessCard(
String hotelId,
String externalMessageId,
Instant receivedAt,
Long orderId,
String basicStatus,
String businessStatus,
String businessDisplayPayloadJson,
String businessValidationErrorsJson) {
return seedReviewOrderTaskWithBusinessCard(
hotelId,
externalMessageId,
receivedAt,
orderId,
orderId == null
? ReservationV4TargetResolutionStatus.UNRESOLVED.name()
: ReservationV4TargetResolutionStatus.RESOLVED.name(),
basicStatus,
businessStatus,
businessDisplayPayloadJson,
businessValidationErrorsJson);
}
private SeededOrderTask seedReviewOrderTaskWithBusinessCard(
String hotelId,
String externalMessageId,
Instant receivedAt,
Long orderId,
String targetResolutionStatus,
String basicStatus,
String businessStatus,
String businessDisplayPayloadJson,
String businessValidationErrorsJson) {
SourceMessageCaptureResult source = captureSourceMessage(
hotelId,
externalMessageId,
@@ -934,9 +1101,7 @@ class ReservationV4CommandControllerTest {
""");
ReservationV4TaskCardSnapshot businessCard = insertCard(orderTask, hotelId,
ReservationV4CardType.ROOM_INFORMATION.name(), "NEW_BOOKING", 1, 30,
businessStatus, reviewStatusFor(businessStatus), """
{"event_type":"NEW_BOOKING","route_code":"S01","business_fields":{"order_ref":"ORDER-REVIEW","event_type":"NEW_BOOKING","manual_review":true,"room_items":[{"room_type_code":"TWN","room_count":2,"pms_room_type_code":null}]}}
""");
businessStatus, reviewStatusFor(businessStatus), businessDisplayPayloadJson, businessValidationErrorsJson);
return new SeededOrderTask(orderTask, sourceCard, basicCard, businessCard);
}
@@ -980,6 +1145,21 @@ class ReservationV4CommandControllerTest {
String cardStatus,
String reviewStatus,
String displayPayloadJson) {
return insertCard(orderTask, hotelId, cardType, eventType, sourceEventIndex, sortOrder, cardStatus, reviewStatus,
displayPayloadJson, null);
}
private ReservationV4TaskCardSnapshot insertCard(
ReservationV4OrderTaskSnapshot orderTask,
String hotelId,
String cardType,
String eventType,
Integer sourceEventIndex,
Integer sortOrder,
String cardStatus,
String reviewStatus,
String displayPayloadJson,
String validationErrorsJson) {
return workflowRepository.insertTaskCard(new ReservationV4TaskCardDraft(
hotelId,
orderTask.id(),
@@ -995,7 +1175,7 @@ class ReservationV4CommandControllerTest {
{"private_url":"https://private.example.test/raw","raw":"must stay internal"}
""",
displayPayloadJson,
null,
validationErrorsJson,
orderTask.createdAt()));
}

View File

@@ -2,6 +2,7 @@ package cn.nianxx.thhotel.workflows.reservation.control;
import static cn.nianxx.thhotel.support.MockMvcAuthTestSupport.loginToken;
import static cn.nianxx.thhotel.support.MockMvcAuthTestSupport.performAuthorized;
import static org.hamcrest.Matchers.contains;
import static org.hamcrest.Matchers.containsString;
import static org.hamcrest.Matchers.matchesPattern;
import static org.hamcrest.Matchers.not;
@@ -240,8 +241,20 @@ class ReservationV4QueryControllerTest {
.andExpect(jsonPath("$.order_task.target_locator_value").value("GRP-V4-QUERY-001"))
.andExpect(jsonPath("$.source_message_card.card_type").value("SOURCE_MESSAGE_DISPLAY"))
.andExpect(jsonPath("$.basic_information_card.card_type").value("BASIC_INFORMATION"))
.andExpect(jsonPath("$.basic_information_card.fields[?(@.field_pointer=='/basic_information/account_code')].control_type")
.value(contains("select")))
.andExpect(jsonPath("$.basic_information_card.fields[?(@.field_pointer=='/basic_information/account_code')].options_source")
.value(contains("reservation_v4_account_catalog")))
.andExpect(jsonPath("$.basic_information_card.fields[?(@.field_pointer=='/basic_information/market_code')].raw_readonly")
.value(contains(true)))
.andExpect(jsonPath("$.basic_information_card.fields[?(@.field_pointer=='/basic_information/source_code')].raw_readonly")
.value(contains(true)))
.andExpect(jsonPath("$.business_cards[0].card_type").value("ROOM_INFORMATION"))
.andExpect(jsonPath("$.business_cards[0].display_payload.event_type").value("NEW_BOOKING"))
.andExpect(jsonPath("$.business_cards[0].fields[?(@.field_pointer=='/room_items/0/room_type_code')].control_type")
.value(contains("select")))
.andExpect(jsonPath("$.business_cards[0].fields[?(@.field_pointer=='/room_items/0/room_count')].control_type")
.value(contains("number")))
.andExpect(jsonPath("$.business_cards[0].ai_payload_json").doesNotExist())
.andExpect(jsonPath("$.availability.read_only").value(false))
.andExpect(jsonPath("$.availability.confirmable").value(true))
@@ -488,7 +501,7 @@ class ReservationV4QueryControllerTest {
""");
insertCard(orderTask, ReservationV4CardType.BASIC_INFORMATION.name(), null, 0, 20,
ReservationV4CardStatus.PENDING_CONFIRM.name(), null, """
{"card_type":"BASIC_INFORMATION","account_code":"QBD_TRAVEL"}
{"card_type":"BASIC_INFORMATION","order_ref":"order-1","basic_information":{"account_code":"QBD_TRAVEL","market_code":"LEISURE","source_code":"TRAVEL_AGENT"}}
""");
insertCard(orderTask, ReservationV4CardType.ROOM_INFORMATION.name(), "NEW_BOOKING", 1, 30,
ReservationV4CardStatus.PENDING_CONFIRM.name(), null, """

View File

@@ -1206,6 +1206,56 @@ class SuperAgentTaskResultControllerTest {
assertThat(basicReviewCount).isEqualTo(1L);
}
@Test
void shouldMarkV4BasicInformationReviewRequiredWhenAccountCodeUnknown() throws Exception {
SourceMessageCaptureResult source = captureSourceMessage("mail-v4-unknown-account-001");
String body = v4BusinessRootBody("mail-v4-unknown-account-001")
.replace("\"account_code\": \"QBD_TRAVEL\"", "\"account_code\": \"UNKNOWN_ACCOUNT\"")
.replace("GRP-V4-001", "GRP-V4-UNKNOWN-ACCOUNT-001");
mockMvc.perform(signedPost(body, "nonce-v4-unknown-account-001"))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.accepted_count").value(2));
String basicValidationErrors = jdbcTemplate.queryForObject("""
SELECT validation_errors_json
FROM workflow_reservation_v4_task_card
WHERE source_message_id = ?
AND card_type = 'BASIC_INFORMATION'
AND card_status = 'REVIEW_REQUIRED'
AND review_status = 'PENDING'
LIMIT 1
""", String.class, source.inboxId());
assertThat(basicValidationErrors)
.contains("basic_information.account_code")
.contains("Account Code 不在信息系统目录中");
}
@Test
void shouldMarkV4BusinessCardReviewRequiredWhenRoomTypeCodeUnknown() throws Exception {
SourceMessageCaptureResult source = captureSourceMessage("mail-v4-unknown-room-type-001");
String body = v4BusinessRootBody("mail-v4-unknown-room-type-001")
.replace("\"room_type_code\": \"TWN\"", "\"room_type_code\": \"UNKNOWN_ROOM\"")
.replace("GRP-V4-001", "GRP-V4-UNKNOWN-ROOM-001");
mockMvc.perform(signedPost(body, "nonce-v4-unknown-room-type-001"))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.accepted_count").value(2));
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.room_items.0.room_type_code")
.contains("房型代码不在第一版目录中");
}
@Test
void shouldCreateV4CancelTraceAndRoomingListTasksInEventOrder() throws Exception {
SourceMessageCaptureResult source = captureSourceMessage("mail-v4-cancel-trace-rooming-001");