实现V4 Payment附件安全摘要

This commit is contained in:
andy
2026-07-21 16:31:54 +07:00
parent 3438d4daec
commit 4b869a0ecd
15 changed files with 759 additions and 43 deletions

View File

@@ -208,6 +208,89 @@ class ReservationV4CommandControllerTest {
assertAuditCount("V4_CARD_CONFIRM", "v4-command-admin", seeded.orderTask().id().toString(), 2);
}
@Test
void shouldConfirmPaymentCardWithVersionOnlyAndKeepAttachmentIdsReadonly() throws Exception {
SeededOrderTask seeded = seedOrderTaskWithBusinessCard(
HOTEL_ID,
"mail-v4-command-payment-confirm-001",
Instant.parse("2026-07-19T01:12:00Z"),
null,
"GROUP",
"GROUP_CODE",
"GRP-V4-PAYMENT-COMMAND-001",
ReservationV4CardType.PAYMENT.name(),
"PAYMENT",
"""
{
"card_type":"PAYMENT",
"event_type":"PAYMENT",
"business_fields":{"attachment_ids":["att-pay-original-001"]}
}
""");
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": {
"attachment_ids": ["att-pay-replaced-001"]
}
}
}
"""))
.andExpect(status().isOk())
.andExpect(jsonPath("$.order_task.order_task_status").value("COMPLETED"))
.andExpect(jsonPath("$.business_cards[0].card_status").value("CONFIRMED"))
.andExpect(jsonPath("$.business_cards[0].confirmed_payload.business_fields.attachment_ids[0]")
.value("att-pay-original-001"))
.andExpect(content().string(not(containsString("att-pay-replaced-001"))));
}
@Test
void shouldRejectPaymentReviewResolutionWhenTryingToModifyAttachmentIds() throws Exception {
SeededOrderTask seeded = seedReviewOrderTaskWithBusinessCard(
HOTEL_ID,
"mail-v4-command-payment-review-readonly-001",
Instant.parse("2026-07-19T01:12:30Z"),
990000000000070120L,
ReservationV4TargetResolutionStatus.RESOLVED.name(),
ReservationV4CardStatus.CONFIRMED.name(),
ReservationV4CardStatus.REVIEW_REQUIRED.name(),
ReservationV4CardType.PAYMENT.name(),
"PAYMENT",
"""
{
"card_type":"PAYMENT",
"event_type":"PAYMENT",
"business_fields":{"attachment_ids":["att-pay-review-original-001"]}
}
""",
"""
[{"field":"business_fields.attachment_ids.0","message":"付款凭证附件集合只读"}]
""");
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/attachment_ids/0", "value": "att-pay-review-replaced-001"}
]
}
"""))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.error_code").value("V4_REVIEW_POINTER_READONLY"));
}
@Test
void shouldSetGroupBookingStatusDefWhenConfirmingGroupRoomingListFromTen() throws Exception {
Long orderId = 990000000000080001L;

View File

@@ -17,9 +17,12 @@ import cn.nianxx.thhotel.platform.identity.common.enums.PlatformUserStatus;
import cn.nianxx.thhotel.platform.identity.domain.PlatformUserEntity;
import cn.nianxx.thhotel.platform.identity.repository.PlatformIdentityRepository;
import cn.nianxx.thhotel.platform.identity.service.impl.AuthPasswordService;
import cn.nianxx.thhotel.platform.message.common.dto.SourceMessageMediaSummary;
import cn.nianxx.thhotel.platform.message.common.request.CaptureSourceMessageCommand;
import cn.nianxx.thhotel.platform.message.common.request.CaptureSourceMessageMedia;
import cn.nianxx.thhotel.platform.message.common.result.SourceMessageCaptureResult;
import cn.nianxx.thhotel.platform.message.service.SourceMessageCaptureService;
import cn.nianxx.thhotel.platform.message.service.SourceMessageQueryService;
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationAiBatchDraft;
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationAiTransitionDraft;
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationAuditLogDraft;
@@ -78,6 +81,8 @@ class ReservationV4QueryControllerTest {
@Autowired
private SourceMessageCaptureService captureService;
@Autowired
private SourceMessageQueryService sourceMessageQueryService;
@Autowired
private ReservationV4WorkflowRepository workflowRepository;
@@ -593,6 +598,197 @@ class ReservationV4QueryControllerTest {
.andExpect(content().string(not(containsString("oss.example.test"))));
}
@Test
void shouldReturnPaymentAttachmentSafeSummariesWithoutUrlsOrEditableAttachmentIds() throws Exception {
SourceMessageCaptureResult source = captureSourceMessage(
"mail-v4-query-payment-attachments-001",
"V4 Query Payment Attachments",
Instant.parse("2026-07-18T03:02:47Z"),
HOTEL_ID,
List.of(
new CaptureSourceMessageMedia(
"ATTACHMENT",
"payment-slip.png",
"image/png",
12345L,
"https://oss.example.test/payment-slip.png?signature=secret",
"att-pay-image-001"),
new CaptureSourceMessageMedia(
"ATTACHMENT",
"payment-voucher.pdf",
"application/pdf",
67890L,
"oss://private/payment-voucher.pdf",
"att-pay-pdf-001")));
ReservationV4OrderTaskSnapshot orderTask = seedOrderTaskWithBusinessCardType(
source,
990000000000003701L,
Instant.parse("2026-07-18T03:02:47Z"),
"GROUP",
"GROUP_CODE",
"GRP-V4-PAYMENT-SAFE-001",
ReservationV4CardType.PAYMENT.name(),
"PAYMENT",
ReservationV4CardStatus.CONFIRMED.name(),
ReservationV4CardStatus.PENDING_CONFIRM.name(),
"""
{
"card_type":"PAYMENT",
"event_type":"PAYMENT",
"business_fields":{
"attachment_ids":["att-pay-image-001","att-pay-pdf-001"]
}
}
""",
null);
performAuthorized(mockMvc, adminToken(), get("/api/reservation/order-tasks/{orderTaskId}", orderTask.id())
.param("hotel_id", HOTEL_ID))
.andExpect(status().isOk())
.andExpect(jsonPath("$.business_cards[0].card_type").value("PAYMENT"))
.andExpect(jsonPath("$.business_cards[0].display_payload.payment_attachments.length()").value(2))
.andExpect(jsonPath("$.business_cards[0].display_payload.payment_attachments[0].attachment_id")
.value("att-pay-image-001"))
.andExpect(jsonPath("$.business_cards[0].display_payload.payment_attachments[0].file_name")
.value("payment-slip.png"))
.andExpect(jsonPath("$.business_cards[0].display_payload.payment_attachments[0].content_type")
.value("image/png"))
.andExpect(jsonPath("$.business_cards[0].display_payload.payment_attachments[0].size_bytes")
.value(12345))
.andExpect(jsonPath("$.business_cards[0].display_payload.payment_attachments[0].is_image")
.value(true))
.andExpect(jsonPath("$.business_cards[0].display_payload.payment_attachments[0].preview_available")
.value(true))
.andExpect(jsonPath("$.business_cards[0].display_payload.payment_attachments[0].download_available")
.value(true))
.andExpect(jsonPath("$.business_cards[0].display_payload.payment_attachments[0].external_media_id")
.value("att-pay-image-001"))
.andExpect(jsonPath("$.business_cards[0].display_payload.payment_attachments[1].attachment_id")
.value("att-pay-pdf-001"))
.andExpect(jsonPath("$.business_cards[0].display_payload.payment_attachments[1].is_image")
.value(false))
.andExpect(jsonPath("$.business_cards[0].display_payload.payment_attachments[1].preview_available")
.value(false))
.andExpect(jsonPath("$.business_cards[0].display_payload.payment_attachments[1].download_available")
.value(true))
.andExpect(jsonPath("$.business_cards[0].fields[?(@.field_pointer=='/business_fields/attachment_ids/0')]")
.doesNotExist())
.andExpect(jsonPath("$.business_cards[0].fields[?(@.field_pointer=='/business_fields/attachment_ids/1')]")
.doesNotExist())
.andExpect(content().string(not(containsString("externalUrl"))))
.andExpect(content().string(not(containsString("download_url"))))
.andExpect(content().string(not(containsString("signedUrl"))))
.andExpect(content().string(not(containsString("https://oss.example.test"))))
.andExpect(content().string(not(containsString("oss://private"))));
}
@Test
void shouldNotExposeCrossHotelPaymentAttachmentSummaryWhenV4ReferenceIsPolluted() throws Exception {
SourceMessageCaptureResult otherSource = captureSourceMessage(
"mail-v4-query-payment-cross-hotel-001",
"V4 Query Payment Cross Hotel Attachments",
Instant.parse("2026-07-18T03:02:48Z"),
OTHER_HOTEL_ID,
List.of(new CaptureSourceMessageMedia(
"ATTACHMENT",
"other-hotel-payment-secret.pdf",
"application/pdf",
55555L,
"https://oss.example.test/other-hotel-payment-secret.pdf",
"att-other-hotel-secret-001")));
ReservationV4OrderTaskSnapshot orderTask = seedOrderTaskWithBusinessCardType(
otherSource,
990000000000003702L,
Instant.parse("2026-07-18T03:02:48Z"),
"GROUP",
"GROUP_CODE",
"GRP-V4-PAYMENT-CROSS-HOTEL-001",
ReservationV4CardType.PAYMENT.name(),
"PAYMENT",
ReservationV4CardStatus.CONFIRMED.name(),
ReservationV4CardStatus.PENDING_CONFIRM.name(),
"""
{
"card_type":"PAYMENT",
"event_type":"PAYMENT",
"business_fields":{
"attachment_ids":["att-other-hotel-secret-001"]
}
}
""",
null);
performAuthorized(mockMvc, adminToken(), get("/api/reservation/order-tasks/{orderTaskId}", orderTask.id())
.param("hotel_id", HOTEL_ID))
.andExpect(status().isOk())
.andExpect(jsonPath("$.business_cards[0].display_payload.payment_attachments[0].attachment_id")
.value("att-other-hotel-secret-001"))
.andExpect(jsonPath("$.business_cards[0].display_payload.payment_attachments[0].file_name")
.doesNotExist())
.andExpect(jsonPath("$.business_cards[0].display_payload.payment_attachments[0].content_type")
.doesNotExist())
.andExpect(jsonPath("$.business_cards[0].display_payload.payment_attachments[0].download_available")
.value(false))
.andExpect(jsonPath("$.business_cards[0].display_payload.payment_attachments[0].unavailable_reason_code")
.value("ATTACHMENT_NOT_FOUND"))
.andExpect(content().string(not(containsString("other-hotel-payment-secret.pdf"))))
.andExpect(content().string(not(containsString("https://oss.example.test/other-hotel-payment-secret.pdf"))));
}
@Test
void shouldNotMatchPaymentAttachmentByInternalSourceMessageMediaId() throws Exception {
SourceMessageCaptureResult source = captureSourceMessage(
"mail-v4-query-payment-internal-media-id-001",
"V4 Query Payment Internal Media Id",
Instant.parse("2026-07-18T03:02:49Z"),
HOTEL_ID,
List.of(new CaptureSourceMessageMedia(
"ATTACHMENT",
"payment-internal-id-should-not-match.png",
"image/png",
34567L,
"https://oss.example.test/payment-internal-id-should-not-match.png",
"att-payment-real-external-001")));
SourceMessageMediaSummary mediaSummary = sourceMessageQueryService
.getMediaSummaries(HOTEL_ID, source.inboxId())
.get(0);
ReservationV4OrderTaskSnapshot orderTask = seedOrderTaskWithBusinessCardType(
source,
990000000000003703L,
Instant.parse("2026-07-18T03:02:49Z"),
"GROUP",
"GROUP_CODE",
"GRP-V4-PAYMENT-INTERNAL-MEDIA-ID-001",
ReservationV4CardType.PAYMENT.name(),
"PAYMENT",
ReservationV4CardStatus.CONFIRMED.name(),
ReservationV4CardStatus.PENDING_CONFIRM.name(),
"""
{
"card_type":"PAYMENT",
"event_type":"PAYMENT",
"business_fields":{
"attachment_ids":["%s"]
}
}
""".formatted(mediaSummary.mediaId()),
null);
performAuthorized(mockMvc, adminToken(), get("/api/reservation/order-tasks/{orderTaskId}", orderTask.id())
.param("hotel_id", HOTEL_ID))
.andExpect(status().isOk())
.andExpect(jsonPath("$.business_cards[0].display_payload.payment_attachments[0].attachment_id")
.value(mediaSummary.mediaId()))
.andExpect(jsonPath("$.business_cards[0].display_payload.payment_attachments[0].file_name")
.doesNotExist())
.andExpect(jsonPath("$.business_cards[0].display_payload.payment_attachments[0].download_available")
.value(false))
.andExpect(jsonPath("$.business_cards[0].display_payload.payment_attachments[0].unavailable_reason_code")
.value("ATTACHMENT_NOT_FOUND"))
.andExpect(content().string(not(containsString("payment-internal-id-should-not-match.png"))))
.andExpect(content().string(not(containsString("att-payment-real-external-001"))));
}
@Test
void shouldReturnReadonlyRoomInformationDisplayModelForCancelBooking() throws Exception {
Long orderId = 990000000000777002L;
@@ -1235,6 +1431,67 @@ class ReservationV4QueryControllerTest {
return orderTask;
}
private ReservationV4OrderTaskSnapshot seedOrderTaskWithBusinessCardType(
SourceMessageCaptureResult source,
Long aiBatchId,
Instant receivedAt,
String targetBookingType,
String targetLocatorType,
String targetLocatorValue,
String businessCardType,
String businessEventType,
String basicCardStatus,
String businessCardStatus,
String businessDisplayPayloadJson,
String confirmedPayloadJson) {
LocalDateTime now = LocalDateTime.ofInstant(receivedAt.plusSeconds(10), ZoneOffset.UTC);
ReservationV4OrderTaskSnapshot orderTask = workflowRepository.findOrCreateOrderTask(new ReservationV4OrderTaskDraft(
HOTEL_ID,
source.inboxId(),
aiBatchId,
"order-generic-card-" + source.inboxId(),
1,
null,
targetBookingType,
targetLocatorType,
targetLocatorValue,
ReservationV4TargetResolutionStatus.RESOLVED.name(),
ReservationV4OrderTaskStatus.OPEN.name(),
LocalDateTime.ofInstant(receivedAt, ZoneOffset.UTC),
now));
insertCard(orderTask, ReservationV4CardType.SOURCE_MESSAGE_DISPLAY.name(), null, 0, 10,
ReservationV4CardStatus.READONLY.name(), null, """
{"card_type":"SOURCE_MESSAGE_DISPLAY","source_message":{"subject":"V4 Query Business"}}
""");
insertCard(orderTask, ReservationV4CardType.BASIC_INFORMATION.name(), null, 0, 20,
basicCardStatus, reviewStatusFor(basicCardStatus), """
{
"card_type":"BASIC_INFORMATION",
"order_ref":"order-1",
"basic_information":{"account_code":"QBD_TRAVEL","market_code":"LEISURE","source_code":"TRAVEL_AGENT"}
}
""");
ReservationV4TaskCardSnapshot businessCard = insertCard(
orderTask,
businessCardType,
businessEventType,
1,
30,
businessCardStatus,
reviewStatusFor(businessCardStatus),
businessDisplayPayloadJson);
if (confirmedPayloadJson != null) {
workflowRepository.confirmTaskCardWithVersion(
HOTEL_ID,
businessCard.id(),
businessCard.version(),
confirmedPayloadJson,
"v4-query-admin",
now);
}
return orderTask;
}
private void seedConfirmedRoomInformationProjection(
Long orderId,
String externalMessageId,
@@ -1370,6 +1627,15 @@ class ReservationV4QueryControllerTest {
String subject,
Instant receivedAt,
String hotelId) {
return captureSourceMessage(externalMessageId, subject, receivedAt, hotelId, List.of());
}
private SourceMessageCaptureResult captureSourceMessage(
String externalMessageId,
String subject,
Instant receivedAt,
String hotelId,
List<CaptureSourceMessageMedia> mediaItems) {
return captureService.capture(new CaptureSourceMessageCommand(
hotelId,
"AGENTBUS",
@@ -1386,7 +1652,7 @@ class ReservationV4QueryControllerTest {
"<html><body>Please handle V4 query message.</body></html>",
"{\"source\":{\"external_message_id\":\"" + externalMessageId + "\"}}",
"agentbus-outlook-v1",
List.of()
mediaItems
));
}

View File

@@ -1091,6 +1091,29 @@ class SuperAgentTaskResultControllerTest {
.contains("att-pay-1")
.contains("payment-slip.jpg")
.doesNotContain("https://oss.example.test");
Long orderTaskId = jdbcTemplate.queryForObject("""
SELECT id
FROM workflow_reservation_v4_order_task
WHERE source_message_id = ?
AND order_ref = 'order-1'
LIMIT 1
""", Long.class, source.inboxId());
performAuthorized(mockMvc, adminToken(), get("/api/reservation/order-tasks/{orderTaskId}", orderTaskId)
.param("hotel_id", "HOTEL-TEST"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.business_cards[?(@.card_type=='PAYMENT')].display_payload.payment_attachments[0].attachment_id")
.value(contains("att-pay-1")))
.andExpect(jsonPath("$.business_cards[?(@.card_type=='PAYMENT')].display_payload.payment_attachments[0].file_name")
.value(contains("payment-slip.jpg")))
.andExpect(jsonPath("$.business_cards[?(@.card_type=='PAYMENT')].display_payload.payment_attachments[0].content_type")
.value(contains("image/jpeg")))
.andExpect(jsonPath("$.business_cards[?(@.card_type=='PAYMENT')].display_payload.payment_attachments[0].is_image")
.value(contains(true)))
.andExpect(jsonPath("$.business_cards[?(@.card_type=='PAYMENT')].display_payload.payment_attachments[0].download_available")
.value(contains(true)))
.andExpect(content().string(not(containsString("\"field_pointer\":\"/business_fields/attachment_ids"))))
.andExpect(content().string(not(containsString("https://oss.example.test"))))
.andExpect(content().string(not(containsString("raw-event-attachment.jpg"))));
List<String> displayPayloads = jdbcTemplate.queryForList("""
SELECT display_payload_json
FROM workflow_reservation_v4_task_card