实现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
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user