实现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
) {
}
}

View File

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

View File

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

View File

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