增加 SuperAgent 邮件会话查询接口
This commit is contained in:
@@ -110,6 +110,30 @@ public class MybatisSourceMessageInboxRepository implements SourceMessageInboxRe
|
||||
.toList();
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询指定来源和渠道下的同一外部会话,避免不同接入方会话 ID 偶然相同导致串数据。
|
||||
*/
|
||||
@Override
|
||||
public List<SourceMessageInboxSnapshot> findByExternalConversationId(
|
||||
String hotelId,
|
||||
String provider,
|
||||
String channel,
|
||||
String externalConversationId) {
|
||||
if (!hasText(hotelId) || !hasText(provider) || !hasText(channel) || !hasText(externalConversationId)) {
|
||||
return List.of();
|
||||
}
|
||||
return inboxMapper.selectList(Wrappers.<SourceMessageInboxEntity>lambdaQuery()
|
||||
.eq(SourceMessageInboxEntity::getHotelId, trim(hotelId))
|
||||
.eq(SourceMessageInboxEntity::getProvider, trim(provider))
|
||||
.eq(SourceMessageInboxEntity::getChannel, trim(channel))
|
||||
.eq(SourceMessageInboxEntity::getExternalConversationId, trim(externalConversationId))
|
||||
.orderByAsc(SourceMessageInboxEntity::getReceivedAt)
|
||||
.orderByAsc(SourceMessageInboxEntity::getId))
|
||||
.stream()
|
||||
.map(this::toSnapshot)
|
||||
.toList();
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据 SourceMessage 幂等键读取已有记录,用于重复投递判断。
|
||||
*/
|
||||
|
||||
@@ -31,6 +31,15 @@ public interface SourceMessageInboxRepository {
|
||||
*/
|
||||
List<SourceMessageInboxSnapshot> findByExternalConversationId(String hotelId, String externalConversationId);
|
||||
|
||||
/**
|
||||
* 按酒店、来源、渠道和外部邮件会话 ID 查询同一会话全部 Inbox 安全快照。
|
||||
*/
|
||||
List<SourceMessageInboxSnapshot> findByExternalConversationId(
|
||||
String hotelId,
|
||||
String provider,
|
||||
String channel,
|
||||
String externalConversationId);
|
||||
|
||||
/**
|
||||
* 按酒店、来源、渠道、外部邮件 ID 查询幂等记录。
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package cn.nianxx.thhotel.workflows.reservation.common.request;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
/**
|
||||
* SuperAgent 查询邮件会话请求。可直接按外部会话 ID 查询,也可用外部来源消息 ID 反查会话。
|
||||
*
|
||||
* @param hotelId 酒店上下文 ID,用于隔离不同酒店的 SourceMessage 和任务数据
|
||||
* @param sourceProvider 来源提供方稳定代码,按 source_message_id 反查时使用,缺省为 AGENTBUS
|
||||
* @param sourceChannel 来源渠道稳定代码,按 source_message_id 反查时使用,缺省为 EMAIL
|
||||
* @param externalConversationId 外部邮件会话 ID,对应 AgentBus source.external_conversation_id
|
||||
* @param sourceMessageId 外部来源消息 ID,对应 AgentBus source.external_message_id,不是内部 Inbox ID
|
||||
*/
|
||||
public record ReservationMessageConversationQueryRequest(
|
||||
@JsonProperty("hotel_id")
|
||||
String hotelId,
|
||||
@JsonProperty("source_provider")
|
||||
String sourceProvider,
|
||||
@JsonProperty("source_channel")
|
||||
String sourceChannel,
|
||||
@JsonProperty("external_conversation_id")
|
||||
String externalConversationId,
|
||||
@JsonProperty("source_message_id")
|
||||
String sourceMessageId
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package cn.nianxx.thhotel.workflows.reservation.common.result;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* SuperAgent 邮件会话正文查询结果。只返回受控正文,不返回附件 URL 或未清洗 HTML。
|
||||
*
|
||||
* @param hotelId 酒店上下文 ID
|
||||
* @param externalConversationId 外部邮件会话 ID
|
||||
* @param messageCount 当前响应中的邮件数量
|
||||
* @param messages 按邮件接收时间正序排列的受控正文列表
|
||||
*/
|
||||
public record ReservationMessageConversationMessagesResult(
|
||||
@JsonProperty("hotel_id")
|
||||
String hotelId,
|
||||
@JsonProperty("external_conversation_id")
|
||||
String externalConversationId,
|
||||
@JsonProperty("message_count")
|
||||
Integer messageCount,
|
||||
List<MessageRecord> messages
|
||||
) {
|
||||
|
||||
/**
|
||||
* 邮件受控正文。HTML 只返回后端清洗结果,媒体引用由后续受控原文接口另行提供。
|
||||
*
|
||||
* @param externalSourceMessageId 外部来源消息 ID,对应 AgentBus source.external_message_id
|
||||
* @param externalConversationId 外部邮件会话 ID
|
||||
* @param senderSummary 发送人安全摘要
|
||||
* @param subject 邮件主题安全摘要
|
||||
* @param receivedAt 本系统接收该邮件的 UTC 时间
|
||||
* @param sourceSentAt 来源系统发送时间
|
||||
* @param textBody 纯文本正文
|
||||
* @param htmlBodySanitized 后端清洗后的 HTML 正文
|
||||
* @param htmlSanitizeRequired HTML 是否需要按清洗后内容渲染
|
||||
* @param htmlRenderMode HTML 渲染建议,例如 SANITIZED_HTML 或 TEXT_ONLY
|
||||
*/
|
||||
public record MessageRecord(
|
||||
@JsonProperty("external_source_message_id")
|
||||
String externalSourceMessageId,
|
||||
@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_sanitized")
|
||||
String htmlBodySanitized,
|
||||
@JsonProperty("html_sanitize_required")
|
||||
Boolean htmlSanitizeRequired,
|
||||
@JsonProperty("html_render_mode")
|
||||
String htmlRenderMode
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package cn.nianxx.thhotel.workflows.reservation.common.result;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* SuperAgent 邮件会话任务查询结果。按邮件接收时间正序,再按任务创建时间正序返回任务。
|
||||
*
|
||||
* @param hotelId 酒店上下文 ID
|
||||
* @param externalConversationId 外部邮件会话 ID
|
||||
* @param taskCount 当前响应中的任务数量
|
||||
* @param tasks 会话下关联任务列表
|
||||
*/
|
||||
public record ReservationMessageConversationTasksResult(
|
||||
@JsonProperty("hotel_id")
|
||||
String hotelId,
|
||||
@JsonProperty("external_conversation_id")
|
||||
String externalConversationId,
|
||||
@JsonProperty("task_count")
|
||||
Integer taskCount,
|
||||
List<TaskRecord> tasks
|
||||
) {
|
||||
|
||||
/**
|
||||
* 邮件会话下的任务摘要。source message 字段始终使用外部邮件 ID,避免暴露内部 Inbox 主键。
|
||||
*
|
||||
* @param taskId 本系统任务 ID
|
||||
* @param orderId 任务当前挂靠订单 ID
|
||||
* @param externalSourceMessageId 外部来源消息 ID,对应 AgentBus source.external_message_id
|
||||
* @param externalConversationId 外部邮件会话 ID
|
||||
* @param sourceReceivedAt 本系统接收该邮件的 UTC 时间
|
||||
* @param sourceEventIndex AI transition 来源事件序号
|
||||
* @param catalogCode Skill 目录代码
|
||||
* @param skillId Skill 标识
|
||||
* @param resultType AI 结果类型
|
||||
* @param taskType AI 原始任务类型
|
||||
* @param systemTaskType 系统主任务类型
|
||||
* @param taskCardType 前端任务卡类型
|
||||
* @param taskSubtype 业务动作 subtype
|
||||
* @param taskStatus 任务状态
|
||||
* @param queueParticipation 是否参与订单执行队列
|
||||
* @param executionOrder 同订单执行顺序
|
||||
* @param parentTaskId 父任务 ID
|
||||
* @param parentSourceEventIndex 父事件序号
|
||||
* @param linkedTaskGroupId 联动任务组 ID
|
||||
* @param blockedUntilParentCompleted 是否等待父任务完成
|
||||
* @param completedAt 任务完成时间
|
||||
* @param taskCreatedAt 任务创建时间
|
||||
* @param taskUpdatedAt 任务更新时间
|
||||
*/
|
||||
public record TaskRecord(
|
||||
@JsonProperty("task_id")
|
||||
String taskId,
|
||||
@JsonProperty("order_id")
|
||||
String orderId,
|
||||
@JsonProperty("external_source_message_id")
|
||||
String externalSourceMessageId,
|
||||
@JsonProperty("external_conversation_id")
|
||||
String externalConversationId,
|
||||
@JsonProperty("source_received_at")
|
||||
OffsetDateTime sourceReceivedAt,
|
||||
@JsonProperty("source_event_index")
|
||||
Integer sourceEventIndex,
|
||||
@JsonProperty("catalog_code")
|
||||
String catalogCode,
|
||||
@JsonProperty("skill_id")
|
||||
String skillId,
|
||||
@JsonProperty("result_type")
|
||||
String resultType,
|
||||
@JsonProperty("task_type")
|
||||
String taskType,
|
||||
@JsonProperty("system_task_type")
|
||||
String systemTaskType,
|
||||
@JsonProperty("task_card_type")
|
||||
String taskCardType,
|
||||
@JsonProperty("task_subtype")
|
||||
String taskSubtype,
|
||||
@JsonProperty("task_status")
|
||||
String taskStatus,
|
||||
@JsonProperty("queue_participation")
|
||||
Boolean queueParticipation,
|
||||
@JsonProperty("execution_order")
|
||||
Integer executionOrder,
|
||||
@JsonProperty("parent_task_id")
|
||||
String parentTaskId,
|
||||
@JsonProperty("parent_source_event_index")
|
||||
Integer parentSourceEventIndex,
|
||||
@JsonProperty("linked_task_group_id")
|
||||
String linkedTaskGroupId,
|
||||
@JsonProperty("blocked_until_parent_completed")
|
||||
Boolean blockedUntilParentCompleted,
|
||||
@JsonProperty("completed_at")
|
||||
OffsetDateTime completedAt,
|
||||
@JsonProperty("task_created_at")
|
||||
OffsetDateTime taskCreatedAt,
|
||||
@JsonProperty("task_updated_at")
|
||||
OffsetDateTime taskUpdatedAt
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -6,9 +6,12 @@ import cn.nianxx.thhotel.integrations.ai.superagent.service.impl.SuperAgentTaskR
|
||||
import cn.nianxx.thhotel.integrations.ai.superagent.service.impl.SuperAgentTaskResultProperties;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationAiCaseContextQueryRequest;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationAiObjectDetailQueryRequest;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationMessageConversationQueryRequest;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationAiCaseContextResult;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationAiObjectDetailResult;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationAiQueryResponse;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationMessageConversationMessagesResult;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationMessageConversationTasksResult;
|
||||
import cn.nianxx.thhotel.workflows.reservation.service.ReservationAiQueryService;
|
||||
import cn.nianxx.thhotel.workflows.reservation.service.impl.ReservationAiQueryException;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
@@ -34,6 +37,8 @@ public class ReservationAiQueryController {
|
||||
|
||||
private static final String CASE_CONTEXT_PATH = "/api/ai-query/v1/case-context";
|
||||
private static final String OBJECT_DETAIL_PATH = "/api/ai-query/v1/object-detail";
|
||||
private static final String MESSAGE_CONVERSATION_TASKS_PATH = "/api/ai-query/v1/message-conversation/tasks";
|
||||
private static final String MESSAGE_CONVERSATION_MESSAGES_PATH = "/api/ai-query/v1/message-conversation/messages";
|
||||
|
||||
private final ReservationAiQueryService aiQueryService;
|
||||
private final SuperAgentTaskResultSecurityService securityService;
|
||||
@@ -112,6 +117,70 @@ public class ReservationAiQueryController {
|
||||
List.of()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询邮件会话下全部任务,给 SuperAgent 在处理当前邮件前核对历史任务状态。
|
||||
*/
|
||||
@PostMapping(
|
||||
value = "/message-conversation/tasks",
|
||||
produces = MediaType.APPLICATION_JSON_VALUE)
|
||||
public ResponseEntity<ReservationAiQueryResponse<ReservationMessageConversationTasksResult>>
|
||||
queryMessageConversationTasks(
|
||||
@RequestBody(required = false) String rawBody,
|
||||
@RequestHeader(value = "Content-Type", required = false) String contentType,
|
||||
@RequestHeader(name = "X-TH-Hotel-SuperAgent-Client-Id", required = false) String clientId,
|
||||
@RequestHeader(name = "X-TH-Hotel-SuperAgent-Timestamp", required = false) String timestamp,
|
||||
@RequestHeader(name = "X-TH-Hotel-SuperAgent-Nonce", required = false) String nonce,
|
||||
@RequestHeader(name = "X-TH-Hotel-SuperAgent-Signature", required = false) String signature,
|
||||
@RequestHeader(value = "X-TH-Hotel-Request-Id", required = false) String requestId,
|
||||
@RequestHeader(value = "X-Request-Id", required = false) String legacyRequestId,
|
||||
@RequestHeader(value = "X-AI-Trace-Id", required = false) String traceId,
|
||||
@RequestHeader(value = "X-TH-Hotel-AI-Trace-Id", required = false) String thHotelTraceId) {
|
||||
String requestBody = rawBody == null ? "" : rawBody;
|
||||
verifyHmac(MESSAGE_CONVERSATION_TASKS_PATH, clientId, timestamp, nonce, signature, requestBody);
|
||||
requireJsonContentType(contentType);
|
||||
ReservationMessageConversationQueryRequest request = readBody(
|
||||
requestBody,
|
||||
ReservationMessageConversationQueryRequest.class);
|
||||
ReservationMessageConversationTasksResult result = aiQueryService.queryMessageConversationTasks(request);
|
||||
return ResponseEntity.ok(ReservationAiQueryResponse.success(
|
||||
firstText(requestId, legacyRequestId),
|
||||
firstText(thHotelTraceId, traceId),
|
||||
result,
|
||||
List.of()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询邮件会话下全部受控正文,只返回清洗后的 HTML,不返回附件 URL 或原始 HTML。
|
||||
*/
|
||||
@PostMapping(
|
||||
value = "/message-conversation/messages",
|
||||
produces = MediaType.APPLICATION_JSON_VALUE)
|
||||
public ResponseEntity<ReservationAiQueryResponse<ReservationMessageConversationMessagesResult>>
|
||||
queryMessageConversationMessages(
|
||||
@RequestBody(required = false) String rawBody,
|
||||
@RequestHeader(value = "Content-Type", required = false) String contentType,
|
||||
@RequestHeader(name = "X-TH-Hotel-SuperAgent-Client-Id", required = false) String clientId,
|
||||
@RequestHeader(name = "X-TH-Hotel-SuperAgent-Timestamp", required = false) String timestamp,
|
||||
@RequestHeader(name = "X-TH-Hotel-SuperAgent-Nonce", required = false) String nonce,
|
||||
@RequestHeader(name = "X-TH-Hotel-SuperAgent-Signature", required = false) String signature,
|
||||
@RequestHeader(value = "X-TH-Hotel-Request-Id", required = false) String requestId,
|
||||
@RequestHeader(value = "X-Request-Id", required = false) String legacyRequestId,
|
||||
@RequestHeader(value = "X-AI-Trace-Id", required = false) String traceId,
|
||||
@RequestHeader(value = "X-TH-Hotel-AI-Trace-Id", required = false) String thHotelTraceId) {
|
||||
String requestBody = rawBody == null ? "" : rawBody;
|
||||
verifyHmac(MESSAGE_CONVERSATION_MESSAGES_PATH, clientId, timestamp, nonce, signature, requestBody);
|
||||
requireJsonContentType(contentType);
|
||||
ReservationMessageConversationQueryRequest request = readBody(
|
||||
requestBody,
|
||||
ReservationMessageConversationQueryRequest.class);
|
||||
ReservationMessageConversationMessagesResult result = aiQueryService.queryMessageConversationMessages(request);
|
||||
return ResponseEntity.ok(ReservationAiQueryResponse.success(
|
||||
firstText(requestId, legacyRequestId),
|
||||
firstText(thHotelTraceId, traceId),
|
||||
result,
|
||||
List.of()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用任务结果接收接口同一套 HMAC 规则校验查询请求,校验通过后才允许解析业务 JSON。
|
||||
*/
|
||||
|
||||
@@ -2,8 +2,11 @@ package cn.nianxx.thhotel.workflows.reservation.service;
|
||||
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationAiCaseContextQueryRequest;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationAiObjectDetailQueryRequest;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationMessageConversationQueryRequest;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationAiCaseContextResult;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationAiObjectDetailResult;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationMessageConversationMessagesResult;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationMessageConversationTasksResult;
|
||||
|
||||
/**
|
||||
* Reservation AI 查询服务。为 SuperAgent / Main Agent 提供只读上下文,不产生业务写入。
|
||||
@@ -19,4 +22,16 @@ public interface ReservationAiQueryService {
|
||||
* 查询单个对象详情。第一版只支持 ORDER:{orderId} 形式的本系统订单对象。
|
||||
*/
|
||||
ReservationAiObjectDetailResult queryObjectDetail(ReservationAiObjectDetailQueryRequest request);
|
||||
|
||||
/**
|
||||
* 查询邮件会话下全部任务,按邮件接收时间和任务创建时间稳定排序。
|
||||
*/
|
||||
ReservationMessageConversationTasksResult queryMessageConversationTasks(
|
||||
ReservationMessageConversationQueryRequest request);
|
||||
|
||||
/**
|
||||
* 查询邮件会话下全部受控正文,返回清洗后的 HTML,不返回附件 URL 或原始未清洗 HTML。
|
||||
*/
|
||||
ReservationMessageConversationMessagesResult queryMessageConversationMessages(
|
||||
ReservationMessageConversationQueryRequest request);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
package cn.nianxx.thhotel.workflows.reservation.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.repository.SourceMessageInboxRepository;
|
||||
import cn.nianxx.thhotel.platform.message.service.SourceMessageHtmlSanitizerService;
|
||||
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;
|
||||
@@ -9,12 +16,16 @@ import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationSystemTas
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationTaskStatus;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationAiCaseContextQueryRequest;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationAiObjectDetailQueryRequest;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationMessageConversationQueryRequest;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationAiCaseContextResult;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationAiObjectDetailResult;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationAiQueryWarningResult;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationMessageConversationMessagesResult;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationMessageConversationTasksResult;
|
||||
import cn.nianxx.thhotel.workflows.reservation.repository.ReservationAiWorkflowRepository;
|
||||
import cn.nianxx.thhotel.workflows.reservation.service.ReservationAiQueryService;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.Comparator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
@@ -22,9 +33,11 @@ import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.Stream;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/**
|
||||
* Reservation AI 查询服务实现。只读取订单、任务和 AI 过渡层事实,不执行业务状态流转。
|
||||
@@ -35,6 +48,12 @@ public class ReservationAiQueryServiceImpl implements ReservationAiQueryService
|
||||
private static final String OBJECT_ID_PREFIX_ORDER = "ORDER:";
|
||||
private static final String SOURCE_TABLE_ORDER = "workflow_reservation_order";
|
||||
private static final String WARNING_OPERA_PROJECTION_UNAVAILABLE = "OPERA_PROJECTION_UNAVAILABLE";
|
||||
private static final String DEFAULT_SOURCE_PROVIDER = "AGENTBUS";
|
||||
private static final String DEFAULT_SOURCE_CHANNEL = "EMAIL";
|
||||
private static final String SUPERAGENT_AI_QUERY_ACTOR = "system:superagent-ai-query";
|
||||
private static final String MESSAGE_CONVERSATION_ACCESS_SCENE = "superagent-message-conversation-messages";
|
||||
private static final Pattern HTML_URL_ATTRIBUTE_PATTERN = Pattern.compile(
|
||||
"(?i)\\s+(href|src|xlink:href|formaction|poster|background)\\s*=\\s*(\"[^\"]*\"|'[^']*'|[^\\s>]+)");
|
||||
private static final Set<String> OPEN_TASK_STATUSES = Set.of(
|
||||
ReservationTaskStatus.PENDING_CONFIRM.name(),
|
||||
ReservationTaskStatus.READY.name(),
|
||||
@@ -44,12 +63,19 @@ public class ReservationAiQueryServiceImpl implements ReservationAiQueryService
|
||||
ReservationTaskStatus.COMPLETED.name());
|
||||
|
||||
private final ReservationAiWorkflowRepository repository;
|
||||
private final SourceMessageInboxRepository sourceMessageInboxRepository;
|
||||
private final SourceMessageHtmlSanitizerService htmlSanitizerService;
|
||||
|
||||
/**
|
||||
* 注入 Reservation 工作流持久化边界,Service 不直接依赖 Mapper。
|
||||
* 注入 Reservation 工作流持久化边界、SourceMessage 持久化边界和 HTML 清洗服务。
|
||||
*/
|
||||
public ReservationAiQueryServiceImpl(ReservationAiWorkflowRepository repository) {
|
||||
public ReservationAiQueryServiceImpl(
|
||||
ReservationAiWorkflowRepository repository,
|
||||
SourceMessageInboxRepository sourceMessageInboxRepository,
|
||||
SourceMessageHtmlSanitizerService htmlSanitizerService) {
|
||||
this.repository = repository;
|
||||
this.sourceMessageInboxRepository = sourceMessageInboxRepository;
|
||||
this.htmlSanitizerService = htmlSanitizerService;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -167,6 +193,234 @@ public class ReservationAiQueryServiceImpl implements ReservationAiQueryService
|
||||
"当前系统尚未接入 OPERA 对象投影,日期、房型、房价等字段无法确认。")));
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询邮件会话下全部任务。排序先看邮件接收时间,再看任务创建时间,避免历史邮件任务乱序。
|
||||
*/
|
||||
@Override
|
||||
public ReservationMessageConversationTasksResult queryMessageConversationTasks(
|
||||
ReservationMessageConversationQueryRequest request) {
|
||||
validateConversationRequest(request);
|
||||
String hotelId = trimToNull(request.hotelId());
|
||||
List<SourceMessageInboxSnapshot> messages = findConversationMessages(request);
|
||||
Map<Long, SourceMessageInboxSnapshot> messageIndex = indexMessages(messages);
|
||||
List<ReservationMessageConversationTasksResult.TaskRecord> tasks = repository
|
||||
.findAiQueryTasksBySourceMessageIds(hotelId, messages.stream().map(SourceMessageInboxSnapshot::id).toList())
|
||||
.stream()
|
||||
.filter(task -> messageIndex.containsKey(task.sourceMessageId()))
|
||||
.sorted(Comparator
|
||||
.comparing(
|
||||
(ReservationAiQueryTaskSnapshot task) -> sourceReceivedAt(task, messageIndex),
|
||||
Comparator.nullsLast(LocalDateTime::compareTo))
|
||||
.thenComparing(
|
||||
ReservationAiQueryTaskSnapshot::createdAt,
|
||||
Comparator.nullsLast(LocalDateTime::compareTo))
|
||||
.thenComparing(ReservationAiQueryTaskSnapshot::id, Comparator.nullsLast(Long::compareTo)))
|
||||
.map(task -> toConversationTask(task, messageIndex.get(task.sourceMessageId())))
|
||||
.toList();
|
||||
return new ReservationMessageConversationTasksResult(
|
||||
hotelId,
|
||||
conversationId(messages, request),
|
||||
tasks.size(),
|
||||
tasks);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询邮件会话下全部受控正文。读取正文时写审计,只输出清洗后的 HTML 和纯文本正文。
|
||||
*/
|
||||
@Override
|
||||
@Transactional
|
||||
public ReservationMessageConversationMessagesResult queryMessageConversationMessages(
|
||||
ReservationMessageConversationQueryRequest request) {
|
||||
validateConversationRequest(request);
|
||||
String hotelId = trimToNull(request.hotelId());
|
||||
List<SourceMessageInboxSnapshot> messages = findConversationMessages(request);
|
||||
List<ReservationMessageConversationMessagesResult.MessageRecord> resultMessages = messages.stream()
|
||||
.map(this::toConversationMessage)
|
||||
.toList();
|
||||
return new ReservationMessageConversationMessagesResult(
|
||||
hotelId,
|
||||
conversationId(messages, request),
|
||||
resultMessages.size(),
|
||||
resultMessages);
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据会话 ID 或外部 SourceMessage ID 找到同一邮件链的全部消息。
|
||||
*/
|
||||
private List<SourceMessageInboxSnapshot> findConversationMessages(ReservationMessageConversationQueryRequest request) {
|
||||
String hotelId = trimToNull(request.hotelId());
|
||||
String externalConversationId = trimToNull(request.externalConversationId());
|
||||
if (externalConversationId != null) {
|
||||
List<SourceMessageInboxSnapshot> messages = sourceMessageInboxRepository.findByExternalConversationId(
|
||||
hotelId,
|
||||
sourceProvider(request),
|
||||
sourceChannel(request),
|
||||
externalConversationId);
|
||||
if (messages.isEmpty()) {
|
||||
throw notFound("MESSAGE_CONVERSATION_NOT_FOUND", "邮件会话不存在");
|
||||
}
|
||||
return messages;
|
||||
}
|
||||
|
||||
SourceMessageInboxSnapshot anchor = sourceMessageInboxRepository.findByIdempotencyKey(
|
||||
hotelId,
|
||||
sourceProvider(request),
|
||||
sourceChannel(request),
|
||||
trimToNull(request.sourceMessageId()))
|
||||
.orElseThrow(() -> notFound("SOURCE_MESSAGE_NOT_FOUND", "外部来源消息不存在"));
|
||||
String anchorConversationId = trimToNull(anchor.externalConversationId());
|
||||
if (anchorConversationId == null) {
|
||||
return List.of(anchor);
|
||||
}
|
||||
List<SourceMessageInboxSnapshot> messages = sourceMessageInboxRepository.findByExternalConversationId(
|
||||
hotelId,
|
||||
anchor.provider(),
|
||||
anchor.channel(),
|
||||
anchorConversationId);
|
||||
return messages.isEmpty() ? List.of(anchor) : messages;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将邮件列表按内部 SourceMessage ID 建索引,只在服务层内部用于任务关联。
|
||||
*/
|
||||
private Map<Long, SourceMessageInboxSnapshot> indexMessages(List<SourceMessageInboxSnapshot> messages) {
|
||||
Map<Long, SourceMessageInboxSnapshot> result = new LinkedHashMap<>();
|
||||
messages.forEach(message -> result.put(message.id(), message));
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将任务快照转换为 SuperAgent 会话任务响应,隐藏内部 SourceMessage ID。
|
||||
*/
|
||||
private ReservationMessageConversationTasksResult.TaskRecord toConversationTask(
|
||||
ReservationAiQueryTaskSnapshot task,
|
||||
SourceMessageInboxSnapshot message) {
|
||||
return new ReservationMessageConversationTasksResult.TaskRecord(
|
||||
idString(task.id()),
|
||||
idString(task.orderId()),
|
||||
message.externalMessageId(),
|
||||
message.externalConversationId(),
|
||||
UtcTimeFormatter.toUtcOffsetDateTime(message.receivedAt()),
|
||||
task.transitionSourceEventIndex(),
|
||||
task.catalogCode(),
|
||||
task.skillId(),
|
||||
task.resultType(),
|
||||
task.aiTaskType(),
|
||||
task.systemTaskType(),
|
||||
task.taskCardType(),
|
||||
task.taskSubtype(),
|
||||
task.taskStatus(),
|
||||
task.queueParticipation(),
|
||||
task.executionOrder(),
|
||||
idString(task.parentTaskId()),
|
||||
task.parentSourceEventIndex(),
|
||||
task.linkedTaskGroupId(),
|
||||
task.blockedUntilParentCompleted(),
|
||||
UtcTimeFormatter.toUtcOffsetDateTime(task.completedAt()),
|
||||
UtcTimeFormatter.toUtcOffsetDateTime(task.createdAt()),
|
||||
UtcTimeFormatter.toUtcOffsetDateTime(task.updatedAt()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 将邮件原文转换为受控正文响应,不返回附件 URL 或原始未清洗 HTML。
|
||||
*/
|
||||
private ReservationMessageConversationMessagesResult.MessageRecord toConversationMessage(
|
||||
SourceMessageInboxSnapshot message) {
|
||||
SourceMessageOriginalContent originalContent = readOriginalAndAudit(message);
|
||||
String htmlBody = originalContent.htmlBody();
|
||||
String sanitizedHtml = htmlSanitizerService.sanitizeHtml(removeMediaUrls(htmlBody, originalContent));
|
||||
return new ReservationMessageConversationMessagesResult.MessageRecord(
|
||||
message.externalMessageId(),
|
||||
message.externalConversationId(),
|
||||
message.senderSummary(),
|
||||
message.subject(),
|
||||
UtcTimeFormatter.toUtcOffsetDateTime(message.receivedAt()),
|
||||
UtcTimeFormatter.toUtcOffsetDateTime(message.sourceSentAt()),
|
||||
removeMediaUrls(originalContent.textBody(), originalContent),
|
||||
removeHtmlUrlAttributes(sanitizedHtml),
|
||||
true,
|
||||
htmlSanitizerService.htmlRenderMode(htmlBody));
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除正文中已知媒体外链,避免 SuperAgent 受控正文接口返回附件或内嵌图片 URL。
|
||||
*/
|
||||
private String removeMediaUrls(String body, SourceMessageOriginalContent originalContent) {
|
||||
if (body == null || originalContent.mediaItems() == null || originalContent.mediaItems().isEmpty()) {
|
||||
return body;
|
||||
}
|
||||
String result = body;
|
||||
for (SourceMessageOriginalMediaItem mediaItem : originalContent.mediaItems()) {
|
||||
String externalUrl = trimToNull(mediaItem.externalUrl());
|
||||
if (externalUrl != null) {
|
||||
result = result.replace(externalUrl, "");
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* SuperAgent 正文查询不返回任何 HTML URL 属性,图片和附件由后续受控原文接口处理。
|
||||
*/
|
||||
private String removeHtmlUrlAttributes(String htmlBody) {
|
||||
if (htmlBody == null) {
|
||||
return null;
|
||||
}
|
||||
return HTML_URL_ATTRIBUTE_PATTERN.matcher(htmlBody).replaceAll("");
|
||||
}
|
||||
|
||||
/**
|
||||
* 受控读取邮件正文并写入审计,便于后续追踪 SuperAgent 原文访问。
|
||||
*/
|
||||
private SourceMessageOriginalContent readOriginalAndAudit(SourceMessageInboxSnapshot message) {
|
||||
SourceMessageOriginalContent content = sourceMessageInboxRepository.findOriginalContent(message.id())
|
||||
.orElse(new SourceMessageOriginalContent(message.id(), null, null, List.of()));
|
||||
sourceMessageInboxRepository.insertOriginalAccessAudit(new SourceMessageOriginalAccessAuditDraft(
|
||||
message.id(),
|
||||
SUPERAGENT_AI_QUERY_ACTOR,
|
||||
MESSAGE_CONVERSATION_ACCESS_SCENE,
|
||||
SourceMessageOriginalAccessResult.GRANTED.code(),
|
||||
LocalDateTime.now(ZoneOffset.UTC)));
|
||||
return content;
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取任务来源邮件接收时间,供会话任务排序使用。
|
||||
*/
|
||||
private LocalDateTime sourceReceivedAt(
|
||||
ReservationAiQueryTaskSnapshot task,
|
||||
Map<Long, SourceMessageInboxSnapshot> messageIndex) {
|
||||
SourceMessageInboxSnapshot message = messageIndex.get(task.sourceMessageId());
|
||||
return message == null ? null : message.receivedAt();
|
||||
}
|
||||
|
||||
/**
|
||||
* 响应中的会话 ID 优先取实际消息快照,单封无会话 ID 时回显请求值。
|
||||
*/
|
||||
private String conversationId(
|
||||
List<SourceMessageInboxSnapshot> messages,
|
||||
ReservationMessageConversationQueryRequest request) {
|
||||
return messages.stream()
|
||||
.map(SourceMessageInboxSnapshot::externalConversationId)
|
||||
.filter(value -> trimToNull(value) != null)
|
||||
.findFirst()
|
||||
.orElse(trimToNull(request.externalConversationId()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询外部 source_message_id 时使用来源提供方;缺省按当前 AgentBus 邮件入口处理。
|
||||
*/
|
||||
private String sourceProvider(ReservationMessageConversationQueryRequest request) {
|
||||
return Optional.ofNullable(trimToNull(request.sourceProvider())).orElse(DEFAULT_SOURCE_PROVIDER);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询外部 source_message_id 时使用来源渠道;缺省按当前 EMAIL 邮件入口处理。
|
||||
*/
|
||||
private String sourceChannel(ReservationMessageConversationQueryRequest request) {
|
||||
return Optional.ofNullable(trimToNull(request.sourceChannel())).orElse(DEFAULT_SOURCE_CHANNEL);
|
||||
}
|
||||
|
||||
private ReservationAiCaseContextResult.MatchedOrderRecord toMatchedOrderRecord(
|
||||
ReservationAiQueryOrderSnapshot order) {
|
||||
return new ReservationAiCaseContextResult.MatchedOrderRecord(
|
||||
@@ -309,10 +563,26 @@ public class ReservationAiQueryServiceImpl implements ReservationAiQueryService
|
||||
requireText(request.objectId(), "OBJECT_ID_REQUIRED", "object_id 不能为空");
|
||||
}
|
||||
|
||||
private void validateConversationRequest(ReservationMessageConversationQueryRequest request) {
|
||||
if (request == null) {
|
||||
throw badRequest("MISSING_REQUEST_BODY", "请求体不能为空");
|
||||
}
|
||||
requireText(request.hotelId(), "HOTEL_ID_REQUIRED", "hotel_id 不能为空");
|
||||
if (trimToNull(request.externalConversationId()) == null && trimToNull(request.sourceMessageId()) == null) {
|
||||
throw badRequest(
|
||||
"MESSAGE_CONVERSATION_QUERY_KEY_REQUIRED",
|
||||
"external_conversation_id、source_message_id 至少需要一个");
|
||||
}
|
||||
}
|
||||
|
||||
private ReservationAiQueryException badRequest(String code, String message) {
|
||||
return new ReservationAiQueryException(HttpStatus.BAD_REQUEST, code, message);
|
||||
}
|
||||
|
||||
private ReservationAiQueryException notFound(String code, String message) {
|
||||
return new ReservationAiQueryException(HttpStatus.NOT_FOUND, code, message);
|
||||
}
|
||||
|
||||
private void requireText(String value, String code, String message) {
|
||||
if (trimToNull(value) == null) {
|
||||
throw badRequest(code, message);
|
||||
|
||||
@@ -11,11 +11,14 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
|
||||
|
||||
import cn.nianxx.thhotel.ThHotelApplication;
|
||||
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 java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import javax.crypto.Mac;
|
||||
@@ -45,6 +48,8 @@ class ReservationAiQueryControllerTest {
|
||||
private static final String HOTEL_ID = "HOTEL-TEST";
|
||||
private static final String CASE_CONTEXT_ENDPOINT = "/api/ai-query/v1/case-context";
|
||||
private static final String OBJECT_DETAIL_ENDPOINT = "/api/ai-query/v1/object-detail";
|
||||
private static final String CONVERSATION_TASKS_ENDPOINT = "/api/ai-query/v1/message-conversation/tasks";
|
||||
private static final String CONVERSATION_MESSAGES_ENDPOINT = "/api/ai-query/v1/message-conversation/messages";
|
||||
private static final String CLIENT_ID = "superagent-test-client";
|
||||
private static final String SECRET = "test-superagent-secret";
|
||||
private static final String UTC_INSTANT_PATTERN = "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(\\.\\d+)?Z$";
|
||||
@@ -313,24 +318,273 @@ class ReservationAiQueryControllerTest {
|
||||
.andExpect(content().string(not(containsString(SECRET))));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldQueryConversationMessagesByExternalConversationIdWithoutRawHtmlOrMediaUrls() throws Exception {
|
||||
String conversationId = "thread-ai-query-conversation-messages-001";
|
||||
captureSourceMessage(
|
||||
"mail-ai-query-conversation-messages-late-001",
|
||||
conversationId,
|
||||
Instant.parse("2026-07-07T09:10:00Z"),
|
||||
"Late message controlled text.",
|
||||
"<html><body><p>Late HTML body</p></body></html>",
|
||||
List.of());
|
||||
captureSourceMessage(
|
||||
"mail-ai-query-conversation-messages-early-001",
|
||||
conversationId,
|
||||
Instant.parse("2026-07-07T09:00:00Z"),
|
||||
"Early message controlled text.",
|
||||
"<html><body onclick=\"alert(1)\"><p>Early HTML body</p>"
|
||||
+ "<img src=\"https://media.example.test/inline.png?token=secret\" />"
|
||||
+ "<a href=\"https://media.example.test/private.pdf?token=secret\">private file</a>"
|
||||
+ "<a href=\"javascript:alert(2)\">unsafe link</a>"
|
||||
+ "<script>alert(3)</script></body></html>",
|
||||
List.of(new CaptureSourceMessageMedia(
|
||||
"ATTACHMENT",
|
||||
"private.pdf",
|
||||
"application/pdf",
|
||||
1000L,
|
||||
"https://media.example.test/private.pdf?token=secret",
|
||||
"attachment-ai-query-001")));
|
||||
|
||||
String body = """
|
||||
{
|
||||
"hotel_id": "HOTEL-TEST",
|
||||
"source_provider": "AGENTBUS",
|
||||
"source_channel": "EMAIL",
|
||||
"external_conversation_id": "%s"
|
||||
}
|
||||
""".formatted(conversationId);
|
||||
|
||||
mockMvc.perform(signedPost(CONVERSATION_MESSAGES_ENDPOINT, body,
|
||||
"nonce-ai-query-conversation-messages-001",
|
||||
"req-ai-query-conversation-messages-001"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.success").value(true))
|
||||
.andExpect(jsonPath("$.request_id").value("req-ai-query-conversation-messages-001"))
|
||||
.andExpect(jsonPath("$.data.hotel_id").value(HOTEL_ID))
|
||||
.andExpect(jsonPath("$.data.external_conversation_id").value(conversationId))
|
||||
.andExpect(jsonPath("$.data.message_count").value(2))
|
||||
.andExpect(jsonPath("$.data.messages[0].external_source_message_id")
|
||||
.value("mail-ai-query-conversation-messages-early-001"))
|
||||
.andExpect(jsonPath("$.data.messages[0].received_at").value(matchesPattern(UTC_INSTANT_PATTERN)))
|
||||
.andExpect(jsonPath("$.data.messages[0].text_body").value(containsString("Early message")))
|
||||
.andExpect(jsonPath("$.data.messages[0].html_body_sanitized").value(containsString("Early HTML body")))
|
||||
.andExpect(jsonPath("$.data.messages[0].html_render_mode").value("SANITIZED_HTML"))
|
||||
.andExpect(jsonPath("$.data.messages[1].external_source_message_id")
|
||||
.value("mail-ai-query-conversation-messages-late-001"))
|
||||
.andExpect(content().string(not(containsString("\"html_body\":"))))
|
||||
.andExpect(content().string(not(containsString("\"attachments\""))))
|
||||
.andExpect(content().string(not(containsString("media.example.test"))))
|
||||
.andExpect(content().string(not(containsString("token=secret"))))
|
||||
.andExpect(content().string(not(containsString("<script"))))
|
||||
.andExpect(content().string(not(containsString("onclick"))))
|
||||
.andExpect(content().string(not(containsString("javascript:"))));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldFilterConversationMessagesByProviderAndChannelWhenConversationIdOverlaps() throws Exception {
|
||||
String conversationId = "thread-ai-query-provider-overlap-001";
|
||||
captureSourceMessage(
|
||||
"AGENTBUS",
|
||||
"EMAIL",
|
||||
"mail-ai-query-provider-target-001",
|
||||
conversationId,
|
||||
Instant.parse("2026-07-07T09:00:00Z"),
|
||||
"Target provider text.",
|
||||
"<html><body>Target provider HTML.</body></html>",
|
||||
List.of());
|
||||
captureSourceMessage(
|
||||
"OTHER_PROVIDER",
|
||||
"EMAIL",
|
||||
"mail-ai-query-provider-other-001",
|
||||
conversationId,
|
||||
Instant.parse("2026-07-07T09:01:00Z"),
|
||||
"Other provider text must not leak.",
|
||||
"<html><body>Other provider HTML must not leak.</body></html>",
|
||||
List.of());
|
||||
|
||||
String body = """
|
||||
{
|
||||
"hotel_id": "HOTEL-TEST",
|
||||
"source_provider": "AGENTBUS",
|
||||
"source_channel": "EMAIL",
|
||||
"external_conversation_id": "%s"
|
||||
}
|
||||
""".formatted(conversationId);
|
||||
|
||||
mockMvc.perform(signedPost(CONVERSATION_MESSAGES_ENDPOINT, body,
|
||||
"nonce-ai-query-provider-overlap-001",
|
||||
"req-ai-query-provider-overlap-001"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.success").value(true))
|
||||
.andExpect(jsonPath("$.data.message_count").value(1))
|
||||
.andExpect(jsonPath("$.data.messages[0].external_source_message_id")
|
||||
.value("mail-ai-query-provider-target-001"))
|
||||
.andExpect(content().string(containsString("Target provider text")))
|
||||
.andExpect(content().string(not(containsString("Other provider text must not leak"))))
|
||||
.andExpect(content().string(not(containsString("mail-ai-query-provider-other-001"))));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldQueryConversationTasksByExternalSourceMessageIdAnchorInRequiredOrder() throws Exception {
|
||||
String conversationId = "thread-ai-query-conversation-tasks-001";
|
||||
SourceMessageCaptureResult lateSource = captureSourceMessage(
|
||||
"mail-ai-query-conversation-tasks-late-001",
|
||||
conversationId,
|
||||
Instant.parse("2026-07-07T09:10:00Z"),
|
||||
"Late task source.",
|
||||
"<html><body>Late task source.</body></html>",
|
||||
List.of());
|
||||
SourceMessageCaptureResult earlySource = captureSourceMessage(
|
||||
"mail-ai-query-conversation-tasks-early-001",
|
||||
conversationId,
|
||||
Instant.parse("2026-07-07T09:00:00Z"),
|
||||
"Early task source.",
|
||||
"<html><body>Early task source.</body></html>",
|
||||
List.of());
|
||||
insertActiveGroupOrder(920000000000001101L, earlySource.inboxId(), "GRP-AIQUERY-CONV-EARLY-001");
|
||||
insertActiveGroupOrder(920000000000001102L, lateSource.inboxId(), "GRP-AIQUERY-CONV-LATE-001");
|
||||
insertTransition(920000000000001201L, earlySource.inboxId(), 1, "GRP-AIQUERY-CONV-EARLY-001");
|
||||
insertTransition(920000000000001202L, earlySource.inboxId(), 2, "GRP-AIQUERY-CONV-EARLY-001");
|
||||
insertTransition(920000000000001203L, lateSource.inboxId(), 1, "GRP-AIQUERY-CONV-LATE-001");
|
||||
insertTask(
|
||||
920000000000001301L,
|
||||
920000000000001101L,
|
||||
earlySource.inboxId(),
|
||||
920000000000001201L,
|
||||
"READY",
|
||||
1,
|
||||
LocalDateTime.parse("2026-07-07T09:06:00"));
|
||||
insertTask(
|
||||
920000000000001302L,
|
||||
920000000000001101L,
|
||||
earlySource.inboxId(),
|
||||
920000000000001202L,
|
||||
"PENDING_CONFIRM",
|
||||
2,
|
||||
LocalDateTime.parse("2026-07-07T09:05:00"));
|
||||
insertTask(
|
||||
920000000000001303L,
|
||||
920000000000001102L,
|
||||
lateSource.inboxId(),
|
||||
920000000000001203L,
|
||||
"READY",
|
||||
1,
|
||||
LocalDateTime.parse("2026-07-07T08:00:00"));
|
||||
|
||||
String body = """
|
||||
{
|
||||
"hotel_id": "HOTEL-TEST",
|
||||
"source_provider": "AGENTBUS",
|
||||
"source_channel": "EMAIL",
|
||||
"source_message_id": "mail-ai-query-conversation-tasks-early-001"
|
||||
}
|
||||
""";
|
||||
|
||||
mockMvc.perform(signedPost(CONVERSATION_TASKS_ENDPOINT, body,
|
||||
"nonce-ai-query-conversation-tasks-001",
|
||||
"req-ai-query-conversation-tasks-001"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.success").value(true))
|
||||
.andExpect(jsonPath("$.data.hotel_id").value(HOTEL_ID))
|
||||
.andExpect(jsonPath("$.data.external_conversation_id").value(conversationId))
|
||||
.andExpect(jsonPath("$.data.task_count").value(3))
|
||||
.andExpect(jsonPath("$.data.tasks[0].task_id").value("920000000000001302"))
|
||||
.andExpect(jsonPath("$.data.tasks[0].external_source_message_id")
|
||||
.value("mail-ai-query-conversation-tasks-early-001"))
|
||||
.andExpect(jsonPath("$.data.tasks[0].task_created_at").value(matchesPattern(UTC_INSTANT_PATTERN)))
|
||||
.andExpect(jsonPath("$.data.tasks[1].task_id").value("920000000000001301"))
|
||||
.andExpect(jsonPath("$.data.tasks[1].external_source_message_id")
|
||||
.value("mail-ai-query-conversation-tasks-early-001"))
|
||||
.andExpect(jsonPath("$.data.tasks[2].task_id").value("920000000000001303"))
|
||||
.andExpect(jsonPath("$.data.tasks[2].external_source_message_id")
|
||||
.value("mail-ai-query-conversation-tasks-late-001"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectConversationMessagesWhenHmacSignatureInvalid() throws Exception {
|
||||
String body = """
|
||||
{
|
||||
"hotel_id": "HOTEL-TEST",
|
||||
"external_conversation_id": "thread-ai-query-hmac-invalid"
|
||||
}
|
||||
""";
|
||||
|
||||
mockMvc.perform(post(CONVERSATION_MESSAGES_ENDPOINT)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.header("X-TH-Hotel-Request-Id", "req-ai-query-conversation-hmac-invalid")
|
||||
.header("X-TH-Hotel-SuperAgent-Client-Id", CLIENT_ID)
|
||||
.header("X-TH-Hotel-SuperAgent-Timestamp", Instant.now().toString())
|
||||
.header("X-TH-Hotel-SuperAgent-Nonce", "nonce-ai-query-conversation-hmac-invalid")
|
||||
.header("X-TH-Hotel-SuperAgent-Signature", "sha256=invalid")
|
||||
.content(body))
|
||||
.andExpect(status().isUnauthorized())
|
||||
.andExpect(jsonPath("$.success").value(false))
|
||||
.andExpect(jsonPath("$.request_id").value("req-ai-query-conversation-hmac-invalid"))
|
||||
.andExpect(jsonPath("$.error.code").value("AUTH_SIGNATURE_INVALID"))
|
||||
.andExpect(content().string(not(containsString(SECRET))));
|
||||
}
|
||||
|
||||
private SourceMessageCaptureResult captureSourceMessage(String externalMessageId) {
|
||||
return captureService.capture(new CaptureSourceMessageCommand(
|
||||
HOTEL_ID,
|
||||
return captureSourceMessage(
|
||||
externalMessageId,
|
||||
"thread-" + externalMessageId,
|
||||
Instant.parse("2026-07-07T08:00:00Z"),
|
||||
"Please handle booking message.",
|
||||
"<html><body>Please handle booking message.</body></html>",
|
||||
List.of());
|
||||
}
|
||||
|
||||
private SourceMessageCaptureResult captureSourceMessage(
|
||||
String externalMessageId,
|
||||
String externalConversationId,
|
||||
Instant receivedAt,
|
||||
String textBody,
|
||||
String htmlBody,
|
||||
List<CaptureSourceMessageMedia> mediaItems) {
|
||||
return captureSourceMessage(
|
||||
"AGENTBUS",
|
||||
"EMAIL",
|
||||
externalMessageId,
|
||||
"thread-" + externalMessageId,
|
||||
externalConversationId,
|
||||
receivedAt,
|
||||
textBody,
|
||||
htmlBody,
|
||||
mediaItems);
|
||||
}
|
||||
|
||||
private SourceMessageCaptureResult captureSourceMessage(
|
||||
String provider,
|
||||
String channel,
|
||||
String externalMessageId,
|
||||
String externalConversationId,
|
||||
Instant receivedAt,
|
||||
String textBody,
|
||||
String htmlBody,
|
||||
List<CaptureSourceMessageMedia> mediaItems) {
|
||||
SourceMessageCaptureResult result = captureService.capture(new CaptureSourceMessageCommand(
|
||||
HOTEL_ID,
|
||||
provider,
|
||||
channel,
|
||||
externalMessageId,
|
||||
externalConversationId,
|
||||
"frame-" + externalMessageId,
|
||||
"session-ai-query",
|
||||
Instant.parse("2026-07-07T08:00:00Z"),
|
||||
receivedAt,
|
||||
"guest@example.test",
|
||||
"M002 AI Query",
|
||||
"Please handle booking message.",
|
||||
"<html><body>Please handle booking message.</body></html>",
|
||||
textBody,
|
||||
htmlBody,
|
||||
"{\"source\":{\"external_message_id\":\"" + externalMessageId + "\"}}",
|
||||
"agentbus-outlook-v1",
|
||||
List.of()
|
||||
mediaItems
|
||||
));
|
||||
jdbcTemplate.update("""
|
||||
UPDATE platform_source_message_inbox
|
||||
SET received_at = ?
|
||||
WHERE id = ?
|
||||
""", LocalDateTime.ofInstant(receivedAt, ZoneOffset.UTC), result.inboxId());
|
||||
return result;
|
||||
}
|
||||
|
||||
private void insertActiveGroupOrder(Long orderId, Long sourceMessageId, String groupCode) {
|
||||
@@ -359,10 +613,22 @@ class ReservationAiQueryControllerTest {
|
||||
'update_stay_dates', 'current', ?, ?, ?, 0, '{}', '{}', '{}',
|
||||
CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||
""", transitionId, HOTEL_ID, transitionId - 1, sourceMessageId, sourceEventIndex, groupCode,
|
||||
"0".repeat(64), "1".repeat(64));
|
||||
fixedHash(transitionId), fixedHash(transitionId + 1));
|
||||
}
|
||||
|
||||
private void insertTask(Long taskId, Long orderId, Long sourceMessageId, Long transitionId, String taskStatus) {
|
||||
insertTask(taskId, orderId, sourceMessageId, transitionId, taskStatus, 1, null);
|
||||
}
|
||||
|
||||
private void insertTask(
|
||||
Long taskId,
|
||||
Long orderId,
|
||||
Long sourceMessageId,
|
||||
Long transitionId,
|
||||
String taskStatus,
|
||||
int executionOrder,
|
||||
LocalDateTime createdAt) {
|
||||
LocalDateTime createdAtValue = createdAt == null ? LocalDateTime.now(ZoneOffset.UTC) : createdAt;
|
||||
jdbcTemplate.update("""
|
||||
INSERT INTO workflow_reservation_task (
|
||||
id, hotel_id, order_id, source_message_id, ai_transition_id,
|
||||
@@ -371,9 +637,14 @@ class ReservationAiQueryControllerTest {
|
||||
version, created_at, updated_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, 'normal_task', 'Update Booking', 'UPDATE_BOOKING',
|
||||
'UPDATE_BOOKING', 'update_stay_dates', ?, 1, 1, 0, 0,
|
||||
CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||
""", taskId, HOTEL_ID, orderId, sourceMessageId, transitionId, taskStatus);
|
||||
'UPDATE_BOOKING', 'update_stay_dates', ?, 1, ?, 0, 0,
|
||||
?, ?)
|
||||
""", taskId, HOTEL_ID, orderId, sourceMessageId, transitionId, taskStatus,
|
||||
executionOrder, createdAtValue, createdAtValue);
|
||||
}
|
||||
|
||||
private String fixedHash(Long value) {
|
||||
return String.format("%064d", value);
|
||||
}
|
||||
|
||||
private MockHttpServletRequestBuilder signedPost(String endpoint, String body, String nonce, String requestId) throws Exception {
|
||||
|
||||
Reference in New Issue
Block a user