diff --git a/docs/project/integrations/superagent-api-contract.md b/docs/project/integrations/superagent-api-contract.md index a44c7f8..2d6552d 100644 --- a/docs/project/integrations/superagent-api-contract.md +++ b/docs/project/integrations/superagent-api-contract.md @@ -4,10 +4,10 @@ | 项目 | 内容 | | --- | --- | -| 文档版本 | 0.2 | -| 日期 | 2026-07-08 | -| 状态 | 第一版后端已实现接口契约 | -| 适用范围 | SuperAgent 调用本系统查询上下文、提交 AI 任务结果 | +| 文档版本 | 0.3 | +| 日期 | 2026-07-09 | +| 状态 | 已增加邮件会话任务和受控正文查询接口 | +| 适用范围 | SuperAgent 调用本系统查询上下文、查询邮件会话、提交 AI 任务结果 | | 主要读者 | SuperAgent 对接方、后端、测试、运维 | ## 联调参数区 @@ -17,7 +17,7 @@ | 参数 | 当前联调值 | 中文说明 | | --- | --- | --- | | `TH_HOTEL_API_BASE_URL` | `http://8.138.234.141:18087` | 本系统后端基础地址;本地联调用 8080,部署环境改为实际网关或服务地址。 | -| `HOTEL_ID` | `HOTEL-DEV` | 当前 dev profile 下 AgentBus 入库默认酒店 ID;调用三个 SuperAgent 接口时均应传入请求体 `hotel_id`。 | +| `HOTEL_ID` | `HOTEL-DEV` | 当前 dev profile 下 AgentBus 入库默认酒店 ID;调用五个 SuperAgent 接口时均应传入请求体 `hotel_id`。 | | `SUPERAGENT_CLIENT_ID` | `superagent-debug` | SuperAgent 调用方 ID,对应 Header `X-TH-Hotel-SuperAgent-Client-Id`。 | | `SUPERAGENT_HMAC_SECRET` | `th-hotel-superagent-debug-20260709-change-before-prod` | dev/test 联调临时 HMAC 密钥;生产上线前必须更换为新的高强度随机密钥。 | @@ -103,6 +103,8 @@ SuperAgent 不应知道或依赖内部 SourceMessage Inbox ID。任务结果通 查询接口 1、2 在 SuperAgent 查询阶段不依赖当前邮件是否已经入库。若请求体兼容旧契约传入 `source_message_id` 或 `source_event_index`,第一版后端会接收但忽略,不校验它们的格式,也不把它们作为查询边界。 +查询接口 3、4 面向已经入库的邮件会话:`external_conversation_id` 按 `hotel_id + source_provider + source_channel + external_conversation_id` 查询;`source_message_id` 表示外部来源消息 ID,可作为锚点反查该邮件所属会话。 + ## 4. 接口 1:查询订单上下文 ### 4.1 请求 @@ -265,10 +267,171 @@ SuperAgent 不应知道或依赖内部 SourceMessage Inbox ID。任务结果通 说明:接口 2 响应中的 `source_message_id` 当前是本系统内部 SourceMessage Inbox ID,用于对象溯源和排查;不要把该字段当作 SuperAgent 任务结果通知接口的外部 `source_message_id` 使用。 -## 6. 接口 3:SuperAgent 通知 AI 任务结果 +## 6. 接口 3:查询邮件会话下所有任务 ### 6.1 请求 +| 项目 | 内容 | +| --- | --- | +| Method | `POST` | +| URL | `{TH_HOTEL_API_BASE_URL}/api/ai-query/v1/message-conversation/tasks` | +| request_path | `/api/ai-query/v1/message-conversation/tasks` | +| Content-Type | `application/json` | +| 业务动作 | 只读查询邮件会话下任务,不创建任务、不修改订单、不写 OPERA | + +### 6.2 请求体 + +按外部邮件会话 ID 查询: + +```json +{ + "hotel_id": "", + "source_provider": "AGENTBUS", + "source_channel": "EMAIL", + "external_conversation_id": "thread-20260708-0001" +} +``` + +按外部来源消息 ID 作为锚点反查会话: + +```json +{ + "hotel_id": "", + "source_provider": "AGENTBUS", + "source_channel": "EMAIL", + "source_message_id": "mail-20260708-0001" +} +``` + +字段说明: + +| 字段 | 是否必填 | 中文说明 | +| --- | --- | --- | +| `hotel_id` | 是 | 酒店上下文 ID | +| `source_provider` | 否 | 来源提供方,按会话 ID 查询和按 `source_message_id` 反查时都参与隔离,缺省为 `AGENTBUS` | +| `source_channel` | 否 | 来源渠道,按会话 ID 查询和按 `source_message_id` 反查时都参与隔离,缺省为 `EMAIL` | +| `external_conversation_id` | 条件必填 | 外部邮件会话 ID,对应 AgentBus `source.external_conversation_id` | +| `source_message_id` | 条件必填 | 外部来源消息 ID,对应 AgentBus `source.external_message_id`,不是内部 Inbox ID | + +`external_conversation_id`、`source_message_id` 至少一个非空。两者同时传入时,第一版以后端直接按 `external_conversation_id` 查询为准。 + +### 6.3 成功响应 + +```json +{ + "success": true, + "request_id": "req-003", + "trace_id": "trace-001", + "data": { + "hotel_id": "HOTEL-DEV", + "external_conversation_id": "thread-20260708-0001", + "task_count": 2, + "tasks": [ + { + "task_id": "1900000000000000400", + "order_id": "1900000000000000300", + "external_source_message_id": "mail-20260708-0001", + "external_conversation_id": "thread-20260708-0001", + "source_received_at": "2026-07-08T01:00:00Z", + "source_event_index": 1, + "catalog_code": "S02", + "skill_id": "update_booking_amendment_skill", + "result_type": "normal_task", + "task_type": "Update Booking", + "system_task_type": "UPDATE_BOOKING", + "task_card_type": "UPDATE_BOOKING", + "task_subtype": "update_stay_dates", + "task_status": "PENDING_CONFIRM", + "queue_participation": true, + "execution_order": 1, + "parent_task_id": null, + "parent_source_event_index": null, + "linked_task_group_id": null, + "blocked_until_parent_completed": false, + "completed_at": null, + "task_created_at": "2026-07-08T01:01:00Z", + "task_updated_at": "2026-07-08T01:01:00Z" + } + ] + }, + "warnings": [], + "error": null +} +``` + +排序规则: + +1. 先按邮件 `received_at` 正序。 +2. 同一封邮件下,再按任务 `created_at` 正序。 +3. 若时间相同,再按 `task_id` 正序稳定排序。 + +## 7. 接口 4:查询邮件会话下所有受控正文 + +### 7.1 请求 + +| 项目 | 内容 | +| --- | --- | +| Method | `POST` | +| URL | `{TH_HOTEL_API_BASE_URL}/api/ai-query/v1/message-conversation/messages` | +| request_path | `/api/ai-query/v1/message-conversation/messages` | +| Content-Type | `application/json` | +| 业务动作 | 只读查询邮件会话受控正文,不返回附件 URL 或原始未清洗 HTML | + +### 7.2 请求体 + +请求体字段与接口 3 相同,可按 `external_conversation_id` 查询,也可按外部 `source_message_id` 锚点反查会话。 + +```json +{ + "hotel_id": "", + "source_provider": "AGENTBUS", + "source_channel": "EMAIL", + "source_message_id": "mail-20260708-0001" +} +``` + +### 7.3 成功响应 + +```json +{ + "success": true, + "request_id": "req-004", + "trace_id": "trace-001", + "data": { + "hotel_id": "HOTEL-DEV", + "external_conversation_id": "thread-20260708-0001", + "message_count": 2, + "messages": [ + { + "external_source_message_id": "mail-20260708-0001", + "external_conversation_id": "thread-20260708-0001", + "sender_summary": "guest@example.test", + "subject": "Booking update", + "received_at": "2026-07-08T01:00:00Z", + "source_sent_at": "2026-07-08T00:59:00Z", + "text_body": "Please update arrival date...", + "html_body_sanitized": "Please update arrival date...", + "html_sanitize_required": true, + "html_render_mode": "SANITIZED_HTML" + } + ] + }, + "warnings": [], + "error": null +} +``` + +安全边界: + +- `messages[]` 按邮件 `received_at` 正序返回。 +- 不返回 `html_body` 原始未清洗 HTML。 +- 不返回 `attachments`、`inline_images`、`external_url`、附件 URL 或 HTML 中的 `href/src` 外链属性。 +- 后端读取正文时会写入 SourceMessage 原文访问审计。 + +## 8. 接口 5:SuperAgent 通知 AI 任务结果 + +### 8.1 请求 + | 项目 | 内容 | | --- | --- | | Method | `POST` | @@ -277,7 +440,7 @@ SuperAgent 不应知道或依赖内部 SourceMessage Inbox ID。任务结果通 | Content-Type | `application/json` | | 业务动作 | 接收 AI 任务结果,写入 AI 过渡层、订单、任务和任务卡 | -### 6.2 请求体 +### 8.2 请求体 ```json { @@ -332,7 +495,7 @@ SuperAgent 不应知道或依赖内部 SourceMessage Inbox ID。任务结果通 正式联调时,后端通过 `hotel_id + source_provider(默认 AGENTBUS) + source_channel(默认 EMAIL) + source_message_id` 查找 `platform_source_message_inbox.external_message_id`。如果没有找到,返回 `SOURCE_MESSAGE_NOT_FOUND`。本地旧夹具允许在缺少 `hotel_id` 时使用内部数字 SourceMessage ID,但该兼容路径不作为 SuperAgent 正式契约。 -### 6.3 成功响应 +### 8.3 成功响应 ```json { @@ -359,9 +522,9 @@ SuperAgent 不应知道或依赖内部 SourceMessage Inbox ID。任务结果通 } ``` -## 7. 错误响应 +## 9. 错误响应 -### 7.1 查询接口错误响应 +### 9.1 查询接口错误响应 ```json { @@ -378,7 +541,7 @@ SuperAgent 不应知道或依赖内部 SourceMessage Inbox ID。任务结果通 } ``` -### 7.2 任务结果通知接口错误响应 +### 9.2 任务结果通知接口错误响应 ```json { @@ -389,7 +552,7 @@ SuperAgent 不应知道或依赖内部 SourceMessage Inbox ID。任务结果通 } ``` -### 7.3 常见错误码 +### 9.3 常见错误码 | 错误码 | HTTP 状态 | 中文说明 | | --- | --- | --- | @@ -402,11 +565,13 @@ SuperAgent 不应知道或依赖内部 SourceMessage Inbox ID。任务结果通 | `REQUEST_CONTENT_TYPE_UNSUPPORTED` | 415 | `Content-Type` 不是 `application/json` | | `REQUEST_BODY_INVALID` | 400 | JSON 不合法 | | `QUERY_KEY_REQUIRED` | 400 | 查询接口缺少可用业务 key | +| `MESSAGE_CONVERSATION_QUERY_KEY_REQUIRED` | 400 | 会话查询缺少 `external_conversation_id` 或 `source_message_id` | | `OBJECT_NOT_FOUND` | 404 | 对象详情查询目标不存在 | -| `HOTEL_ID_REQUIRED` | 400 | 三个 SuperAgent 接口缺少必填 `hotel_id` | -| `SOURCE_MESSAGE_NOT_FOUND` | 404 | 任务结果通知引用的外部来源消息尚未写入 SourceMessage Inbox | +| `MESSAGE_CONVERSATION_NOT_FOUND` | 404 | 外部邮件会话尚未写入 SourceMessage Inbox | +| `HOTEL_ID_REQUIRED` | 400 | 五个 SuperAgent 接口缺少必填 `hotel_id` | +| `SOURCE_MESSAGE_NOT_FOUND` | 404 | 任务结果通知或会话锚点引用的外部来源消息尚未写入 SourceMessage Inbox | -## 8. HMAC 上线配置 +## 10. HMAC 上线配置 上线需要配置: diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/message/repository/MybatisSourceMessageInboxRepository.java b/server/src/main/java/cn/nianxx/thhotel/platform/message/repository/MybatisSourceMessageInboxRepository.java index 7f87571..f5db3ae 100644 --- a/server/src/main/java/cn/nianxx/thhotel/platform/message/repository/MybatisSourceMessageInboxRepository.java +++ b/server/src/main/java/cn/nianxx/thhotel/platform/message/repository/MybatisSourceMessageInboxRepository.java @@ -110,6 +110,30 @@ public class MybatisSourceMessageInboxRepository implements SourceMessageInboxRe .toList(); } + /** + * 查询指定来源和渠道下的同一外部会话,避免不同接入方会话 ID 偶然相同导致串数据。 + */ + @Override + public List 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.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 幂等键读取已有记录,用于重复投递判断。 */ diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/message/repository/SourceMessageInboxRepository.java b/server/src/main/java/cn/nianxx/thhotel/platform/message/repository/SourceMessageInboxRepository.java index 2f3091e..08c23b4 100644 --- a/server/src/main/java/cn/nianxx/thhotel/platform/message/repository/SourceMessageInboxRepository.java +++ b/server/src/main/java/cn/nianxx/thhotel/platform/message/repository/SourceMessageInboxRepository.java @@ -31,6 +31,15 @@ public interface SourceMessageInboxRepository { */ List findByExternalConversationId(String hotelId, String externalConversationId); + /** + * 按酒店、来源、渠道和外部邮件会话 ID 查询同一会话全部 Inbox 安全快照。 + */ + List findByExternalConversationId( + String hotelId, + String provider, + String channel, + String externalConversationId); + /** * 按酒店、来源、渠道、外部邮件 ID 查询幂等记录。 */ diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/request/ReservationMessageConversationQueryRequest.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/request/ReservationMessageConversationQueryRequest.java new file mode 100644 index 0000000..e376d76 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/request/ReservationMessageConversationQueryRequest.java @@ -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 +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationMessageConversationMessagesResult.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationMessageConversationMessagesResult.java new file mode 100644 index 0000000..b8fb38b --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationMessageConversationMessagesResult.java @@ -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 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 + ) { + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationMessageConversationTasksResult.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationMessageConversationTasksResult.java new file mode 100644 index 0000000..baa9254 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationMessageConversationTasksResult.java @@ -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 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 + ) { + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationAiQueryController.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationAiQueryController.java index 0b2d8c0..94f8f18 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationAiQueryController.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationAiQueryController.java @@ -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> + 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> + 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。 */ diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/ReservationAiQueryService.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/ReservationAiQueryService.java index 3b53298..8b313a0 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/ReservationAiQueryService.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/ReservationAiQueryService.java @@ -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); } diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationAiQueryServiceImpl.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationAiQueryServiceImpl.java index 1be7281..01e33a8 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationAiQueryServiceImpl.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationAiQueryServiceImpl.java @@ -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 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 messages = findConversationMessages(request); + Map messageIndex = indexMessages(messages); + List 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 messages = findConversationMessages(request); + List resultMessages = messages.stream() + .map(this::toConversationMessage) + .toList(); + return new ReservationMessageConversationMessagesResult( + hotelId, + conversationId(messages, request), + resultMessages.size(), + resultMessages); + } + + /** + * 根据会话 ID 或外部 SourceMessage ID 找到同一邮件链的全部消息。 + */ + private List findConversationMessages(ReservationMessageConversationQueryRequest request) { + String hotelId = trimToNull(request.hotelId()); + String externalConversationId = trimToNull(request.externalConversationId()); + if (externalConversationId != null) { + List 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 messages = sourceMessageInboxRepository.findByExternalConversationId( + hotelId, + anchor.provider(), + anchor.channel(), + anchorConversationId); + return messages.isEmpty() ? List.of(anchor) : messages; + } + + /** + * 将邮件列表按内部 SourceMessage ID 建索引,只在服务层内部用于任务关联。 + */ + private Map indexMessages(List messages) { + Map 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 messageIndex) { + SourceMessageInboxSnapshot message = messageIndex.get(task.sourceMessageId()); + return message == null ? null : message.receivedAt(); + } + + /** + * 响应中的会话 ID 优先取实际消息快照,单封无会话 ID 时回显请求值。 + */ + private String conversationId( + List 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); diff --git a/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationAiQueryControllerTest.java b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationAiQueryControllerTest.java index 1a7370c..3151417 100644 --- a/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationAiQueryControllerTest.java +++ b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationAiQueryControllerTest.java @@ -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.", + "

Late HTML body

", + List.of()); + captureSourceMessage( + "mail-ai-query-conversation-messages-early-001", + conversationId, + Instant.parse("2026-07-07T09:00:00Z"), + "Early message controlled text.", + "

Early HTML body

" + + "" + + "private file" + + "unsafe link" + + "", + 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("Target provider 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.", + "Other provider HTML must not leak.", + 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.", + "Late task source.", + List.of()); + SourceMessageCaptureResult earlySource = captureSourceMessage( + "mail-ai-query-conversation-tasks-early-001", + conversationId, + Instant.parse("2026-07-07T09:00:00Z"), + "Early task source.", + "Early task source.", + 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.", + "Please handle booking message.", + List.of()); + } + + private SourceMessageCaptureResult captureSourceMessage( + String externalMessageId, + String externalConversationId, + Instant receivedAt, + String textBody, + String htmlBody, + List 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 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.", - "Please handle booking message.", + 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 {