实现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

@@ -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
) {
}

View File

@@ -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用于后续排查正文是否被意外改写。
*/

View File

@@ -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 变化时,标记差异并保存安全排查摘要。
*/

View File

@@ -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 批量读取安全摘要,供业务列表避免逐条查询。
*

View File

@@ -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 安全摘要,用于业务列表预取主题等摘要字段,避免逐条查询。
*/

View File

@@ -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;
}

View File

@@ -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
) {
}
}