补齐前端P0查询接口

This commit is contained in:
andy
2026-07-08 16:34:27 +08:00
parent 66a613d6cd
commit 3c1649a567
34 changed files with 1662 additions and 39 deletions

View File

@@ -0,0 +1,52 @@
package cn.nianxx.thhotel.platform.message.common.result;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.time.OffsetDateTime;
import java.util.List;
/**
* 邮件会话中的单封邮件详情。该结果会返回完整 text/html 和媒体外链,只能用于受控会话详情场景。
*
* @param id 内部 SourceMessage Inbox ID
* @param externalMessageId 外部单封邮件 ID
* @param externalConversationId 外部邮件会话 ID
* @param senderSummary 发送人安全摘要
* @param subject 邮件主题摘要
* @param receivedAt 本系统接收时间UTC
* @param sourceSentAt 邮件来源发送时间UTC
* @param textBody 完整纯文本正文
* @param htmlBody 完整 HTML 正文,前端展示前必须 sanitize
* @param htmlSanitizeRequired 是否要求前端 sanitize HTML
* @param inlineImages 内联图片外链
* @param attachments 附件外链
* @param relatedOrders 关联订单摘要
* @param relatedTasks 关联任务摘要
*/
public record SourceMessageConversationMessageResult(
String id,
@JsonProperty("external_message_id")
String externalMessageId,
@JsonProperty("external_conversation_id")
String externalConversationId,
@JsonProperty("sender_summary")
String senderSummary,
String subject,
@JsonProperty("received_at")
OffsetDateTime receivedAt,
@JsonProperty("source_sent_at")
OffsetDateTime sourceSentAt,
@JsonProperty("text_body")
String textBody,
@JsonProperty("html_body")
String htmlBody,
@JsonProperty("html_sanitize_required")
Boolean htmlSanitizeRequired,
@JsonProperty("inline_images")
List<SourceMessageOriginalMediaResponse> inlineImages,
List<SourceMessageOriginalMediaResponse> attachments,
@JsonProperty("related_orders")
List<SourceMessageRelatedOrderResult> relatedOrders,
@JsonProperty("related_tasks")
List<SourceMessageRelatedTaskResult> relatedTasks
) {
}

View File

@@ -0,0 +1,15 @@
package cn.nianxx.thhotel.platform.message.common.result;
import java.util.List;
/**
* SourceMessage 邮件会话详情结果。包含会话摘要和同会话所有邮件原文。
*
* @param conversation 会话摘要
* @param messages 同一会话内全部邮件,按接收时间正序返回
*/
public record SourceMessageConversationResult(
SourceMessageConversationSummaryResult conversation,
List<SourceMessageConversationMessageResult> messages
) {
}

View File

@@ -0,0 +1,31 @@
package cn.nianxx.thhotel.platform.message.common.result;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.time.OffsetDateTime;
/**
* 邮件会话摘要。用于前端在会话详情顶部展示来源链路信息。
*
* @param hotelId 酒店上下文 ID
* @param channel 来源渠道
* @param externalConversationId 外部邮件会话 ID
* @param subject 当前定位消息的主题摘要
* @param messageCount 会话内邮件数量
* @param firstReceivedAt 会话第一封入库时间UTC
* @param lastReceivedAt 会话最后一封入库时间UTC
*/
public record SourceMessageConversationSummaryResult(
@JsonProperty("hotel_id")
String hotelId,
String channel,
@JsonProperty("external_conversation_id")
String externalConversationId,
String subject,
@JsonProperty("message_count")
Long messageCount,
@JsonProperty("first_received_at")
OffsetDateTime firstReceivedAt,
@JsonProperty("last_received_at")
OffsetDateTime lastReceivedAt
) {
}

View File

@@ -0,0 +1,15 @@
package cn.nianxx.thhotel.platform.message.common.result;
import java.util.List;
/**
* SourceMessage 关联业务上下文。由业务模块实现提供,平台会话服务只做聚合展示。
*
* @param relatedOrders 关联订单摘要列表
* @param relatedTasks 关联任务摘要列表
*/
public record SourceMessageRelatedContextResult(
List<SourceMessageRelatedOrderResult> relatedOrders,
List<SourceMessageRelatedTaskResult> relatedTasks
) {
}

View File

@@ -0,0 +1,20 @@
package cn.nianxx.thhotel.platform.message.common.result;
import com.fasterxml.jackson.annotation.JsonProperty;
/**
* SourceMessage 会话中关联订单摘要。平台层只定义通用展示字段,具体来源由业务模块提供。
*
* @param orderId 订单 ID
* @param displayOrderKey 前端展示用订单号或临时订单号
* @param orderStatus 订单状态
*/
public record SourceMessageRelatedOrderResult(
@JsonProperty("order_id")
String orderId,
@JsonProperty("display_order_key")
String displayOrderKey,
@JsonProperty("order_status")
String orderStatus
) {
}

View File

@@ -0,0 +1,29 @@
package cn.nianxx.thhotel.platform.message.common.result;
import com.fasterxml.jackson.annotation.JsonProperty;
/**
* SourceMessage 会话中关联任务摘要。用于前端从邮件原文跳转到任务详情。
*
* @param taskId 任务 ID
* @param orderId 任务当前挂靠订单 ID
* @param taskType 系统主任务类型
* @param taskSubtype 任务 subtype
* @param taskStatus 任务状态
* @param cardName 任务卡名称
*/
public record SourceMessageRelatedTaskResult(
@JsonProperty("task_id")
String taskId,
@JsonProperty("order_id")
String orderId,
@JsonProperty("task_type")
String taskType,
@JsonProperty("task_subtype")
String taskSubtype,
@JsonProperty("task_status")
String taskStatus,
@JsonProperty("card_name")
String cardName
) {
}

View File

@@ -1,9 +1,11 @@
package cn.nianxx.thhotel.platform.message.control;
import cn.nianxx.thhotel.platform.message.common.result.SourceMessagePageResult;
import cn.nianxx.thhotel.platform.message.common.result.SourceMessageConversationResult;
import cn.nianxx.thhotel.platform.message.common.result.SourceMessageOriginalResponse;
import cn.nianxx.thhotel.platform.message.common.request.SourceMessageOriginalAccessRequest;
import cn.nianxx.thhotel.platform.message.common.request.SourceMessageQueryRequest;
import cn.nianxx.thhotel.platform.message.service.SourceMessageConversationService;
import cn.nianxx.thhotel.platform.message.service.SourceMessageOriginalService;
import cn.nianxx.thhotel.platform.message.service.SourceMessageQueryService;
import cn.nianxx.thhotel.platform.message.common.dto.SourceMessageSummaryResponse;
@@ -25,15 +27,18 @@ public class SourceMessageController {
private final SourceMessageQueryService queryService;
private final SourceMessageOriginalService originalService;
private final SourceMessageConversationService conversationService;
/**
* 注入 SourceMessage 查询与原文读取服务Controller 不直接访问 Mapper 或 Repository。
*/
public SourceMessageController(
SourceMessageQueryService queryService,
SourceMessageOriginalService originalService) {
SourceMessageOriginalService originalService,
SourceMessageConversationService conversationService) {
this.queryService = queryService;
this.originalService = originalService;
this.conversationService = conversationService;
}
/**
@@ -85,6 +90,15 @@ public class SourceMessageController {
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "source message not found"));
}
/**
* 读取指定 SourceMessage 所在邮件会话完整详情。该接口由后端内部记录原文读取审计,前端不传原文 key。
*/
@GetMapping("/{id}/conversation")
public SourceMessageConversationResult conversation(@PathVariable Long id) {
return conversationService.getConversation(id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "source message not found"));
}
/**
* 校验请求头文本是否有效,避免空白调用方或场景进入审计记录。
*/

View File

@@ -27,7 +27,9 @@ import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.LocalDateTime;
import java.util.HexFormat;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import org.springframework.stereotype.Repository;
@@ -90,6 +92,24 @@ public class MybatisSourceMessageInboxRepository implements SourceMessageInboxRe
.toList();
}
/**
* 查询同一外部会话下的全部 SourceMessage 安全快照,按接收时间正序返回。
*/
@Override
public List<SourceMessageInboxSnapshot> findByExternalConversationId(String hotelId, String externalConversationId) {
if (!hasText(hotelId) || !hasText(externalConversationId)) {
return List.of();
}
return inboxMapper.selectList(Wrappers.<SourceMessageInboxEntity>lambdaQuery()
.eq(SourceMessageInboxEntity::getHotelId, trim(hotelId))
.eq(SourceMessageInboxEntity::getExternalConversationId, trim(externalConversationId))
.orderByAsc(SourceMessageInboxEntity::getReceivedAt)
.orderByAsc(SourceMessageInboxEntity::getId))
.stream()
.map(this::toSnapshot)
.toList();
}
/**
* 根据 SourceMessage 幂等键读取已有记录,用于重复投递判断。
*/
@@ -160,6 +180,30 @@ public class MybatisSourceMessageInboxRepository implements SourceMessageInboxRe
.toList();
}
/**
* 批量统计会话消息数。该方法只访问 Inbox 索引表,不读取正文或媒体 URL。
*/
@Override
public Map<String, Long> countByExternalConversationIds(String hotelId, List<String> externalConversationIds) {
if (!hasText(hotelId) || externalConversationIds == null || externalConversationIds.isEmpty()) {
return Map.of();
}
List<String> safeConversationIds = externalConversationIds.stream()
.filter(this::hasText)
.map(this::trim)
.distinct()
.toList();
if (safeConversationIds.isEmpty()) {
return Map.of();
}
Map<String, Long> counts = new LinkedHashMap<>();
inboxMapper.selectList(Wrappers.<SourceMessageInboxEntity>lambdaQuery()
.eq(SourceMessageInboxEntity::getHotelId, trim(hotelId))
.in(SourceMessageInboxEntity::getExternalConversationId, safeConversationIds))
.forEach(entity -> counts.merge(entity.getExternalConversationId(), 1L, Long::sum));
return counts;
}
/**
* 插入 Inbox 主记录及原始 payloadRECEIVED 状态额外保存正文和媒体引用。
*/

View File

@@ -8,6 +8,7 @@ import cn.nianxx.thhotel.platform.message.common.result.SourceMessagePageResult;
import cn.nianxx.thhotel.platform.message.common.request.SourceMessageQueryRequest;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
import java.util.Optional;
/**
@@ -25,6 +26,11 @@ public interface SourceMessageInboxRepository {
*/
List<SourceMessageInboxSnapshot> findByIds(List<Long> ids);
/**
* 按外部邮件会话 ID 查询同一会话全部 Inbox 安全快照。
*/
List<SourceMessageInboxSnapshot> findByExternalConversationId(String hotelId, String externalConversationId);
/**
* 按酒店、来源、渠道、外部邮件 ID 查询幂等记录。
*/
@@ -47,6 +53,11 @@ public interface SourceMessageInboxRepository {
*/
List<Long> findIdsBySafeKeyword(String hotelId, String keyword, int limit);
/**
* 批量统计外部邮件会话下的 SourceMessage 数量,用于任务和订单前端摘要。
*/
Map<String, Long> countByExternalConversationIds(String hotelId, List<String> externalConversationIds);
/**
* 插入 Inbox 及其 payload/body/media 子记录,返回内部 SourceMessage ID。
*/

View File

@@ -0,0 +1,18 @@
package cn.nianxx.thhotel.platform.message.service;
import cn.nianxx.thhotel.platform.message.common.result.SourceMessageConversationResult;
import java.util.Optional;
/**
* SourceMessage 邮件会话详情服务。该服务会返回完整正文、HTML 和媒体外链,并写入内部读取审计。
*/
public interface SourceMessageConversationService {
/**
* 读取指定 SourceMessage 所在邮件会话的完整详情。
*
* @param sourceMessageId 当前定位的 SourceMessage Inbox ID
* @return 找到时返回同一会话全部邮件详情;不存在时为空
*/
Optional<SourceMessageConversationResult> getConversation(Long sourceMessageId);
}

View File

@@ -4,6 +4,7 @@ import cn.nianxx.thhotel.platform.message.common.dto.SourceMessageSummaryRespons
import cn.nianxx.thhotel.platform.message.common.request.SourceMessageQueryRequest;
import cn.nianxx.thhotel.platform.message.common.result.SourceMessagePageResult;
import java.util.List;
import java.util.Map;
import java.util.Optional;
/**
@@ -43,4 +44,13 @@ public interface SourceMessageQueryService {
* @return 匹配到的安全摘要列表不返回正文、HTML、附件 URL 或 payload
*/
List<SourceMessageSummaryResponse> getSummariesByIds(List<Long> inboxIds);
/**
* 批量统计外部邮件会话下的 SourceMessage 数量,供业务摘要字段展示。
*
* @param hotelId 酒店上下文 ID
* @param externalConversationIds 外部邮件会话 ID 列表
* @return 会话 ID 到消息数量的映射
*/
Map<String, Long> countByExternalConversationIds(String hotelId, List<String> externalConversationIds);
}

View File

@@ -0,0 +1,18 @@
package cn.nianxx.thhotel.platform.message.service;
import cn.nianxx.thhotel.platform.message.common.result.SourceMessageRelatedContextResult;
/**
* SourceMessage 关联业务上下文 Provider。业务模块实现该接口平台会话服务只做聚合展示。
*/
public interface SourceMessageRelatedContextProvider {
/**
* 查询单封 SourceMessage 关联的业务摘要。
*
* @param hotelId 酒店上下文 ID
* @param sourceMessageId SourceMessage Inbox ID
* @return 关联订单和任务摘要;没有关联时返回空列表结果
*/
SourceMessageRelatedContextResult findRelatedContext(String hotelId, Long sourceMessageId);
}

View File

@@ -0,0 +1,176 @@
package cn.nianxx.thhotel.platform.message.service.impl;
import cn.nianxx.thhotel.platform.common.time.UtcTimeFormatter;
import cn.nianxx.thhotel.platform.message.common.dto.SourceMessageInboxSnapshot;
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;
import cn.nianxx.thhotel.platform.message.common.enums.SourceMessageOriginalAccessResult;
import cn.nianxx.thhotel.platform.message.common.result.SourceMessageConversationMessageResult;
import cn.nianxx.thhotel.platform.message.common.result.SourceMessageConversationResult;
import cn.nianxx.thhotel.platform.message.common.result.SourceMessageConversationSummaryResult;
import cn.nianxx.thhotel.platform.message.common.result.SourceMessageOriginalMediaResponse;
import cn.nianxx.thhotel.platform.message.common.result.SourceMessageRelatedContextResult;
import cn.nianxx.thhotel.platform.message.repository.SourceMessageInboxRepository;
import cn.nianxx.thhotel.platform.message.service.SourceMessageConversationService;
import cn.nianxx.thhotel.platform.message.service.SourceMessageRelatedContextProvider;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.List;
import java.util.Optional;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* SourceMessage 邮件会话详情服务实现。该服务负责受控读取原文并写入后端内部审计。
*/
@Service
public class SourceMessageConversationServiceImpl implements SourceMessageConversationService {
private static final String INTERNAL_ACTOR_ID = "system:source-message-conversation";
private static final String ACCESS_SCENE = "source-message-conversation";
private static final String MEDIA_TYPE_INLINE_IMAGE = "INLINE_IMAGE";
private static final String MEDIA_TYPE_ATTACHMENT = "ATTACHMENT";
private final SourceMessageInboxRepository inboxRepository;
private final List<SourceMessageRelatedContextProvider> relatedContextProviders;
/**
* 注入 SourceMessage 持久化端口和业务关联上下文 Provider 列表。
*/
public SourceMessageConversationServiceImpl(
SourceMessageInboxRepository inboxRepository,
List<SourceMessageRelatedContextProvider> relatedContextProviders) {
this.inboxRepository = inboxRepository;
this.relatedContextProviders = relatedContextProviders == null ? List.of() : relatedContextProviders;
}
/**
* 读取指定 SourceMessage 所在会话的完整原文链路,并为每封邮件写入内部读取审计。
*/
@Override
@Transactional
public Optional<SourceMessageConversationResult> getConversation(Long sourceMessageId) {
Optional<SourceMessageInboxSnapshot> sourceOptional = inboxRepository.findById(sourceMessageId);
if (sourceOptional.isEmpty()) {
return Optional.empty();
}
SourceMessageInboxSnapshot source = sourceOptional.get();
List<SourceMessageInboxSnapshot> conversationMessages = findConversationMessages(source);
SourceMessageConversationSummaryResult conversation = toConversationSummary(source, conversationMessages);
List<SourceMessageConversationMessageResult> messages = conversationMessages.stream()
.map(this::toConversationMessage)
.toList();
return Optional.of(new SourceMessageConversationResult(conversation, messages));
}
/**
* 查询同一外部会话邮件;外部会话 ID 缺失时降级为单封邮件详情。
*/
private List<SourceMessageInboxSnapshot> findConversationMessages(SourceMessageInboxSnapshot source) {
if (source.externalConversationId() == null || source.externalConversationId().isBlank()) {
return List.of(source);
}
List<SourceMessageInboxSnapshot> messages = inboxRepository.findByExternalConversationId(
source.hotelId(),
source.externalConversationId());
return messages.isEmpty() ? List.of(source) : messages;
}
/**
* 生成会话顶部摘要,时间统一转为 UTC OffsetDateTime。
*/
private SourceMessageConversationSummaryResult toConversationSummary(
SourceMessageInboxSnapshot source,
List<SourceMessageInboxSnapshot> messages) {
SourceMessageInboxSnapshot first = messages.get(0);
SourceMessageInboxSnapshot last = messages.get(messages.size() - 1);
return new SourceMessageConversationSummaryResult(
source.hotelId(),
source.channel(),
source.externalConversationId(),
source.subject(),
(long) messages.size(),
UtcTimeFormatter.toUtcOffsetDateTime(first.receivedAt()),
UtcTimeFormatter.toUtcOffsetDateTime(last.receivedAt()));
}
/**
* 将 Inbox 快照转换为会话邮件详情,并附带原文、媒体外链和业务关联摘要。
*/
private SourceMessageConversationMessageResult toConversationMessage(SourceMessageInboxSnapshot message) {
SourceMessageOriginalContent originalContent = readOriginalAndAudit(message);
SourceMessageRelatedContextResult relatedContext = findRelatedContext(message);
return new SourceMessageConversationMessageResult(
message.id().toString(),
message.externalMessageId(),
message.externalConversationId(),
message.senderSummary(),
message.subject(),
UtcTimeFormatter.toUtcOffsetDateTime(message.receivedAt()),
UtcTimeFormatter.toUtcOffsetDateTime(message.sourceSentAt()),
originalContent.textBody(),
originalContent.htmlBody(),
true,
filterMedia(originalContent.mediaItems(), MEDIA_TYPE_INLINE_IMAGE),
filterMedia(originalContent.mediaItems(), MEDIA_TYPE_ATTACHMENT),
relatedContext.relatedOrders(),
relatedContext.relatedTasks());
}
/**
* 读取原文并写入内部审计。会话接口不依赖前端传原文读取 key。
*/
private SourceMessageOriginalContent readOriginalAndAudit(SourceMessageInboxSnapshot message) {
SourceMessageOriginalContent content = inboxRepository.findOriginalContent(message.id())
.orElse(new SourceMessageOriginalContent(message.id(), null, null, List.of()));
LocalDateTime now = LocalDateTime.now(ZoneOffset.UTC);
inboxRepository.insertOriginalAccessAudit(new SourceMessageOriginalAccessAuditDraft(
message.id(),
INTERNAL_ACTOR_ID,
ACCESS_SCENE,
SourceMessageOriginalAccessResult.GRANTED.code(),
now));
return content;
}
/**
* 聚合所有业务模块提供的关联上下文。当前 reservation 模块会提供订单和任务摘要。
*/
private SourceMessageRelatedContextResult findRelatedContext(SourceMessageInboxSnapshot message) {
List<SourceMessageRelatedContextResult> contexts = relatedContextProviders.stream()
.map(provider -> provider.findRelatedContext(message.hotelId(), message.id()))
.toList();
return new SourceMessageRelatedContextResult(
contexts.stream().flatMap(context -> context.relatedOrders().stream()).toList(),
contexts.stream().flatMap(context -> context.relatedTasks().stream()).toList());
}
/**
* 按媒体类型筛选并转换原文媒体引用。
*/
private List<SourceMessageOriginalMediaResponse> filterMedia(
List<SourceMessageOriginalMediaItem> mediaItems,
String mediaType) {
if (mediaItems == null || mediaItems.isEmpty()) {
return List.of();
}
return mediaItems.stream()
.filter(item -> mediaType.equals(item.mediaType()))
.map(this::toMediaResponse)
.toList();
}
/**
* 转换媒体内部 DTO 为接口响应对象。
*/
private SourceMessageOriginalMediaResponse toMediaResponse(SourceMessageOriginalMediaItem item) {
return new SourceMessageOriginalMediaResponse(
item.mediaType(),
item.fileName(),
item.contentType(),
item.sizeBytes(),
item.externalUrl(),
item.externalMediaId());
}
}

View File

@@ -8,6 +8,7 @@ import cn.nianxx.thhotel.platform.message.common.result.SourceMessagePageResult;
import cn.nianxx.thhotel.platform.message.repository.SourceMessageInboxRepository;
import cn.nianxx.thhotel.platform.message.service.SourceMessageQueryService;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import org.springframework.stereotype.Service;
@@ -72,6 +73,14 @@ public class SourceMessageQueryServiceImpl implements SourceMessageQueryService
.toList();
}
/**
* 批量统计邮件会话消息数,只访问 SourceMessage Inbox 索引表。
*/
@Override
public Map<String, Long> countByExternalConversationIds(String hotelId, List<String> externalConversationIds) {
return inboxRepository.countByExternalConversationIds(hotelId, externalConversationIds);
}
/**
* 规范化页码,缺失或非法页码统一回到第一页。
*/