实现V4 Payment附件安全摘要
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
package cn.nianxx.thhotel.platform.message.common.dto;
|
||||
|
||||
/**
|
||||
* SourceMessage 媒体安全摘要。该 DTO 不包含 externalUrl,只供普通业务查询做附件 ID、名称、类型和大小匹配。
|
||||
*
|
||||
* @param mediaId 媒体引用内部 ID
|
||||
* @param mediaType 媒体类型,例如 ATTACHMENT 或 INLINE_IMAGE
|
||||
* @param fileName 附件或内联图片文件名
|
||||
* @param contentType 媒体 MIME 类型
|
||||
* @param sizeBytes 媒体大小字节数
|
||||
* @param externalMediaId 外部系统媒体 ID,用于前端在原文权限接口返回的媒体对象中匹配
|
||||
* @param externalUrlPresent 是否存在受控原文读取 URL;不返回 URL 值本身
|
||||
*/
|
||||
public record SourceMessageMediaSummary(
|
||||
String mediaId,
|
||||
String mediaType,
|
||||
String fileName,
|
||||
String contentType,
|
||||
Long sizeBytes,
|
||||
String externalMediaId,
|
||||
boolean externalUrlPresent
|
||||
) {
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package cn.nianxx.thhotel.platform.message.repository;
|
||||
|
||||
import cn.nianxx.thhotel.platform.message.common.dto.SourceMessageInboxDraft;
|
||||
import cn.nianxx.thhotel.platform.message.common.dto.SourceMessageInboxSnapshot;
|
||||
import cn.nianxx.thhotel.platform.message.common.dto.SourceMessageMediaSummary;
|
||||
import cn.nianxx.thhotel.platform.message.common.dto.SourceMessageOriginalAccessAuditDraft;
|
||||
import cn.nianxx.thhotel.platform.message.common.dto.SourceMessageOriginalContent;
|
||||
import cn.nianxx.thhotel.platform.message.common.dto.SourceMessageOriginalMediaItem;
|
||||
@@ -280,6 +281,26 @@ public class MybatisSourceMessageInboxRepository implements SourceMessageInboxRe
|
||||
return Optional.ofNullable(payload).map(SourceMessagePayloadEntity::getPayloadJson);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询 SourceMessage 媒体安全摘要,只用于同酒店业务卡附件匹配,不返回外部 URL。
|
||||
*/
|
||||
@Override
|
||||
public List<SourceMessageMediaSummary> findMediaSummaries(String hotelId, Long id) {
|
||||
if (!hasText(hotelId) || id == null) {
|
||||
return List.of();
|
||||
}
|
||||
SourceMessageInboxEntity inbox = inboxMapper.selectById(id);
|
||||
if (inbox == null || !trim(hotelId).equals(inbox.getHotelId())) {
|
||||
return List.of();
|
||||
}
|
||||
return mediaMapper.selectList(Wrappers.<SourceMessageMediaEntity>lambdaQuery()
|
||||
.eq(SourceMessageMediaEntity::getInboxId, id)
|
||||
.orderByAsc(SourceMessageMediaEntity::getId))
|
||||
.stream()
|
||||
.map(this::toMediaSummary)
|
||||
.toList();
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录重复投递 payload 差异,不覆盖第一次保存的原始 payload。
|
||||
*/
|
||||
@@ -474,6 +495,21 @@ public class MybatisSourceMessageInboxRepository implements SourceMessageInboxRe
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将媒体 Entity 转成安全摘要,保留 URL 是否存在的布尔信息但不暴露 URL 值。
|
||||
*/
|
||||
private SourceMessageMediaSummary toMediaSummary(SourceMessageMediaEntity entity) {
|
||||
return new SourceMessageMediaSummary(
|
||||
entity.getId() == null ? null : entity.getId().toString(),
|
||||
entity.getMediaType(),
|
||||
entity.getFileName(),
|
||||
entity.getContentType(),
|
||||
entity.getSizeBytes(),
|
||||
entity.getExternalMediaId(),
|
||||
hasText(entity.getExternalUrl())
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算正文内容 SHA-256,用于后续排查正文是否被意外改写。
|
||||
*/
|
||||
|
||||
@@ -2,6 +2,7 @@ package cn.nianxx.thhotel.platform.message.repository;
|
||||
|
||||
import cn.nianxx.thhotel.platform.message.common.dto.SourceMessageInboxDraft;
|
||||
import cn.nianxx.thhotel.platform.message.common.dto.SourceMessageInboxSnapshot;
|
||||
import cn.nianxx.thhotel.platform.message.common.dto.SourceMessageMediaSummary;
|
||||
import cn.nianxx.thhotel.platform.message.common.dto.SourceMessageOriginalAccessAuditDraft;
|
||||
import cn.nianxx.thhotel.platform.message.common.dto.SourceMessageOriginalContent;
|
||||
import cn.nianxx.thhotel.platform.message.common.dto.SourceMessagePayloadDuplicateDraft;
|
||||
@@ -83,6 +84,11 @@ public interface SourceMessageInboxRepository {
|
||||
*/
|
||||
Optional<String> findPayloadJson(Long id);
|
||||
|
||||
/**
|
||||
* 按酒店和内部 SourceMessage ID 读取媒体安全摘要,不返回 externalUrl。
|
||||
*/
|
||||
List<SourceMessageMediaSummary> findMediaSummaries(String hotelId, Long id);
|
||||
|
||||
/**
|
||||
* 幂等命中但 payload 变化时,标记差异并保存安全排查摘要。
|
||||
*/
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package cn.nianxx.thhotel.platform.message.service;
|
||||
|
||||
import cn.nianxx.thhotel.platform.message.common.dto.SourceMessageMediaSummary;
|
||||
import cn.nianxx.thhotel.platform.message.common.dto.SourceMessageSummaryResponse;
|
||||
import cn.nianxx.thhotel.platform.message.common.request.SourceMessageQueryRequest;
|
||||
import cn.nianxx.thhotel.platform.message.common.result.SourceMessagePageResult;
|
||||
@@ -37,6 +38,15 @@ public interface SourceMessageQueryService {
|
||||
*/
|
||||
Optional<SourceMessageSummaryResponse> getSummary(Long inboxId);
|
||||
|
||||
/**
|
||||
* 按酒店和内部 SourceMessage ID 读取媒体安全摘要,供业务卡按附件 ID 匹配展示,不返回正文、HTML 或 URL。
|
||||
*
|
||||
* @param hotelId 酒店上下文 ID
|
||||
* @param inboxId 内部 SourceMessage Inbox ID
|
||||
* @return 媒体安全摘要列表
|
||||
*/
|
||||
List<SourceMessageMediaSummary> getMediaSummaries(String hotelId, Long inboxId);
|
||||
|
||||
/**
|
||||
* 按内部 SourceMessage ID 批量读取安全摘要,供业务列表避免逐条查询。
|
||||
*
|
||||
|
||||
@@ -3,6 +3,7 @@ package cn.nianxx.thhotel.platform.message.service.impl;
|
||||
import cn.nianxx.thhotel.platform.common.time.UtcTimeFormatter;
|
||||
import cn.nianxx.thhotel.platform.hotel.service.HotelContextService;
|
||||
import cn.nianxx.thhotel.platform.message.common.dto.SourceMessageInboxSnapshot;
|
||||
import cn.nianxx.thhotel.platform.message.common.dto.SourceMessageMediaSummary;
|
||||
import cn.nianxx.thhotel.platform.message.common.dto.SourceMessageSummaryResponse;
|
||||
import cn.nianxx.thhotel.platform.message.common.request.SourceMessageQueryRequest;
|
||||
import cn.nianxx.thhotel.platform.message.common.result.SourceMessagePageResult;
|
||||
@@ -69,6 +70,14 @@ public class SourceMessageQueryServiceImpl implements SourceMessageQueryService
|
||||
return inboxRepository.findById(inboxId).map(this::toSummary);
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取 SourceMessage 媒体安全摘要,不返回正文、HTML、附件 URL 或原始 payload。
|
||||
*/
|
||||
@Override
|
||||
public List<SourceMessageMediaSummary> getMediaSummaries(String hotelId, Long inboxId) {
|
||||
return inboxRepository.findMediaSummaries(hotelContextService.resolveCurrentHotelId(hotelId), inboxId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量读取 SourceMessage 安全摘要,用于业务列表预取主题等摘要字段,避免逐条查询。
|
||||
*/
|
||||
|
||||
@@ -80,6 +80,7 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
|
||||
private static final Set<String> REVIEW_READONLY_ROOT_FIELDS = Set.of(
|
||||
"ai_payload_json",
|
||||
"attachments",
|
||||
"attachment_ids",
|
||||
"blocking_points",
|
||||
"card_type",
|
||||
"card_status",
|
||||
@@ -537,7 +538,7 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
|
||||
overlayRoomInformationPayload(orderTask, card, payload, submittedPayload);
|
||||
return;
|
||||
}
|
||||
overlayBusinessPayload(payload, submittedPayload);
|
||||
overlayBusinessPayload(card, payload, submittedPayload);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -557,15 +558,18 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
|
||||
/**
|
||||
* 业务卡只合并展示快照中已经存在且后端允许编辑的叶子字段,不接受新增字段或整体替换对象 / 数组。
|
||||
*/
|
||||
private void overlayBusinessPayload(ObjectNode payload, ObjectNode submittedPayload) {
|
||||
private void overlayBusinessPayload(
|
||||
ReservationV4TaskCardSnapshot card,
|
||||
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);
|
||||
overlayEditableBusinessLeaves(card, (ObjectNode) businessFields, submittedRoot, List.of(), true);
|
||||
return;
|
||||
}
|
||||
overlayEditableBusinessLeaves(payload, submittedRoot, List.of(), false);
|
||||
overlayEditableBusinessLeaves(card, payload, submittedRoot, List.of(), false);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -690,6 +694,7 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
|
||||
}
|
||||
|
||||
private void overlayEditableBusinessLeaves(
|
||||
ReservationV4TaskCardSnapshot card,
|
||||
ObjectNode target,
|
||||
JsonNode submitted,
|
||||
List<String> path,
|
||||
@@ -701,15 +706,16 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
|
||||
while (fields.hasNext()) {
|
||||
Map.Entry<String, JsonNode> field = fields.next();
|
||||
List<String> childPath = appendPath(path, field.getKey());
|
||||
if (!isBusinessPathWritable(childPath, wrappedBusinessFields)) {
|
||||
if (!isBusinessPathWritable(card, childPath, wrappedBusinessFields)) {
|
||||
continue;
|
||||
}
|
||||
JsonNode submittedValue = submitted.get(field.getKey());
|
||||
overlayEditableBusinessValue(target, field.getKey(), field.getValue(), submittedValue, childPath, wrappedBusinessFields);
|
||||
overlayEditableBusinessValue(card, target, field.getKey(), field.getValue(), submittedValue, childPath, wrappedBusinessFields);
|
||||
}
|
||||
}
|
||||
|
||||
private void overlayEditableBusinessArray(
|
||||
ReservationV4TaskCardSnapshot card,
|
||||
ArrayNode target,
|
||||
JsonNode submitted,
|
||||
List<String> path,
|
||||
@@ -720,11 +726,12 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
|
||||
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);
|
||||
overlayEditableBusinessValue(card, target, index, target.get(index), submitted.get(index), childPath, wrappedBusinessFields);
|
||||
}
|
||||
}
|
||||
|
||||
private void overlayEditableBusinessValue(
|
||||
ReservationV4TaskCardSnapshot card,
|
||||
ObjectNode parent,
|
||||
String fieldName,
|
||||
JsonNode currentValue,
|
||||
@@ -732,11 +739,11 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
|
||||
List<String> path,
|
||||
boolean wrappedBusinessFields) {
|
||||
if (currentValue != null && currentValue.isObject()) {
|
||||
overlayEditableBusinessLeaves((ObjectNode) currentValue, submittedValue, path, wrappedBusinessFields);
|
||||
overlayEditableBusinessLeaves(card, (ObjectNode) currentValue, submittedValue, path, wrappedBusinessFields);
|
||||
return;
|
||||
}
|
||||
if (currentValue != null && currentValue.isArray()) {
|
||||
overlayEditableBusinessArray((ArrayNode) currentValue, submittedValue, path, wrappedBusinessFields);
|
||||
overlayEditableBusinessArray(card, (ArrayNode) currentValue, submittedValue, path, wrappedBusinessFields);
|
||||
return;
|
||||
}
|
||||
if (submittedValue != null && !submittedValue.isMissingNode() && !submittedValue.isContainerNode()) {
|
||||
@@ -745,6 +752,7 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
|
||||
}
|
||||
|
||||
private void overlayEditableBusinessValue(
|
||||
ReservationV4TaskCardSnapshot card,
|
||||
ArrayNode parent,
|
||||
int index,
|
||||
JsonNode currentValue,
|
||||
@@ -752,11 +760,11 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
|
||||
List<String> path,
|
||||
boolean wrappedBusinessFields) {
|
||||
if (currentValue != null && currentValue.isObject()) {
|
||||
overlayEditableBusinessLeaves((ObjectNode) currentValue, submittedValue, path, wrappedBusinessFields);
|
||||
overlayEditableBusinessLeaves(card, (ObjectNode) currentValue, submittedValue, path, wrappedBusinessFields);
|
||||
return;
|
||||
}
|
||||
if (currentValue != null && currentValue.isArray()) {
|
||||
overlayEditableBusinessArray((ArrayNode) currentValue, submittedValue, path, wrappedBusinessFields);
|
||||
overlayEditableBusinessArray(card, (ArrayNode) currentValue, submittedValue, path, wrappedBusinessFields);
|
||||
return;
|
||||
}
|
||||
if (submittedValue != null && !submittedValue.isMissingNode() && !submittedValue.isContainerNode()) {
|
||||
@@ -764,10 +772,16 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isBusinessPathWritable(List<String> path, boolean wrappedBusinessFields) {
|
||||
private boolean isBusinessPathWritable(
|
||||
ReservationV4TaskCardSnapshot card,
|
||||
List<String> path,
|
||||
boolean wrappedBusinessFields) {
|
||||
if (path == null || path.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
if (ReservationV4CardType.PAYMENT.name().equals(card.cardType()) && "attachment_ids".equals(path.get(0))) {
|
||||
return false;
|
||||
}
|
||||
if (path.stream().anyMatch(REVIEW_READONLY_ROOT_FIELDS::contains)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package cn.nianxx.thhotel.workflows.reservation.service.impl;
|
||||
import cn.nianxx.thhotel.platform.common.time.UtcTimeFormatter;
|
||||
import cn.nianxx.thhotel.platform.hotel.service.HotelContextException;
|
||||
import cn.nianxx.thhotel.platform.hotel.service.HotelContextService;
|
||||
import cn.nianxx.thhotel.platform.message.common.dto.SourceMessageMediaSummary;
|
||||
import cn.nianxx.thhotel.platform.message.common.dto.SourceMessageSummaryResponse;
|
||||
import cn.nianxx.thhotel.platform.message.service.SourceMessageQueryService;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationAiTransitionSnapshot;
|
||||
@@ -90,6 +91,8 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService
|
||||
private static final String LOCATOR_TYPE_GROUP_CODE = "GROUP_CODE";
|
||||
private static final String WRITE_TARGET_CONFIRMED_PAYLOAD = "confirmed_payload";
|
||||
private static final String WRITE_TARGET_REVIEW_FIELD_OVERRIDES = "review_resolution.field_overrides";
|
||||
private static final String PAYMENT_UNAVAILABLE_ATTACHMENT_NOT_FOUND = "ATTACHMENT_NOT_FOUND";
|
||||
private static final String PAYMENT_UNAVAILABLE_ATTACHMENT_URL_UNAVAILABLE = "ATTACHMENT_URL_UNAVAILABLE";
|
||||
private static final String GROUP_BOOKING_STATUS_TEN = "TEN";
|
||||
private static final Map<String, String> GROUP_BOOKING_STATUS_LABELS = Map.of(
|
||||
"TEN", "TEN-Tentative",
|
||||
@@ -253,16 +256,23 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService
|
||||
orderTask.id());
|
||||
ReservationV4ActionAvailabilityResult orderAvailability = orderTaskAvailability(orderTask, queueContext, cards);
|
||||
ReservationV4TaskCardSnapshot basicCard = findFirstCard(cards, ReservationV4CardType.BASIC_INFORMATION.name()).orElse(null);
|
||||
ReservationV4TaskCardResult sourceMessageCard = findFirstCard(cards, ReservationV4CardType.SOURCE_MESSAGE_DISPLAY.name())
|
||||
.map(card -> toCardResult(orderTask, card, orderAvailability, basicCard))
|
||||
ReservationV4TaskCardSnapshot sourceMessageCardSnapshot = findFirstCard(
|
||||
cards,
|
||||
ReservationV4CardType.SOURCE_MESSAGE_DISPLAY.name()).orElse(null);
|
||||
Map<String, PaymentAttachmentSource> paymentAttachmentSources = paymentAttachmentSources(
|
||||
orderTask,
|
||||
sourceMessageCardSnapshot,
|
||||
cards);
|
||||
ReservationV4TaskCardResult sourceMessageCard = Optional.ofNullable(sourceMessageCardSnapshot)
|
||||
.map(card -> toCardResult(orderTask, card, orderAvailability, basicCard, paymentAttachmentSources))
|
||||
.orElse(null);
|
||||
ReservationV4TaskCardResult basicInformationCard = Optional.ofNullable(basicCard)
|
||||
.map(card -> toCardResult(orderTask, card, orderAvailability, basicCard))
|
||||
.map(card -> toCardResult(orderTask, card, orderAvailability, basicCard, paymentAttachmentSources))
|
||||
.orElse(null);
|
||||
List<ReservationV4TaskCardResult> businessCards = cards.stream()
|
||||
.filter(card -> !ReservationV4CardType.SOURCE_MESSAGE_DISPLAY.name().equals(card.cardType()))
|
||||
.filter(card -> !ReservationV4CardType.BASIC_INFORMATION.name().equals(card.cardType()))
|
||||
.map(card -> toCardResult(orderTask, card, orderAvailability, basicCard))
|
||||
.map(card -> toCardResult(orderTask, card, orderAvailability, basicCard, paymentAttachmentSources))
|
||||
.toList();
|
||||
ReservationV4SourceMessageSummaryResult sourceSummary = sourceSummaries(
|
||||
orderTask.hotelId(),
|
||||
@@ -404,8 +414,9 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService
|
||||
ReservationV4OrderTaskSnapshot orderTask,
|
||||
ReservationV4TaskCardSnapshot card,
|
||||
ReservationV4ActionAvailabilityResult orderAvailability,
|
||||
ReservationV4TaskCardSnapshot basicCard) {
|
||||
JsonNode displayPayload = displayPayloadForCard(orderTask, card);
|
||||
ReservationV4TaskCardSnapshot basicCard,
|
||||
Map<String, PaymentAttachmentSource> paymentAttachmentSources) {
|
||||
JsonNode displayPayload = displayPayloadForCard(orderTask, card, paymentAttachmentSources);
|
||||
JsonNode confirmedPayload = confirmedPayloadForCard(card);
|
||||
JsonNode reviewResolution = parseJson(card.reviewResolutionJson());
|
||||
JsonNode validationErrors = parseJson(card.validationErrorsJson());
|
||||
@@ -436,14 +447,18 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService
|
||||
*/
|
||||
private JsonNode displayPayloadForCard(
|
||||
ReservationV4OrderTaskSnapshot orderTask,
|
||||
ReservationV4TaskCardSnapshot card) {
|
||||
ReservationV4TaskCardSnapshot card,
|
||||
Map<String, PaymentAttachmentSource> paymentAttachmentSources) {
|
||||
JsonNode displayPayload = parseJson(card.displayPayloadJson());
|
||||
if (ReservationV4CardType.BASIC_INFORMATION.name().equals(card.cardType())) {
|
||||
return safeBasicInformationPayload(displayPayload);
|
||||
}
|
||||
if (ReservationV4CardType.SOURCE_MESSAGE_DISPLAY.name().equals(card.cardType())
|
||||
|| ReservationV4CardType.SOURCE_MESSAGE_NOTIFICATION.name().equals(card.cardType())) {
|
||||
return displayPayload;
|
||||
return safeBusinessPayload(displayPayload);
|
||||
}
|
||||
if (ReservationV4CardType.PAYMENT.name().equals(card.cardType())) {
|
||||
return paymentDisplayPayload(displayPayload, paymentAttachmentSources);
|
||||
}
|
||||
if (!isRoomInformationEventCard(card) || !displayPayload.isObject()) {
|
||||
return safeBusinessPayload(displayPayload);
|
||||
@@ -517,6 +532,217 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService
|
||||
return copied;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 Payment 卡展示 payload,补充付款凭证附件安全摘要,不返回任何 URL 字段。
|
||||
*/
|
||||
private JsonNode paymentDisplayPayload(
|
||||
JsonNode displayPayload,
|
||||
Map<String, PaymentAttachmentSource> paymentAttachmentSources) {
|
||||
JsonNode safeNode = safeBusinessPayload(displayPayload);
|
||||
if (!safeNode.isObject()) {
|
||||
return safeNode;
|
||||
}
|
||||
ObjectNode safePayload = (ObjectNode) safeNode;
|
||||
ArrayNode summaries = objectMapper.createArrayNode();
|
||||
for (String attachmentId : paymentAttachmentIds(safePayload)) {
|
||||
PaymentAttachmentSource source = paymentAttachmentSources.get(attachmentId);
|
||||
summaries.add(paymentAttachmentSummary(attachmentId, source));
|
||||
}
|
||||
safePayload.set("payment_attachments", summaries);
|
||||
removeSensitiveBusinessPayloadFields(safePayload);
|
||||
return safePayload;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 Payment 卡业务 payload 中读取只读附件 ID 列表,兼容根字段和 business_fields 包裹两种形态。
|
||||
*/
|
||||
private List<String> paymentAttachmentIds(JsonNode payload) {
|
||||
JsonNode attachmentIds = payload.path("business_fields").path("attachment_ids");
|
||||
if (!attachmentIds.isArray()) {
|
||||
attachmentIds = payload.path("attachment_ids");
|
||||
}
|
||||
if (!attachmentIds.isArray()) {
|
||||
return List.of();
|
||||
}
|
||||
List<String> ids = new ArrayList<>();
|
||||
for (JsonNode item : attachmentIds) {
|
||||
if (item != null && item.isTextual() && hasText(item.asText())) {
|
||||
ids.add(item.asText().trim());
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将单个付款附件匹配结果转换成前端可展示的安全摘要。
|
||||
*/
|
||||
private ObjectNode paymentAttachmentSummary(String attachmentId, PaymentAttachmentSource source) {
|
||||
ObjectNode summary = objectMapper.createObjectNode();
|
||||
summary.put("attachment_id", attachmentId);
|
||||
if (source == null) {
|
||||
summary.putNull("file_name");
|
||||
summary.putNull("content_type");
|
||||
summary.putNull("size_bytes");
|
||||
summary.put("is_image", false);
|
||||
summary.put("preview_available", false);
|
||||
summary.put("download_available", false);
|
||||
summary.put("unavailable_reason_code", PAYMENT_UNAVAILABLE_ATTACHMENT_NOT_FOUND);
|
||||
return summary;
|
||||
}
|
||||
putNullableText(summary, "file_name", source.fileName());
|
||||
putNullableText(summary, "content_type", source.contentType());
|
||||
if (source.sizeBytes() == null) {
|
||||
summary.putNull("size_bytes");
|
||||
} else {
|
||||
summary.put("size_bytes", source.sizeBytes());
|
||||
}
|
||||
boolean image = isImageContentType(source.contentType());
|
||||
summary.put("is_image", image);
|
||||
boolean available = source.available();
|
||||
summary.put("preview_available", image && available);
|
||||
summary.put("download_available", available);
|
||||
putNullableText(summary, "external_media_id", firstText(source.externalMediaId(), attachmentId));
|
||||
if (!available) {
|
||||
summary.put("unavailable_reason_code", PAYMENT_UNAVAILABLE_ATTACHMENT_URL_UNAVAILABLE);
|
||||
}
|
||||
return summary;
|
||||
}
|
||||
|
||||
/**
|
||||
* 写入可空文本字段,避免前端在字段不存在和字段为空之间反复做兼容判断。
|
||||
*/
|
||||
private void putNullableText(ObjectNode node, String fieldName, String value) {
|
||||
if (hasText(value)) {
|
||||
node.put(fieldName, value);
|
||||
} else {
|
||||
node.putNull(fieldName);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断附件 MIME 类型是否为图片,用于前端决定缩略图入口是否理论可用。
|
||||
*/
|
||||
private boolean isImageContentType(String contentType) {
|
||||
return contentType != null && contentType.toLowerCase(Locale.ROOT).startsWith("image/");
|
||||
}
|
||||
|
||||
/**
|
||||
* 汇总当前 V4 order task 对应 SourceMessage 的附件安全索引,仅供 Payment 卡按 ID 精确匹配。
|
||||
*/
|
||||
private Map<String, PaymentAttachmentSource> paymentAttachmentSources(
|
||||
ReservationV4OrderTaskSnapshot orderTask,
|
||||
ReservationV4TaskCardSnapshot sourceMessageCard,
|
||||
List<ReservationV4TaskCardSnapshot> cards) {
|
||||
boolean hasPaymentCard = cards != null && cards.stream()
|
||||
.anyMatch(card -> ReservationV4CardType.PAYMENT.name().equals(card.cardType()));
|
||||
if (!hasPaymentCard) {
|
||||
return Map.of();
|
||||
}
|
||||
Map<String, PaymentAttachmentSource> sources = new LinkedHashMap<>();
|
||||
JsonNode sourceDisplayPayload = sourceMessageCard == null
|
||||
? NullNode.getInstance()
|
||||
: parseJson(sourceMessageCard.displayPayloadJson());
|
||||
addSourceMessageCardAttachmentSources(sources, sourceDisplayPayload.path("attachments"));
|
||||
for (SourceMessageMediaSummary media : sourceMessageQueryService.getMediaSummaries(
|
||||
orderTask.hotelId(),
|
||||
orderTask.sourceMessageId())) {
|
||||
addSourceMessageMediaAttachmentSource(sources, media);
|
||||
}
|
||||
return sources;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 SourceMessage 展示卡的包级 attachments[] 中提取附件摘要,优先使用 Agent 包内的附件 ID。
|
||||
*/
|
||||
private void addSourceMessageCardAttachmentSources(
|
||||
Map<String, PaymentAttachmentSource> sources,
|
||||
JsonNode attachments) {
|
||||
if (attachments == null || !attachments.isArray()) {
|
||||
return;
|
||||
}
|
||||
for (JsonNode attachment : attachments) {
|
||||
String attachmentId = firstText(textAt(attachment, "id"), textAt(attachment, "attachment_id"));
|
||||
if (!hasText(attachmentId)) {
|
||||
continue;
|
||||
}
|
||||
String externalMediaId = firstText(
|
||||
textAt(attachment, "external_media_id"),
|
||||
firstText(textAt(attachment, "externalMediaId"), attachmentId));
|
||||
PaymentAttachmentSource source = new PaymentAttachmentSource(
|
||||
attachmentId,
|
||||
firstText(textAt(attachment, "name"), firstText(textAt(attachment, "file_name"), textAt(attachment, "fileName"))),
|
||||
firstText(textAt(attachment, "content_type"), textAt(attachment, "contentType")),
|
||||
longAt(attachment, "size", "size_bytes", "sizeBytes"),
|
||||
externalMediaId,
|
||||
true);
|
||||
registerPaymentAttachmentSource(sources, attachmentId, source);
|
||||
registerPaymentAttachmentSource(sources, externalMediaId, source);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 SourceMessage 媒体表安全摘要中补充附件索引,不读取或返回真实外链。
|
||||
*/
|
||||
private void addSourceMessageMediaAttachmentSource(
|
||||
Map<String, PaymentAttachmentSource> sources,
|
||||
SourceMessageMediaSummary media) {
|
||||
if (media == null || !isAttachmentMedia(media.mediaType())) {
|
||||
return;
|
||||
}
|
||||
String attachmentId = media.externalMediaId();
|
||||
if (!hasText(attachmentId)) {
|
||||
return;
|
||||
}
|
||||
PaymentAttachmentSource source = new PaymentAttachmentSource(
|
||||
attachmentId,
|
||||
media.fileName(),
|
||||
media.contentType(),
|
||||
media.sizeBytes(),
|
||||
media.externalMediaId(),
|
||||
media.externalUrlPresent());
|
||||
registerPaymentAttachmentSource(sources, attachmentId, source);
|
||||
registerPaymentAttachmentSource(sources, media.externalMediaId(), source);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按候选 ID 注册付款附件摘要,保留第一次命中的来源,避免后续弱来源覆盖包级来源。
|
||||
*/
|
||||
private void registerPaymentAttachmentSource(
|
||||
Map<String, PaymentAttachmentSource> sources,
|
||||
String key,
|
||||
PaymentAttachmentSource source) {
|
||||
if (!hasText(key) || source == null) {
|
||||
return;
|
||||
}
|
||||
PaymentAttachmentSource existing = sources.get(key);
|
||||
if (existing == null || (!existing.available() && source.available())) {
|
||||
sources.put(key, source);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 只允许 SourceMessage 附件参与 Payment 匹配,内联图片不作为付款凭证。
|
||||
*/
|
||||
private boolean isAttachmentMedia(String mediaType) {
|
||||
return !hasText(mediaType) || "ATTACHMENT".equalsIgnoreCase(mediaType.trim());
|
||||
}
|
||||
|
||||
/**
|
||||
* 从附件 JSON 中读取大小字段,兼容 Agent 包和系统内部命名。
|
||||
*/
|
||||
private Long longAt(JsonNode node, String... fieldNames) {
|
||||
if (node == null || fieldNames == null) {
|
||||
return null;
|
||||
}
|
||||
for (String fieldName : fieldNames) {
|
||||
JsonNode value = node.get(fieldName);
|
||||
if (value != null && value.isNumber()) {
|
||||
return value.asLong();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private void removeSensitiveBusinessPayloadFields(JsonNode node) {
|
||||
if (node == null || node.isMissingNode() || node.isNull()) {
|
||||
return;
|
||||
@@ -1765,7 +1991,7 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService
|
||||
Iterator<Map.Entry<String, JsonNode>> iterator = fieldRoot.fields();
|
||||
while (iterator.hasNext()) {
|
||||
Map.Entry<String, JsonNode> entry = iterator.next();
|
||||
if (!shouldExposeBusinessField(entry.getKey(), pointerPrefix)) {
|
||||
if (!shouldExposeBusinessField(card, entry.getKey(), pointerPrefix)) {
|
||||
continue;
|
||||
}
|
||||
collectBusinessLeafFields(
|
||||
@@ -1781,7 +2007,14 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService
|
||||
return fields;
|
||||
}
|
||||
|
||||
private boolean shouldExposeBusinessField(String fieldName, String pointerPrefix) {
|
||||
private boolean shouldExposeBusinessField(
|
||||
ReservationV4TaskCardSnapshot card,
|
||||
String fieldName,
|
||||
String pointerPrefix) {
|
||||
if (ReservationV4CardType.PAYMENT.name().equals(card.cardType())
|
||||
&& ("attachment_ids".equals(fieldName) || "payment_attachments".equals(fieldName))) {
|
||||
return false;
|
||||
}
|
||||
if (V4_BUSINESS_READONLY_FIELDS.contains(fieldName)) {
|
||||
return false;
|
||||
}
|
||||
@@ -2248,4 +2481,17 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Payment 附件安全来源,只保存展示摘要和可用性,不保存真实 URL。
|
||||
*/
|
||||
private record PaymentAttachmentSource(
|
||||
String attachmentId,
|
||||
String fileName,
|
||||
String contentType,
|
||||
Long sizeBytes,
|
||||
String externalMediaId,
|
||||
boolean available
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user