补齐前端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);
}
/**
* 规范化页码,缺失或非法页码统一回到第一页。
*/

View File

@@ -0,0 +1,23 @@
package cn.nianxx.thhotel.workflows.reservation.common.request;
/**
* 前端订单列表查询条件。第一版默认查询全部订单状态,按酒店上下文做强过滤。
*
* @param hotelId 酒店上下文 ID
* @param orderStatus 订单状态,可为空;为空时查询全部状态
* @param groupCode Group Code 精确或模糊查询值
* @param confirmationNumber Confirmation No. 精确或模糊查询值
* @param keyword 订单展示名、业务号或临时订单号关键词
* @param pageNum 页码,从 1 开始
* @param pageSize 每页数量
*/
public record ReservationOrderListQueryRequest(
String hotelId,
String orderStatus,
String groupCode,
String confirmationNumber,
String keyword,
Integer pageNum,
Integer pageSize
) {
}

View File

@@ -0,0 +1,48 @@
package cn.nianxx.thhotel.workflows.reservation.common.result;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.time.OffsetDateTime;
/**
* 前端订单列表单行结果。用于工作台订单视角快速判断订单状态和可继续处理的任务。
*
* @param orderId 订单 ID按字符串返回避免前端长整型精度问题
* @param hotelId 酒店上下文 ID
* @param orderStatus 订单状态
* @param displayOrderKey 前端优先展示的订单业务号或临时订单号
* @param temporaryOrderNo 临时订单号
* @param groupCode Group Code仅订单业务号类型为 GROUP_CODE 时返回
* @param confirmationNumber Confirmation No.,仅订单业务号类型为 CONFIRMATION_NUMBER 时返回
* @param displayName 订单展示名
* @param openTaskCount 未关闭任务数,排除 COMPLETED 和 FAILED
* @param nextProcessableTaskId 下一条当前可处理任务 ID
* @param createdAt 订单创建时间UTC
* @param updatedAt 订单更新时间UTC
*/
public record ReservationOrderListItemResult(
@JsonProperty("order_id")
String orderId,
@JsonProperty("hotel_id")
String hotelId,
@JsonProperty("order_status")
String orderStatus,
@JsonProperty("display_order_key")
String displayOrderKey,
@JsonProperty("temporary_order_no")
String temporaryOrderNo,
@JsonProperty("group_code")
String groupCode,
@JsonProperty("confirmation_number")
String confirmationNumber,
@JsonProperty("display_name")
String displayName,
@JsonProperty("open_task_count")
Integer openTaskCount,
@JsonProperty("next_processable_task_id")
String nextProcessableTaskId,
@JsonProperty("created_at")
OffsetDateTime createdAt,
@JsonProperty("updated_at")
OffsetDateTime updatedAt
) {
}

View File

@@ -0,0 +1,15 @@
package cn.nianxx.thhotel.workflows.reservation.common.result;
import java.util.List;
/**
* 前端订单列表分页结果。items 为当前页订单page 为统一分页摘要。
*
* @param items 当前页订单列表
* @param page 分页信息
*/
public record ReservationOrderListResult(
List<ReservationOrderListItemResult> items,
ReservationPaginationResult page
) {
}

View File

@@ -15,6 +15,12 @@ import java.time.OffsetDateTime;
* @param queueParticipation 是否参与执行队列
* @param canProcess 当前是否可处理
* @param readonlyReasonCode 只读原因代码
* @param sourceMessageId 来源 SourceMessage Inbox ID
* @param sourceSubject 来源消息主题摘要
* @param sourceSenderSummary 来源发送人安全摘要
* @param sourceReceivedAt 本系统接收来源消息时间UTC
* @param externalConversationId 外部邮件会话 ID
* @param conversationMessageCount 同一外部邮件会话下的消息数量
* @param createdAt 任务创建时间
*/
public record ReservationOrderTaskTimelineItemResult(
@@ -36,6 +42,18 @@ public record ReservationOrderTaskTimelineItemResult(
Boolean canProcess,
@JsonProperty("readonly_reason_code")
String readonlyReasonCode,
@JsonProperty("source_message_id")
String sourceMessageId,
@JsonProperty("source_subject")
String sourceSubject,
@JsonProperty("source_sender_summary")
String sourceSenderSummary,
@JsonProperty("source_received_at")
OffsetDateTime sourceReceivedAt,
@JsonProperty("external_conversation_id")
String externalConversationId,
@JsonProperty("conversation_message_count")
Long conversationMessageCount,
@JsonProperty("created_at")
OffsetDateTime createdAt
) {

View File

@@ -1,10 +1,29 @@
package cn.nianxx.thhotel.workflows.reservation.common.result;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.time.OffsetDateTime;
import java.util.List;
/**
* 任务详情结果。提供队列可处理状态、任务卡 payload 和按矩阵生成的字段列表。
*
* @param taskId 任务 ID
* @param orderId 订单 ID
* @param sourceMessageId 来源 SourceMessage Inbox ID
* @param sourceSubject 来源消息主题摘要
* @param sourceSenderSummary 来源发送人安全摘要
* @param sourceReceivedAt 本系统接收来源消息时间UTC
* @param externalConversationId 外部邮件会话 ID
* @param conversationMessageCount 同一外部邮件会话下的消息数量
* @param systemTaskType 系统主任务类型
* @param taskCardType 任务卡类型
* @param taskStatus 任务状态
* @param fieldContractVersion 字段矩阵契约版本
* @param draftPayload 草稿 payload
* @param confirmedPayload 最终确认 payload
* @param availability 当前可处理状态
* @param fields 按字段矩阵生成的字段列表
* @param operaOperations OPERA 模拟操作列表
*/
public record ReservationTaskDetailResult(
@JsonProperty("task_id")
@@ -13,6 +32,16 @@ public record ReservationTaskDetailResult(
String orderId,
@JsonProperty("source_message_id")
String sourceMessageId,
@JsonProperty("source_subject")
String sourceSubject,
@JsonProperty("source_sender_summary")
String sourceSenderSummary,
@JsonProperty("source_received_at")
OffsetDateTime sourceReceivedAt,
@JsonProperty("external_conversation_id")
String externalConversationId,
@JsonProperty("conversation_message_count")
Long conversationMessageCount,
@JsonProperty("system_task_type")
String systemTaskType,
@JsonProperty("task_card_type")

View File

@@ -4,12 +4,45 @@ import com.fasterxml.jackson.annotation.JsonProperty;
/**
* 任务详情字段结果。字段规则来自矩阵value 为当前可回显值。
*
* @param rowNumber 矩阵行号
* @param cardName 任务卡展示名称
* @param resultType AI 结果类型白名单值
* @param taskType 任务类型白名单值
* @param taskSubtype 任务 subtype 白名单值
* @param displayArea 前端展示区域
* @param fieldPath 字段路径
* @param displayName 字段展示名称
* @param visible 是否可见
* @param editable 是否可编辑
* @param inputEditable 是否文本输入可编辑
* @param selectEditable 是否下拉选择可编辑
* @param datePicker 是否日期选择
* @param numberInput 是否数字输入
* @param fileDisplay 是否文件展示
* @param tableEditable 是否表格编辑
* @param enumOptions 枚举选项
* @param requiredRule 必填规则
* @param defaultValueSource 默认值来源
* @param displayCondition 展示条件
* @param validationRule 校验规则
* @param writePath 确认写入路径
* @param operaWriteParticipation 是否参与 OPERA 参数组装
* @param operaParameterMapping OPERA 参数映射说明
* @param notes 备注说明
* @param value 当前回显值
*/
public record ReservationTaskFieldResult(
@JsonProperty("row_number")
Integer rowNumber,
@JsonProperty("card_name")
String cardName,
@JsonProperty("result_type")
String resultType,
@JsonProperty("task_type")
String taskType,
@JsonProperty("task_subtype")
String taskSubtype,
@JsonProperty("display_area")
String displayArea,
@JsonProperty("field_path")
@@ -34,6 +67,8 @@ public record ReservationTaskFieldResult(
String enumOptions,
@JsonProperty("required_rule")
String requiredRule,
@JsonProperty("default_value_source")
String defaultValueSource,
@JsonProperty("display_condition")
String displayCondition,
@JsonProperty("validation_rule")

View File

@@ -21,6 +21,10 @@ import java.time.OffsetDateTime;
* @param readonlyReasonCode 只读原因代码
* @param sourceMessageId 来源 SourceMessage Inbox ID
* @param sourceSubject 来源消息主题摘要
* @param sourceSenderSummary 来源发送人安全摘要
* @param sourceReceivedAt 本系统接收来源消息时间UTC
* @param externalConversationId 外部邮件会话 ID
* @param conversationMessageCount 同一外部邮件会话下的消息数量
* @param createdAt 任务创建时间
* @param updatedAt 任务更新时间
*/
@@ -55,6 +59,14 @@ public record ReservationTaskWorkbenchItemResult(
String sourceMessageId,
@JsonProperty("source_subject")
String sourceSubject,
@JsonProperty("source_sender_summary")
String sourceSenderSummary,
@JsonProperty("source_received_at")
OffsetDateTime sourceReceivedAt,
@JsonProperty("external_conversation_id")
String externalConversationId,
@JsonProperty("conversation_message_count")
Long conversationMessageCount,
@JsonProperty("created_at")
OffsetDateTime createdAt,
@JsonProperty("updated_at")

View File

@@ -1,7 +1,9 @@
package cn.nianxx.thhotel.workflows.reservation.control;
import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationOrderListQueryRequest;
import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationTaskWorkbenchQueryRequest;
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationOrderDetailResult;
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationOrderListResult;
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationTaskWorkbenchListResult;
import cn.nianxx.thhotel.workflows.reservation.service.ReservationFrontendQueryService;
import org.springframework.http.MediaType;
@@ -53,6 +55,28 @@ public class ReservationFrontendQueryController {
pageSize));
}
/**
* 查询订单列表,默认返回全部订单状态,并实时聚合未关闭任务数量。
*/
@GetMapping(value = "/orders", produces = MediaType.APPLICATION_JSON_VALUE)
public ReservationOrderListResult listOrders(
@RequestParam(name = "hotel_id", required = false) String hotelId,
@RequestParam(name = "order_status", required = false) String orderStatus,
@RequestParam(name = "group_code", required = false) String groupCode,
@RequestParam(name = "confirmation_number", required = false) String confirmationNumber,
@RequestParam(required = false) String keyword,
@RequestParam(name = "page_num", required = false) Integer pageNum,
@RequestParam(name = "page_size", required = false) Integer pageSize) {
return frontendQueryService.queryOrders(new ReservationOrderListQueryRequest(
hotelId,
orderStatus,
groupCode,
confirmationNumber,
keyword,
pageNum,
pageSize));
}
/**
* 查询订单详情和任务时间线,供订单详情页展示当前订单处理脉络。
*/

View File

@@ -18,6 +18,7 @@ import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationTaskCardDra
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationTaskCardSnapshot;
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationTaskDraft;
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationTaskSnapshot;
import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationOrderListQueryRequest;
import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationTaskWorkbenchQueryRequest;
import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationOrderKeyType;
import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationOrderStatus;
@@ -371,6 +372,70 @@ public class MybatisReservationAiWorkflowRepository implements ReservationAiWork
.toList();
}
/**
* 按来源消息 ID 批量查询订单快照,供 SourceMessage 会话关联摘要使用。
*/
@Override
public List<ReservationAiQueryOrderSnapshot> findAiQueryOrdersBySourceMessageIds(
String hotelId,
List<Long> sourceMessageIds) {
if (sourceMessageIds == null || sourceMessageIds.isEmpty()) {
return List.of();
}
return orderMapper.selectList(Wrappers.<ReservationOrderEntity>lambdaQuery()
.eq(ReservationOrderEntity::getHotelId, hotelId)
.in(ReservationOrderEntity::getSourceMessageId, sourceMessageIds)
.orderByDesc(ReservationOrderEntity::getUpdatedAt))
.stream()
.map(this::toAiQueryOrderSnapshot)
.toList();
}
/**
* 前端订单列表分页查询。状态参数为空时不加状态过滤,默认返回全部订单状态。
*/
@Override
public ReservationPageSnapshot<ReservationAiQueryOrderSnapshot> queryFrontendOrders(
ReservationOrderListQueryRequest request,
int pageNum,
int pageSize) {
Page<ReservationOrderEntity> page = orderMapper.selectPage(Page.of(pageNum, pageSize),
Wrappers.<ReservationOrderEntity>lambdaQuery()
.eq(ReservationOrderEntity::getHotelId, request.hotelId())
.eq(hasText(request.orderStatus()),
ReservationOrderEntity::getOrderStatus,
trim(request.orderStatus()))
.and(hasText(request.groupCode()), wrapper -> wrapper
.like(ReservationOrderEntity::getOrderBusinessKey, trim(request.groupCode()))
.or()
.like(ReservationOrderEntity::getActiveBusinessKey, trim(request.groupCode()))
.or()
.like(ReservationOrderEntity::getDisplayName, trim(request.groupCode())))
.and(hasText(request.confirmationNumber()), wrapper -> wrapper
.like(ReservationOrderEntity::getOrderBusinessKey, trim(request.confirmationNumber()))
.or()
.like(ReservationOrderEntity::getActiveBusinessKey, trim(request.confirmationNumber()))
.or()
.like(ReservationOrderEntity::getDisplayName, trim(request.confirmationNumber())))
.and(hasText(request.keyword()), wrapper -> wrapper
.like(ReservationOrderEntity::getOrderBusinessKey, trim(request.keyword()))
.or()
.like(ReservationOrderEntity::getActiveBusinessKey, trim(request.keyword()))
.or()
.like(ReservationOrderEntity::getTemporaryOrderCode, trim(request.keyword()))
.or()
.like(ReservationOrderEntity::getOrderStatus, trim(request.keyword()))
.or()
.like(ReservationOrderEntity::getDisplayName, trim(request.keyword())))
.orderByDesc(ReservationOrderEntity::getUpdatedAt)
.orderByDesc(ReservationOrderEntity::getId));
return new ReservationPageSnapshot<>(
page.getRecords().stream().map(this::toAiQueryOrderSnapshot).toList(),
page.getTotal(),
pageNum,
pageSize);
}
/**
* 前端任务列表分页查询。只读取任务摘要字段,完整任务卡 payload 仍走任务详情接口。
*/
@@ -428,6 +493,24 @@ public class MybatisReservationAiWorkflowRepository implements ReservationAiWork
return toAiQueryTaskSnapshots(hotelId, tasks);
}
/**
* 按来源消息 ID 批量查询任务快照,供 SourceMessage 会话关联摘要使用。
*/
@Override
public List<ReservationAiQueryTaskSnapshot> findAiQueryTasksBySourceMessageIds(
String hotelId,
List<Long> sourceMessageIds) {
if (sourceMessageIds == null || sourceMessageIds.isEmpty()) {
return List.of();
}
List<ReservationTaskEntity> tasks = taskMapper.selectList(Wrappers.<ReservationTaskEntity>lambdaQuery()
.eq(ReservationTaskEntity::getHotelId, hotelId)
.in(ReservationTaskEntity::getSourceMessageId, sourceMessageIds)
.orderByAsc(ReservationTaskEntity::getOrderId)
.orderByAsc(ReservationTaskEntity::getExecutionOrder));
return toAiQueryTaskSnapshots(hotelId, tasks);
}
/**
* 按 AI transition 中冗余的 Group Code 或 Confirmation Number 查询任务。
*/

View File

@@ -19,6 +19,7 @@ import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationTaskCardSna
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationTaskDraft;
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationTaskSnapshot;
import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationTaskWorkbenchQueryRequest;
import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationOrderListQueryRequest;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
@@ -109,6 +110,21 @@ public interface ReservationAiWorkflowRepository {
*/
List<ReservationAiQueryOrderSnapshot> findAiQueryOrdersByIds(String hotelId, List<Long> orderIds);
/**
* 按来源消息 ID 批量查询订单快照,用于 SourceMessage 会话关联业务摘要。
*/
List<ReservationAiQueryOrderSnapshot> findAiQueryOrdersBySourceMessageIds(
String hotelId,
List<Long> sourceMessageIds);
/**
* 分页查询前端订单列表,默认包含全部订单状态。
*/
ReservationPageSnapshot<ReservationAiQueryOrderSnapshot> queryFrontendOrders(
ReservationOrderListQueryRequest request,
int pageNum,
int pageSize);
/**
* 分页查询前端任务列表 / 工作台摘要。
*/
@@ -123,6 +139,13 @@ public interface ReservationAiWorkflowRepository {
*/
List<ReservationAiQueryTaskSnapshot> findAiQueryTasksByOrderIds(String hotelId, List<Long> orderIds);
/**
* 按来源消息 ID 批量查询任务快照,用于邮件会话关联任务摘要。
*/
List<ReservationAiQueryTaskSnapshot> findAiQueryTasksBySourceMessageIds(
String hotelId,
List<Long> sourceMessageIds);
/**
* 按 AI transition 冗余业务 key 查询任务,用于查到待处理任务但尚无正式订单的场景。
*/

View File

@@ -1,7 +1,9 @@
package cn.nianxx.thhotel.workflows.reservation.service;
import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationOrderListQueryRequest;
import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationTaskWorkbenchQueryRequest;
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationOrderDetailResult;
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationOrderListResult;
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationTaskWorkbenchListResult;
/**
@@ -14,6 +16,11 @@ public interface ReservationFrontendQueryService {
*/
ReservationTaskWorkbenchListResult queryTaskWorkbench(ReservationTaskWorkbenchQueryRequest request);
/**
* 查询订单列表,并实时聚合未关闭任务数量和下一条可处理任务。
*/
ReservationOrderListResult queryOrders(ReservationOrderListQueryRequest request);
/**
* 查询订单详情和同订单任务时间线,供前端订单详情页展示。
*/

View File

@@ -9,8 +9,11 @@ import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationPageSnapsho
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationTaskSnapshot;
import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationOrderKeyType;
import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationTaskStatus;
import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationOrderListQueryRequest;
import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationTaskWorkbenchQueryRequest;
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationOrderDetailResult;
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationOrderListItemResult;
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationOrderListResult;
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationOrderSummaryResult;
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationOrderTaskTimelineItemResult;
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationPaginationResult;
@@ -19,6 +22,7 @@ import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationTaskWork
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationTaskWorkbenchListResult;
import cn.nianxx.thhotel.workflows.reservation.repository.ReservationAiWorkflowRepository;
import cn.nianxx.thhotel.workflows.reservation.service.ReservationFrontendQueryService;
import java.time.OffsetDateTime;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@@ -78,19 +82,50 @@ public class ReservationFrontendQueryServiceImpl implements ReservationFrontendQ
Map<Long, ReservationTaskAvailabilityResult> availabilityByTaskId = findAvailabilityByTaskId(
normalizedRequest.hotelId(),
page.items());
Map<Long, String> sourceSubjectsById = findSourceSubjectsById(page.items());
Map<Long, SourceMessageDisplayContext> sourceContextsById = findSourceContextsById(
normalizedRequest.hotelId(),
page.items());
List<ReservationTaskWorkbenchItemResult> items = page.items().stream()
.map(task -> toWorkbenchItem(
task,
ordersById.get(task.orderId()),
availabilityOrReadOnly(task, availabilityByTaskId),
sourceSubjectsById.get(task.sourceMessageId())))
sourceContextsById.get(task.sourceMessageId())))
.toList();
return new ReservationTaskWorkbenchListResult(
items,
new ReservationPaginationResult(page.pageNum(), page.pageSize(), page.total()));
}
/**
* 查询订单列表,并按当前任务队列实时计算未关闭任务数量和下一条可处理任务。
*/
@Override
@Transactional(readOnly = true)
public ReservationOrderListResult queryOrders(ReservationOrderListQueryRequest request) {
ReservationOrderListQueryRequest normalizedRequest = normalizeOrderListRequest(request);
int pageNum = normalizePageNum(normalizedRequest.pageNum());
int pageSize = normalizePageSize(normalizedRequest.pageSize());
ReservationPageSnapshot<ReservationAiQueryOrderSnapshot> page =
workflowRepository.queryFrontendOrders(normalizedRequest, pageNum, pageSize);
List<Long> orderIds = page.items().stream().map(ReservationAiQueryOrderSnapshot::id).toList();
List<ReservationAiQueryTaskSnapshot> orderTasks = workflowRepository.findAiQueryTasksByOrderIds(
normalizedRequest.hotelId(),
orderIds);
Map<Long, List<ReservationAiQueryTaskSnapshot>> tasksByOrderId = groupTasksByOrderId(orderTasks);
Map<Long, ReservationTaskAvailabilityResult> availabilityByTaskId =
calculateAvailabilityByTaskId(orderTasks, orderTasks);
List<ReservationOrderListItemResult> items = page.items().stream()
.map(order -> toOrderListItem(
order,
tasksByOrderId.getOrDefault(order.id(), List.of()),
availabilityByTaskId))
.toList();
return new ReservationOrderListResult(
items,
new ReservationPaginationResult(page.pageNum(), page.pageSize(), page.total()));
}
/**
* 查询订单详情。includeTasks=false 时只返回订单摘要,便于后续前端轻量使用。
*/
@@ -114,8 +149,14 @@ public class ReservationFrontendQueryServiceImpl implements ReservationFrontendQ
Map<Long, ReservationTaskAvailabilityResult> availabilityByTaskId = calculateAvailabilityByTaskId(
taskSnapshots,
taskSnapshots);
Map<Long, SourceMessageDisplayContext> sourceContextsById = findSourceContextsById(
normalizedHotelId,
taskSnapshots);
List<ReservationOrderTaskTimelineItemResult> tasks = taskSnapshots.stream()
.map(task -> toTimelineItem(task, availabilityOrReadOnly(task, availabilityByTaskId)))
.map(task -> toTimelineItem(
task,
availabilityOrReadOnly(task, availabilityByTaskId),
sourceContextsById.get(task.sourceMessageId())))
.toList();
return new ReservationOrderDetailResult(toOrderSummary(order), tasks, List.of());
}
@@ -148,6 +189,30 @@ public class ReservationFrontendQueryServiceImpl implements ReservationFrontendQ
request.pageSize());
}
/**
* 标准化订单列表查询条件。订单状态为空时保留为空,表示查询全部状态。
*/
private ReservationOrderListQueryRequest normalizeOrderListRequest(ReservationOrderListQueryRequest request) {
if (request == null) {
return new ReservationOrderListQueryRequest(
DEFAULT_HOTEL_ID,
null,
null,
null,
null,
DEFAULT_PAGE_NUM,
DEFAULT_PAGE_SIZE);
}
return new ReservationOrderListQueryRequest(
normalizeHotelId(request.hotelId()),
trimToNull(request.orderStatus()),
trimToNull(request.groupCode()),
trimToNull(request.confirmationNumber()),
trimToNull(request.keyword()),
request.pageNum(),
request.pageSize());
}
/**
* 批量读取订单快照并按订单 ID 建立索引。
*/
@@ -192,7 +257,9 @@ public class ReservationFrontendQueryServiceImpl implements ReservationFrontendQ
/**
* 批量读取来源消息主题摘要,避免任务列表按每条任务调用 SourceMessage 详情查询。
*/
private Map<Long, String> findSourceSubjectsById(List<ReservationAiQueryTaskSnapshot> tasks) {
private Map<Long, SourceMessageDisplayContext> findSourceContextsById(
String hotelId,
List<ReservationAiQueryTaskSnapshot> tasks) {
if (tasks == null || tasks.isEmpty()) {
return Map.of();
}
@@ -201,9 +268,22 @@ public class ReservationFrontendQueryServiceImpl implements ReservationFrontendQ
.filter(Objects::nonNull)
.distinct()
.toList();
Map<Long, String> result = new LinkedHashMap<>();
for (SourceMessageSummaryResponse summary : sourceMessageQueryService.getSummariesByIds(sourceMessageIds)) {
result.put(Long.valueOf(summary.id()), summary.subject());
List<SourceMessageSummaryResponse> summaries = sourceMessageQueryService.getSummariesByIds(sourceMessageIds);
Map<String, Long> conversationCounts = sourceMessageQueryService.countByExternalConversationIds(
hotelId,
summaries.stream()
.map(SourceMessageSummaryResponse::externalConversationId)
.filter(Objects::nonNull)
.distinct()
.toList());
Map<Long, SourceMessageDisplayContext> result = new LinkedHashMap<>();
for (SourceMessageSummaryResponse summary : summaries) {
result.put(Long.valueOf(summary.id()), new SourceMessageDisplayContext(
summary.subject(),
summary.senderSummary(),
summary.receivedAt(),
summary.externalConversationId(),
conversationCounts.get(summary.externalConversationId())));
}
return result;
}
@@ -225,7 +305,7 @@ public class ReservationFrontendQueryServiceImpl implements ReservationFrontendQ
ReservationAiQueryTaskSnapshot task,
ReservationAiQueryOrderSnapshot order,
ReservationTaskAvailabilityResult availability,
String sourceSubject) {
SourceMessageDisplayContext sourceContext) {
return new ReservationTaskWorkbenchItemResult(
task.id().toString(),
task.orderId().toString(),
@@ -241,7 +321,11 @@ public class ReservationFrontendQueryServiceImpl implements ReservationFrontendQ
canProcess(availability),
readonlyReasonCode(task, availability),
task.sourceMessageId().toString(),
sourceSubject,
sourceContext == null ? null : sourceContext.subject(),
sourceContext == null ? null : sourceContext.senderSummary(),
sourceContext == null ? null : sourceContext.receivedAt(),
sourceContext == null ? null : sourceContext.externalConversationId(),
sourceContext == null ? null : sourceContext.conversationMessageCount(),
UtcTimeFormatter.toUtcOffsetDateTime(task.createdAt()),
UtcTimeFormatter.toUtcOffsetDateTime(task.updatedAt()));
}
@@ -251,7 +335,8 @@ public class ReservationFrontendQueryServiceImpl implements ReservationFrontendQ
*/
private ReservationOrderTaskTimelineItemResult toTimelineItem(
ReservationAiQueryTaskSnapshot task,
ReservationTaskAvailabilityResult availability) {
ReservationTaskAvailabilityResult availability,
SourceMessageDisplayContext sourceContext) {
return new ReservationOrderTaskTimelineItemResult(
task.id().toString(),
task.systemTaskType(),
@@ -262,9 +347,37 @@ public class ReservationFrontendQueryServiceImpl implements ReservationFrontendQ
task.queueParticipation(),
canProcess(availability),
readonlyReasonCode(task, availability),
task.sourceMessageId().toString(),
sourceContext == null ? null : sourceContext.subject(),
sourceContext == null ? null : sourceContext.senderSummary(),
sourceContext == null ? null : sourceContext.receivedAt(),
sourceContext == null ? null : sourceContext.externalConversationId(),
sourceContext == null ? null : sourceContext.conversationMessageCount(),
UtcTimeFormatter.toUtcOffsetDateTime(task.createdAt()));
}
/**
* 转换订单快照为前端订单列表单行结果。
*/
private ReservationOrderListItemResult toOrderListItem(
ReservationAiQueryOrderSnapshot order,
List<ReservationAiQueryTaskSnapshot> tasks,
Map<Long, ReservationTaskAvailabilityResult> availabilityByTaskId) {
return new ReservationOrderListItemResult(
order.id().toString(),
order.hotelId(),
order.orderStatus(),
displayOrderKey(order),
order.temporaryOrderCode(),
groupCode(order),
confirmationNumber(order),
order.displayName(),
openTaskCount(tasks),
nextProcessableTaskId(tasks, availabilityByTaskId),
UtcTimeFormatter.toUtcOffsetDateTime(order.createdAt()),
UtcTimeFormatter.toUtcOffsetDateTime(order.updatedAt()));
}
/**
* 转换订单快照为前端订单摘要。
*/
@@ -283,6 +396,41 @@ public class ReservationFrontendQueryServiceImpl implements ReservationFrontendQ
UtcTimeFormatter.toUtcOffsetDateTime(order.updatedAt()));
}
/**
* 按订单 ID 对任务分组,并保持数据库返回顺序。
*/
private Map<Long, List<ReservationAiQueryTaskSnapshot>> groupTasksByOrderId(
List<ReservationAiQueryTaskSnapshot> tasks) {
Map<Long, List<ReservationAiQueryTaskSnapshot>> tasksByOrderId = new LinkedHashMap<>();
for (ReservationAiQueryTaskSnapshot task : tasks) {
tasksByOrderId.computeIfAbsent(task.orderId(), ignored -> new java.util.ArrayList<>()).add(task);
}
return tasksByOrderId;
}
/**
* 统计未关闭任务数量,排除 COMPLETED 和 FAILED。
*/
private Integer openTaskCount(List<ReservationAiQueryTaskSnapshot> tasks) {
return Math.toIntExact(tasks.stream()
.filter(task -> !ReservationTaskStatus.COMPLETED.name().equals(task.taskStatus()))
.filter(task -> !ReservationTaskStatus.FAILED.name().equals(task.taskStatus()))
.count());
}
/**
* 找到当前第一条可处理任务。FAILED 已结束,不会阻塞后续任务。
*/
private String nextProcessableTaskId(
List<ReservationAiQueryTaskSnapshot> tasks,
Map<Long, ReservationTaskAvailabilityResult> availabilityByTaskId) {
return tasks.stream()
.filter(task -> canProcess(availabilityOrReadOnly(task, availabilityByTaskId)))
.map(task -> task.id().toString())
.findFirst()
.orElse(null);
}
/**
* 转换 AI 查询任务快照为可处理状态解析器使用的通用任务快照。
*/
@@ -417,4 +565,15 @@ public class ReservationFrontendQueryServiceImpl implements ReservationFrontendQ
return value.trim();
}
/**
* 来源邮件会话展示上下文。只包含 Inbox 安全摘要不包含正文、HTML 或附件 URL。
*/
private record SourceMessageDisplayContext(
String subject,
String senderSummary,
OffsetDateTime receivedAt,
String externalConversationId,
Long conversationMessageCount) {
}
}

View File

@@ -0,0 +1,117 @@
package cn.nianxx.thhotel.workflows.reservation.service.impl;
import cn.nianxx.thhotel.platform.message.common.result.SourceMessageRelatedContextResult;
import cn.nianxx.thhotel.platform.message.common.result.SourceMessageRelatedOrderResult;
import cn.nianxx.thhotel.platform.message.common.result.SourceMessageRelatedTaskResult;
import cn.nianxx.thhotel.platform.message.service.SourceMessageRelatedContextProvider;
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationAiQueryOrderSnapshot;
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationAiQueryTaskSnapshot;
import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationOrderKeyType;
import cn.nianxx.thhotel.workflows.reservation.repository.ReservationAiWorkflowRepository;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import org.springframework.stereotype.Service;
/**
* Reservation 模块提供的 SourceMessage 关联上下文实现。用于邮件会话详情展示关联订单和任务摘要。
*/
@Service
public class ReservationSourceMessageRelatedContextProvider implements SourceMessageRelatedContextProvider {
private final ReservationAiWorkflowRepository workflowRepository;
/**
* 注入 Reservation 工作流持久化边界,避免平台层直接依赖 reservation 内部表。
*/
public ReservationSourceMessageRelatedContextProvider(ReservationAiWorkflowRepository workflowRepository) {
this.workflowRepository = workflowRepository;
}
/**
* 查询指定 SourceMessage 在 reservation 工作流中的订单和任务关联摘要。
*/
@Override
public SourceMessageRelatedContextResult findRelatedContext(String hotelId, Long sourceMessageId) {
if (sourceMessageId == null) {
return new SourceMessageRelatedContextResult(List.of(), List.of());
}
List<ReservationAiQueryTaskSnapshot> tasks = workflowRepository.findAiQueryTasksBySourceMessageIds(
hotelId,
List.of(sourceMessageId));
Map<Long, ReservationAiQueryOrderSnapshot> ordersById = findRelatedOrders(hotelId, sourceMessageId, tasks);
return new SourceMessageRelatedContextResult(
ordersById.values().stream().map(this::toRelatedOrder).toList(),
tasks.stream().map(this::toRelatedTask).toList());
}
/**
* 合并来源消息直接创建的订单和任务当前挂靠订单,避免只展示其中一侧。
*/
private Map<Long, ReservationAiQueryOrderSnapshot> findRelatedOrders(
String hotelId,
Long sourceMessageId,
List<ReservationAiQueryTaskSnapshot> tasks) {
Map<Long, ReservationAiQueryOrderSnapshot> ordersById = new LinkedHashMap<>();
workflowRepository.findAiQueryOrdersBySourceMessageIds(hotelId, List.of(sourceMessageId))
.forEach(order -> ordersById.put(order.id(), order));
List<Long> taskOrderIds = tasks.stream()
.map(ReservationAiQueryTaskSnapshot::orderId)
.distinct()
.toList();
workflowRepository.findAiQueryOrdersByIds(hotelId, taskOrderIds)
.forEach(order -> ordersById.put(order.id(), order));
return ordersById;
}
/**
* 转换订单快照为 SourceMessage 会话可展示的关联订单摘要。
*/
private SourceMessageRelatedOrderResult toRelatedOrder(ReservationAiQueryOrderSnapshot order) {
return new SourceMessageRelatedOrderResult(
order.id().toString(),
displayOrderKey(order),
order.orderStatus());
}
/**
* 转换任务快照为 SourceMessage 会话可展示的关联任务摘要。
*/
private SourceMessageRelatedTaskResult toRelatedTask(ReservationAiQueryTaskSnapshot task) {
return new SourceMessageRelatedTaskResult(
task.id().toString(),
task.orderId().toString(),
task.systemTaskType(),
task.taskSubtype(),
task.taskStatus(),
task.taskCardType());
}
/**
* 生成订单展示键,优先真实业务号,其次临时订单号。
*/
private String displayOrderKey(ReservationAiQueryOrderSnapshot order) {
String activeBusinessKey = trimToNull(order.activeBusinessKey());
if (activeBusinessKey != null) {
return activeBusinessKey;
}
String orderBusinessKey = trimToNull(order.orderBusinessKey());
if (orderBusinessKey != null) {
return orderBusinessKey;
}
if (ReservationOrderKeyType.TEMPORARY.name().equals(order.orderKeyType())) {
return order.temporaryOrderCode();
}
return order.displayName();
}
/**
* 去除空白字符串,空字符串按 null 处理。
*/
private String trimToNull(String value) {
if (value == null || value.trim().isEmpty()) {
return null;
}
return value.trim();
}
}

View File

@@ -1,5 +1,7 @@
package cn.nianxx.thhotel.workflows.reservation.service.impl;
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.ReservationAuditLogDraft;
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationAuditLogSnapshot;
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationOperaOperationAttemptDraft;
@@ -38,6 +40,7 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.time.format.DateTimeParseException;
import java.util.ArrayList;
@@ -74,6 +77,7 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl
private final ReservationAiWorkflowRepository workflowRepository;
private final ReservationTaskCardFieldDefinitionProvider fieldDefinitionProvider;
private final ReservationTaskAvailabilityResolver availabilityResolver;
private final SourceMessageQueryService sourceMessageQueryService;
private final ObjectMapper objectMapper;
/**
@@ -83,10 +87,12 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl
ReservationAiWorkflowRepository workflowRepository,
ReservationTaskCardFieldDefinitionProvider fieldDefinitionProvider,
ReservationTaskAvailabilityResolver availabilityResolver,
SourceMessageQueryService sourceMessageQueryService,
ObjectMapper objectMapper) {
this.workflowRepository = workflowRepository;
this.fieldDefinitionProvider = fieldDefinitionProvider;
this.availabilityResolver = availabilityResolver;
this.sourceMessageQueryService = sourceMessageQueryService;
this.objectMapper = objectMapper;
}
@@ -103,10 +109,16 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl
ReservationTaskAvailabilityResult availability = availabilityResolver.calculateAvailability(task);
List<ReservationTaskFieldResult> fields = buildFieldResults(task, taskCard);
List<ReservationOperaOperationResult> operaOperations = findOperaOperationResults(task);
SourceMessageDetailContext sourceContext = findSourceMessageDetailContext(task);
return new ReservationTaskDetailResult(
task.id().toString(),
task.orderId().toString(),
task.sourceMessageId().toString(),
sourceContext.subject(),
sourceContext.senderSummary(),
sourceContext.receivedAt(),
sourceContext.externalConversationId(),
sourceContext.conversationMessageCount(),
task.systemTaskType(),
task.taskCardType(),
task.taskStatus(),
@@ -1230,6 +1242,30 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl
.toList();
}
/**
* 查询任务详情顶部来源消息会话摘要。只读取安全摘要,不读取正文或附件 URL。
*/
private SourceMessageDetailContext findSourceMessageDetailContext(ReservationTaskSnapshot task) {
SourceMessageSummaryResponse summary = sourceMessageQueryService.getSummary(task.sourceMessageId())
.orElse(null);
if (summary == null) {
return new SourceMessageDetailContext(null, null, null, null, null);
}
Long conversationMessageCount = null;
if (trimToNull(summary.externalConversationId()) != null) {
conversationMessageCount = sourceMessageQueryService.countByExternalConversationIds(
task.hotelId(),
List.of(summary.externalConversationId()))
.get(summary.externalConversationId());
}
return new SourceMessageDetailContext(
summary.subject(),
summary.senderSummary(),
summary.receivedAt(),
summary.externalConversationId(),
conversationMessageCount);
}
/**
* 将字段定义转换为详情字段结果,并从 AI payload 中读取当前值。
*/
@@ -1237,6 +1273,9 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl
return new ReservationTaskFieldResult(
definition.rowNumber(),
definition.cardName(),
definition.resultType(),
definition.taskType(),
definition.taskSubtype(),
definition.displayArea(),
definition.fieldPath(),
definition.displayName(),
@@ -1250,6 +1289,7 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl
definition.tableEditable(),
definition.enumOptions(),
definition.requiredRule(),
definition.defaultValueSource(),
definition.displayCondition(),
definition.validationRule(),
definition.writePath(),
@@ -1259,6 +1299,17 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl
valueAt(aiPayload, definition.fieldPath()));
}
/**
* 任务详情来源消息展示上下文。只包含 SourceMessage Inbox 安全摘要字段。
*/
private record SourceMessageDetailContext(
String subject,
String senderSummary,
OffsetDateTime receivedAt,
String externalConversationId,
Long conversationMessageCount) {
}
/**
* 从 JSON 中按简单点路径读取值,支持矩阵中的数组路径后缀。
*/