From eee37315a029f6f57ea097aa6290ee561bc2a918 Mon Sep 17 00:00:00 2001 From: andy Date: Sun, 12 Jul 2026 14:53:54 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E6=AD=A3SuperAgent=E4=BB=BB=E5=8A=A1?= =?UTF-8?q?=E7=BB=93=E6=9E=9C=E6=9D=A5=E6=BA=90=E6=B6=88=E6=81=AF=E5=AE=9A?= =?UTF-8?q?=E4=BD=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/project/go-live-notes.md | 1 + .../integrations/superagent-api-contract.md | 12 +- .../superagent-mcp/integration-guide.md | 2 + .../integrations/superagent-mcp/test-cases.md | 2 + .../integrations/superagent-mcp/tools.md | 11 +- .../M002-order-task-workflow-v3.md | 14 +- ...002-superagent-task-result-api-contract.md | 19 +- ...002-v3-p0.1-parent-group-routing-update.md | 7 +- .../impl/SuperAgentMcpServiceImpl.java | 4 +- .../MybatisSourceMessageInboxRepository.java | 18 + .../SourceMessageInboxRepository.java | 5 + .../ReservationAiTaskIntakeServiceImpl.java | 212 ++++++++---- .../src/main/resources/application-test.yml | 2 + ..._message_external_message_lookup_index.sql | 3 + .../control/SuperAgentMcpControllerTest.java | 4 + .../SuperAgentTaskResultControllerTest.java | 324 +++++++++++++++++- ...gentTaskResultP0FixtureRegressionTest.java | 103 ++++++ ...ltStrictSourceMessageIdControllerTest.java | 132 +++++++ 18 files changed, 789 insertions(+), 86 deletions(-) create mode 100644 server/src/main/resources/db/migration/V19__add_source_message_external_message_lookup_index.sql create mode 100644 server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/SuperAgentTaskResultStrictSourceMessageIdControllerTest.java diff --git a/docs/project/go-live-notes.md b/docs/project/go-live-notes.md index 368238d..4f5e5e8 100644 --- a/docs/project/go-live-notes.md +++ b/docs/project/go-live-notes.md @@ -131,6 +131,7 @@ | `SUPERAGENT_TASK_RESULT_CLOCK_SKEW_SECONDS` | 否 | 请求时间允许偏移,默认 `300` 秒。上线前确认本系统、SuperAgent 和 AgentBus 所在机器时间已通过 NTP 同步。 | | `SUPERAGENT_TASK_RESULT_NONCE_TTL_SECONDS` | 否 | nonce 防重放窗口,默认 `600` 秒。 | | `SUPERAGENT_TASK_RESULT_MAX_BODY_BYTES` | 否 | SuperAgent 入站请求体最大字节数,默认 `1048576`。 | +| `SUPERAGENT_TEST_ALLOW_LEGACY_INTERNAL_SOURCE_MESSAGE_ID` | 否 | 仅用于本地 / test 旧夹具兼容内部 SourceMessage ID;正式联调和生产不得开启。 | 注意: diff --git a/docs/project/integrations/superagent-api-contract.md b/docs/project/integrations/superagent-api-contract.md index 38fe2fb..a9c8258 100644 --- a/docs/project/integrations/superagent-api-contract.md +++ b/docs/project/integrations/superagent-api-contract.md @@ -99,7 +99,7 @@ X-TH-Hotel-SuperAgent-Signature: sha256= | 外部来源消息 ID | AgentBus 邮件 payload 中的 `source.external_message_id`,SuperAgent / Main Agent 在最终 JSON 中原样带回为 `source_message_id` | SuperAgent 任务结果通知接口入参和响应回显 | | 内部 SourceMessage Inbox ID | `platform_source_message_inbox.id`,本系统数据库内部主键 | `workflow_*` 表的 `source_message_id` 外键、前端和运维排查 | -SuperAgent 不应知道或依赖内部 SourceMessage Inbox ID。任务结果通知接口收到外部 `source_message_id` 后,后端先解析系统酒店,再使用 `hotel_id + provider + channel + external_message_id` 反查内部 Inbox 记录,最后用内部 ID 写入业务表。 +SuperAgent 不应知道或依赖内部 SourceMessage Inbox ID,也不需要为任务结果通知传数据库层 provider/channel。任务结果通知接口收到外部 `source_message_id` 后,后端先解析系统酒店,再使用 `hotel_id + external_message_id` 反查唯一 Inbox 记录,最后用内部 ID 写入业务表;真实 provider/channel 以 SourceMessage Inbox 入库值为准。 查询接口 1、2 在 SuperAgent 查询阶段不依赖当前邮件是否已经入库。若请求体兼容旧契约传入 `source_message_id` 或 `source_event_index`,第一版后端会接收但忽略,不校验它们的格式,也不把它们作为查询边界。 @@ -626,8 +626,8 @@ V3 字段说明: | --- | --- | --- | | `hotel_id` | 否 | 酒店上下文 ID;SuperAgent 默认不传,后端解析系统酒店后用于反查 SourceMessage Inbox 幂等键。若兼容旧契约传入,单酒店阶段必须与系统酒店一致。 | | `source_message_id` | 是 | SuperAgent / Main Agent 原样带回的外部来源消息 ID,对应 AgentBus `source.external_message_id`,一次请求只能有一个 | -| `source_provider` | 否 | 来源提供方,第一版缺省为 `AGENTBUS` | -| `source_channel` | 否 | 来源渠道,第一版缺省为 `EMAIL` | +| `source_provider` | 否 | V2 兼容字段;通常不传。写入定位不使用该字段,真实 provider 以 SourceMessage Inbox 入库值为准 | +| `source_channel` | 否 | V2 兼容字段;通常不传。写入定位不使用该字段,AgentBus 邮件真实入库渠道可能是 `OUTLOOK` | | `ai_task_results[]` | 是 | AI 拆分出的任务结果列表,必须保留数组顺序 | | `ai_task_results[].source_event_index` | 是 | AI current 事件序号 | | `ai_task_results[].catalog_code` | 是 | Skill 目录代码 | @@ -638,7 +638,7 @@ V3 字段说明: | `ai_task_results[].case_keys` | 否 | 订单关联候选键 | | `ai_task_results[].extracted_fields` | 否 | 业务字段主体 | -正式联调时,SuperAgent 不需要传 `hotel_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 正式契约。 +正式联调时,SuperAgent 不需要传 `hotel_id`。后端通过系统酒店和外部 `source_message_id` 查找唯一 `platform_source_message_inbox.external_message_id`,真实 provider/channel 以 Inbox 入库值为准。如果没有找到,返回 `SOURCE_MESSAGE_NOT_FOUND`;如果同一系统酒店下匹配到多条,返回 `SOURCE_MESSAGE_AMBIGUOUS`。本地旧夹具允许在缺少 `hotel_id` 时使用内部数字 SourceMessage ID,但该兼容路径不作为 SuperAgent 正式契约。 `informational_message` 结构化任务仅用于历史兼容。新数据如果是纯信息类邮件或无法形成业务素材包,应优先使用 V3 结构化 `S10/S99`;旧联调或兼容场景仍可使用下面的 `S000/S999` 文本请求体。 @@ -664,7 +664,7 @@ S999,mail-20260708-0001 | `S999` | 入口阶段无法形成业务素材包,不需要进入业务执行。 | | `mail-20260708-0001` | 外部来源消息 ID,对应 SourceMessage Inbox 的 `external_message_id`。 | -S000/S999 不在 body 里传 `hotel_id`,后端使用平台酒店表唯一 `ACTIVE` 酒店查询 SourceMessage Inbox。命中后创建 `SOURCE_MESSAGE_ONLY` 只读特殊任务:任务列表可见,订单列表不可见,不允许编辑、确认、转换订单、执行 OPERA 或重试 OPERA,也不参与同订单任务执行顺序阻塞。该文本格式仅为兼容路径,新数据优先使用结构化 `S10/S99`。 +S000/S999 不在 body 里传 `hotel_id`,后端使用平台酒店表唯一 `ACTIVE` 酒店和外部消息 ID 查询 SourceMessage Inbox。命中后创建 `SOURCE_MESSAGE_ONLY` 只读特殊任务:任务列表可见,订单列表不可见,不允许编辑、确认、转换订单、执行 OPERA 或重试 OPERA,也不参与同订单任务执行顺序阻塞。该文本格式仅为兼容路径,新数据优先使用结构化 `S10/S99`。 ### 8.6 成功响应 @@ -796,6 +796,7 @@ V3 `adapter_contract_error` 响应中的 `items[]` 不会包含 `order_id` / `ta | `SYSTEM_HOTEL_AMBIGUOUS` | 409 | 单酒店阶段平台酒店表存在多家 `ACTIVE` 酒店 | | `HOTEL_ACCESS_DENIED` | 403 | 显式传入的 `hotel_id` 与系统酒店或当前用户授权酒店不一致 | | `SOURCE_MESSAGE_NOT_FOUND` | 404 | 任务结果通知或会话锚点引用的外部来源消息尚未写入 SourceMessage Inbox | +| `SOURCE_MESSAGE_AMBIGUOUS` | 409 | 任务结果通知的外部来源消息在同一系统酒店下匹配到多条 Inbox 记录,后端拒绝随机选择 | | `missing_source_message_id` | 400 | V3 请求缺少 `source_message.source_message_id`,响应体为 typed `infrastructure_input_error` | ## 10. HMAC 上线配置 @@ -811,6 +812,7 @@ V3 `adapter_contract_error` 响应中的 `items[]` 不会包含 `order_id` / `ta | `SUPERAGENT_TASK_RESULT_CLOCK_SKEW_SECONDS` | 否 | 请求时间允许偏移,默认 `300` 秒 | | `SUPERAGENT_TASK_RESULT_NONCE_TTL_SECONDS` | 否 | nonce 防重放保存时间,默认 `600` 秒 | | `SUPERAGENT_TASK_RESULT_MAX_BODY_BYTES` | 否 | 请求体最大字节数,默认 `1048576` | +| `SUPERAGENT_TEST_ALLOW_LEGACY_INTERNAL_SOURCE_MESSAGE_ID` | 否 | 仅 test 本地旧夹具兼容内部 SourceMessage ID;正式 dev / test 联调和 prod 不应开启 | 上线注意事项: diff --git a/docs/project/integrations/superagent-mcp/integration-guide.md b/docs/project/integrations/superagent-mcp/integration-guide.md index f239666..dd1a541 100644 --- a/docs/project/integrations/superagent-mcp/integration-guide.md +++ b/docs/project/integrations/superagent-mcp/integration-guide.md @@ -130,6 +130,8 @@ REST request 5. 必要时调用 `th_hotel_list_message_conversation_tasks` 查询同一邮件会话下已有任务。 6. 最终只在明确产出任务结果时调用 `th_hotel_submit_task_results`。 +写入工具里的 `source_message_id` 必须来自 AgentBus payload 的 `source.external_message_id`。SuperAgent 不需要传数据库层 provider/channel;TH Hotel 后端按系统酒店和外部消息 ID 匹配唯一 SourceMessage Inbox,真实 channel 可能是 `OUTLOOK`。 + ## 9. 当前 checkpoint 当前 checkpoint: diff --git a/docs/project/integrations/superagent-mcp/test-cases.md b/docs/project/integrations/superagent-mcp/test-cases.md index 80f687b..d086101 100644 --- a/docs/project/integrations/superagent-mcp/test-cases.md +++ b/docs/project/integrations/superagent-mcp/test-cases.md @@ -91,6 +91,8 @@ | MCP-T05-005 | 缺省 hotel id | 不传 `hotel_id`,source message 属于系统酒店 | 后端按系统唯一 ACTIVE 酒店写入成功 | | MCP-T05-006 | 重复提交同一幂等任务 | 使用相同幂等信息 | 不重复创建业务任务 | | MCP-T05-007 | hotel id 不一致 | 显式传非系统酒店 `hotel_id` | 返回 `HOTEL_ID_MISMATCH` | +| MCP-T05-008 | source message 入库渠道为 OUTLOOK | 不传 `source_channel`,只传外部 `source_message_id` | 后端按真实 Inbox 渠道写入成功 | +| MCP-T05-009 | source message 多渠道重复 | 同一酒店存在相同外部 `source_message_id` 的多条 Inbox | 返回 `SOURCE_MESSAGE_AMBIGUOUS` | 写入验证: diff --git a/docs/project/integrations/superagent-mcp/tools.md b/docs/project/integrations/superagent-mcp/tools.md index c4f6117..177c5e6 100644 --- a/docs/project/integrations/superagent-mcp/tools.md +++ b/docs/project/integrations/superagent-mcp/tools.md @@ -18,8 +18,8 @@ | 字段 | 默认值 | 中文说明 | | --- | --- | --- | | `hotel_id` | 后端解析 | SuperAgent 默认不传;单酒店阶段由 TH Hotel 后端从 `platform_hotel` 唯一 `ACTIVE` 酒店解析,兼容旧调用传入时必须与系统酒店一致。 | -| `source_provider` | `AGENTBUS` | 来源提供方 | -| `source_channel` | `EMAIL` | 来源渠道 | +| `source_provider` | 通常不传 | 查询类工具可作为隔离条件;写入工具不要求 SuperAgent 传数据库 provider。 | +| `source_channel` | 通常不传 | 查询类工具可作为隔离条件;写入工具不要求 SuperAgent 传数据库 channel,AgentBus 邮件真实入库渠道可能是 `OUTLOOK`。 | 通用返回建议: @@ -336,6 +336,7 @@ POST /api/ai-query/v1/message-conversation/messages - SuperAgent 已完成当前邮件的最终任务拆分。 - 已确认外部 `source_message_id` 来自 AgentBus payload;`hotel_id` 由 TH Hotel 后端解析。 +- 不需要为写入工具传数据库层 `source_provider` / `source_channel`;后端会按系统酒店和外部消息 ID 匹配真实 Inbox 记录。 - 需要把 AI 任务结果交给 TH Hotel 后端进入人工确认流程。 ### 7.3 不应使用 @@ -359,11 +360,11 @@ POST /api/ai-query/v1/message-conversation/messages }, "source_provider": { "type": ["string", "null"], - "description": "来源提供方,默认 AGENTBUS" + "description": "兼容字段;写入工具通常不需要传,后端写入定位不使用该字段" }, "source_channel": { "type": ["string", "null"], - "description": "来源渠道,默认 EMAIL" + "description": "兼容字段;写入工具通常不需要传,后端写入定位不使用该字段,AgentBus 实际入库渠道可能是 OUTLOOK" }, "source_message_id": { "type": "string", @@ -392,6 +393,8 @@ POST /api/ai-query/v1/message-conversation/messages - `ai_task_results[]` 内部字段较多,完整结构以 `superagent-api-contract.md` 第 8 节为准。 - MCP endpoint 不应重排 `ai_task_results[]`。 +- `source_message_id` 必须是 AgentBus payload 的 `source.external_message_id`,不是内部 `platform_source_message_inbox.id`;写入工具不要求 SuperAgent 知道 Inbox 的真实 channel。 +- 同一系统酒店下如果外部 `source_message_id` 匹配多条 Inbox,业务 Service 返回 `SOURCE_MESSAGE_AMBIGUOUS`,MCP tool result 应原样保留该错误码和 message。 - 如后续需要强 schema 校验,可在 MCP endpoint 内复制 REST 契约中的细粒度字段约束。 ### 7.5 输出 diff --git a/docs/project/requirements/M002-order-task-workflow-v3.md b/docs/project/requirements/M002-order-task-workflow-v3.md index 2f9907a..c83ed3b 100644 --- a/docs/project/requirements/M002-order-task-workflow-v3.md +++ b/docs/project/requirements/M002-order-task-workflow-v3.md @@ -70,13 +70,13 @@ V3 以以下资料和决策为输入: 后端处理时按以下路径反查: ```text -系统酒店 + source_provider + source_channel + source_message.source_message_id +系统酒店 + source_message.source_message_id → platform_source_message_inbox.external_message_id → platform_source_message_inbox.id → workflow / task / transition 表内部 source_message_id 外键 ``` -V3 第一版继续使用单酒店阶段的系统默认酒店;后续多酒店或权限收口时再扩展显式酒店上下文。 +V3 第一版继续使用单酒店阶段的系统默认酒店;后续多酒店或权限收口时再扩展显式酒店上下文。SuperAgent 不需要传数据库层 provider/channel,`source_message.source_channel=Email` 只是上游消息渠道语义;后端以 SourceMessage Inbox 的真实入库 provider/channel 为准。同一系统酒店下如果出现多条相同 `external_message_id`,后端返回 `SOURCE_MESSAGE_AMBIGUOUS`,不随机选择。 ### 4.2 缺少 source_message_id @@ -181,7 +181,9 @@ V3 第一版必须保存并支持以下路由类别: - `Note`、`Allotment Maintenance`、`update_allotment_control_block` 仅历史兼容,不允许新数据生成。 - 当前 producer 不允许再生成 `normal_task/manual_review + Cancel Booking + linked_parent_release_after_child_split`。 - `relationship_type=linked_parent_release_after_child_split` 只用于 Parent / Child 关联和 Preflight,不再决定 `task_subtype`。 -- 旧 V2 兼容 `ai_task_results[]` 若继续提交 `Cancel Booking + linked_parent_release_after_child_split`,也按当前 producer 契约错误处理。 +- 旧 V2 兼容 `ai_task_results[]` 若继续提交 `Cancel Booking + linked_parent_release_after_child_split`,按请求级 `ADAPTER_CONTRACT_ERROR` 拒绝,不创建业务任务,也不创建 AI transition。 +- Parent 只提供 `group_code` 或只提供 `block_code` 时,adapter 可以在派生副本中补齐另一边用于路由和落库;原始 payload 不回写。 +- Parent 的 `group_code` 与 `block_code` 原始候选冲突时不猜测。若 SuperAgent 已输出 `manual_review.reason_code=target_object_unclear` 且 `context_used.parent_identity_candidates[]` 非空,创建同卡 type-known manual review;否则写入 adapter contract error。 P0.1 Parent Group 路由规则: @@ -189,8 +191,11 @@ P0.1 Parent Group 路由规则: | --- | --- | --- | | Child Group 创建 | `normal_task/manual_review + New Booking + new_group_block` | 创建 New Booking 业务任务 | | Parent Group 完整释放 / 取消 | `normal_task/manual_review + Cancel Allotment + cancel_allotment_control_block` | 创建 Cancel Allotment 业务任务卡 | -| 旧 Parent split 新入站 | `Cancel Booking + linked_parent_release_after_child_split` | `adapter_contract_error`,不创建业务任务 | +| 旧 Parent split 新入站 | V3 `Cancel Booking + linked_parent_release_after_child_split` | 按 event 写入 `adapter_contract_error`,不创建业务任务 | +| 旧 Parent split V2 兼容入口 | V2 `ai_task_results[]` 中的 `Cancel Booking + linked_parent_release_after_child_split` | 请求级 `ADAPTER_CONTRACT_ERROR` 拒绝,不创建业务任务 / AI transition | | 重复 Parent 候选 | 同一 Parent Group 再次输出合法 Parent 候选 | 第二个及后续 Parent 写入 `adapter_contract_error` | +| Parent key 单边缺失 | `group_code` 或 `block_code` 仅有一边 | 在派生副本中补齐另一边,继续创建 Cancel Allotment 业务任务卡 | +| Parent key 候选冲突 | 双 key 无法安全归一,且 `target_object_unclear + parent_identity_candidates[]` 完整 | 创建 Cancel Allotment 同卡人工复核任务 | | 历史旧 payload 只读展示 | `Cancel Booking + linked_parent_release_after_child_split` | reader / adapter 展示层可归一为 Cancel Allotment,不改写原 payload | ### 7.2 方案 C:AI 三元组和系统处理分类分离 @@ -422,6 +427,7 @@ V3 P0.1 不做以下事项: - 结构化 `S10/S99` 入站处理,复用 `SOURCE_MESSAGE_ONLY` 只读特殊任务。 - 40 条 P0.1 路由枚举 / 稳定配置。 - V3 业务根 `source_message + message_events[]` 基础解析;能派生到稳定路由的 event 创建业务任务,无法派生的 event、显式 `contract_errors`、根 `missing_fields`、不完整 `manual_review`、当前 producer 旧 Parent Cancel Booking 和不完整 parent split 候选只落 `adapter_contract_error` transition。 +- P0.1 Parent split 已支持单边 Parent key 归一;Parent key 候选冲突且符合 `target_object_unclear + parent_identity_candidates[]` 时进入同卡 type-known manual review,不再误判为 adapter contract error。 - `unhandled_current_intents[]` 只落 `UNHANDLED_CURRENT_INTENT` transition,不创建订单和任务,也不伪装成 adapter 契约错误。 - AI transition 最小保存 `route_code`、`system_process_category`、`adapter_error_code`、`adapter_error_message`。 - 任务列表、订单任务时间线和任务详情顶层透出 `result_type`、`ai_task_type`、`task_subtype`、`route_code`、`system_process_category`。 diff --git a/docs/project/requirements/M002-superagent-task-result-api-contract.md b/docs/project/requirements/M002-superagent-task-result-api-contract.md index 9302e95..5c64715 100644 --- a/docs/project/requirements/M002-superagent-task-result-api-contract.md +++ b/docs/project/requirements/M002-superagent-task-result-api-contract.md @@ -41,7 +41,7 @@ - 本接口只做技术校验和系统接收,不替代用户确认和 OPERA 模拟操作。 - `application/json` 用于 V3 结构化 `S10/S99`、V3 业务根或 V2 `normal_task` / `manual_review` 兼容结构化任务。 - `text/plain` 用于旧 `S000,source_message_id` / `S999,source_message_id` 特殊入口结果兼容。 -- P0.1 后,V3 业务根中的完整 Parent split 父事件必须使用 `event_type=Cancel Allotment` 和 `task_subtype=cancel_allotment_control_block`;当前新入站不接受 `Cancel Booking + linked_parent_release_after_child_split` 作为合法业务任务,旧 V2 `ai_task_results[]` 兼容入口也不能继续提交该三元组。 +- P0.1 后,V3 业务根中的完整 Parent split 父事件必须使用 `event_type=Cancel Allotment` 和 `task_subtype=cancel_allotment_control_block`;当前新入站不接受 `Cancel Booking + linked_parent_release_after_child_split` 作为合法业务任务。V3 `message_events[]` 中的旧三元组按 event 写入 adapter contract error;旧 V2 `ai_task_results[]` 兼容入口提交该三元组时按请求级 `ADAPTER_CONTRACT_ERROR` 拒绝。 ### 2.1 SourceMessage ID 口径 @@ -50,13 +50,13 @@ 后端正式处理路径: ```text -hotel_id + source_provider(默认 AGENTBUS) + source_channel(默认 EMAIL) + source_message_id +系统酒店 + source_message_id → platform_source_message_inbox.external_message_id → platform_source_message_inbox.id → workflow_* 表 source_message_id 内部外键 ``` -数据库 `workflow_*` 表中的 `source_message_id` 仍然保存内部 SourceMessage Inbox ID。只有对外接口的 `source_message_id` 使用外部来源消息 ID。无 `hotel_id` 时仅兼容本地旧夹具使用内部数字 ID,正式 SuperAgent 调用不得依赖该兼容路径。 +数据库 `workflow_*` 表中的 `source_message_id` 仍然保存内部 SourceMessage Inbox ID。只有对外接口的 `source_message_id` 使用外部来源消息 ID。任务结果通知不要求 SuperAgent 传数据库层 `source_provider` / `source_channel`;后端按系统酒店和外部消息 ID 查唯一 Inbox 记录,真实 provider/channel 以 SourceMessage Inbox 入库值为准。无 `hotel_id` 时仅兼容本地旧夹具使用内部数字 ID,正式 SuperAgent 调用不得依赖该兼容路径。 ## 3. 鉴权方案 @@ -171,10 +171,10 @@ JSON 请求体沿用 AI 导入文档定义的聚合结构。 | 字段 | 是否必填 | 中文说明 | | --- | --- | --- | -| `hotel_id` | 是 | 酒店上下文 ID,用于反查 SourceMessage Inbox 幂等键 | +| `hotel_id` | 否 | 酒店上下文 ID;SuperAgent 默认不传,单酒店阶段由后端解析系统酒店 | | `source_message_id` | 是 | 外部来源消息 ID,对应 AgentBus `source.external_message_id`;一次请求只能有一个 | -| `source_provider` | 否 | 来源提供方,第一版缺省为 `AGENTBUS` | -| `source_channel` | 否 | 来源渠道,第一版缺省为 `EMAIL` | +| `source_provider` | 否 | V2 兼容字段;通常不传。写入定位不使用该字段,真实 provider 以 SourceMessage Inbox 入库值为准 | +| `source_channel` | 否 | V2 兼容字段;通常不传。写入定位不使用该字段,AgentBus 邮件真实入库渠道可能是 `OUTLOOK` | | `ai_task_results[]` | 是 | AI 拆分出的任务结果列表,必须保留数组顺序 | | `extraction_warnings[]` | 否 | 抽取警告;不直接等同于业务任务 | @@ -227,7 +227,7 @@ S999,mail-20260708-0001 处理规则: - 第一版使用系统默认酒店反查 SourceMessage Inbox,不要求文本 body 携带 `hotel_id`。 -- 后端按 `默认酒店 + AGENTBUS + EMAIL + external_message_id` 查询 SourceMessage。 +- 后端按 `默认酒店 + external_message_id` 查询唯一 SourceMessage;真实 provider/channel 以 Inbox 入库值为准。 - 命中后创建 `SOURCE_MESSAGE_ONLY` 只读任务。 - 任务列表可见,订单列表不可见。 - 不允许编辑、确认、转换订单、执行 OPERA 或重试 OPERA。 @@ -243,7 +243,7 @@ S999,mail-20260708-0001 - 鉴权签名合法。 - 请求体大小不超过限制。 - JSON body 可解析;S000/S999 文本 body 必须符合 `结果码,source_message_id`。 -- JSON body 中 `hotel_id` 存在。仅本地旧夹具兼容缺少 `hotel_id` 且 `source_message_id` 为内部数字 ID 的调用。 +- JSON body 中 `hotel_id` 可不传;正式 REST / MCP 调用由后端解析系统酒店。仅本地旧夹具在显式开启兼容开关时允许缺少 `hotel_id` 且 `source_message_id` 为内部数字 ID 的调用。 - S000/S999 文本 body 第一版使用系统默认酒店,不读取 `hotel_id`。 - 顶层只有一个 `source_message_id`。 - `source_message_id` 对应的外部来源消息已经写入 SourceMessage Inbox。 @@ -253,6 +253,8 @@ S999,mail-20260708-0001 - 同一个请求内 `source_event_index` 和数组顺序可保存。 - 关键字符串长度不超过数据库限制。 - V3 P0.1 Parent split 当前合法结构必须是 `Cancel Allotment + cancel_allotment_control_block`;`Cancel Booking + linked_parent_release_after_child_split` 属于当前 producer 契约错误,只能作为历史 payload 只读兼容。 +- Parent 只提供 `case_keys.group_code` 或只提供 `case_keys.block_code` 时,后端会在 adapter 派生副本中补齐另一边,不回写原始 payload。 +- Parent `case_keys.group_code` 与 `case_keys.block_code` 原始候选冲突时,SuperAgent 应输出 `manual_review.reason_code=target_object_unclear` 和非空 `context_used.parent_identity_candidates[]`;后端会创建同卡 type-known manual review。缺少该复核结构时按 adapter contract error 处理。 - 同一个 Parent split cluster 只能有一个 Parent 候选;重复 Parent 候选不创建第二张业务任务卡。 ### 5.2 不在本接口判断 @@ -459,6 +461,7 @@ S000 / S999 文本结果创建成功时,同样返回 `201 Created`。这类结 | 409 | `SYSTEM_HOTEL_AMBIGUOUS` | 单酒店阶段平台酒店表存在多家 ACTIVE 酒店 | | 400 | `SOURCE_MESSAGE_REQUIRED` | `source_message_id` 缺失 | | 404 | `SOURCE_MESSAGE_NOT_FOUND` | 外部来源消息尚未写入 SourceMessage Inbox | +| 409 | `SOURCE_MESSAGE_AMBIGUOUS` | 同一系统酒店下存在多条相同外部 `source_message_id` 的 Inbox 记录,后端拒绝随机选择 | | 400 | `TASK_RESULTS_EMPTY` | `ai_task_results[]` 为空 | | 400 | `TASK_RESULT_UNSUPPORTED_TYPE` | `result_type` 或 `task_type` 不可识别 | | 400 | `DUPLICATE_TASK_RESULT_ITEM` | 同一请求内 item 重复 | diff --git a/docs/project/requirements/M002-v3-p0.1-parent-group-routing-update.md b/docs/project/requirements/M002-v3-p0.1-parent-group-routing-update.md index f7937fe..010d048 100644 --- a/docs/project/requirements/M002-v3-p0.1-parent-group-routing-update.md +++ b/docs/project/requirements/M002-v3-p0.1-parent-group-routing-update.md @@ -82,6 +82,7 @@ S10、S99、Fallback、unhandled display = 4 条 - Parent 的 `requires_downstream_hard_validation=true`。 - Parent 的 `case_keys.group_code` 与 `case_keys.block_code` 必须同时存在且完全相等;只取得一边时复制到另一边。 - Parent 的 `extracted_fields.parent_group_code` 必须与归一后的 Parent code 一致。 +- Parent 的 `case_keys.group_code` 与 `case_keys.block_code` 原始候选冲突时,adapter 不猜测目标对象;如 SuperAgent 已输出 `manual_review.reason_code=target_object_unclear` 且 `context_used.parent_identity_candidates[]` 非空,则创建同卡 type-known manual review,否则写入 adapter contract error。 - Parent 的 `related_source_event_indices[]` 必须与 `extracted_fields.child_group_codes[]` 数量一致、顺序一一对应,并指向同根 `message_events[]` 中的 Child New Booking。 如果当前 producer 输出: @@ -126,10 +127,12 @@ Cancel Booking - 路由枚举为 40 条。 - 旧 `linked_parent_release_after_child_split` 不再是当前合法 route definition。 -- 当前入站 `Cancel Booking + linked_parent_release_after_child_split` 记录为 `adapter_contract_error`,不创建业务任务;该规则同时适用于 V3 `message_events[]` 和旧 V2 兼容 `ai_task_results[]`。 +- 当前 V3 `message_events[]` 入站 `Cancel Booking + linked_parent_release_after_child_split` 按 event 记录 `adapter_contract_error` transition,不创建业务任务。 +- 旧 V2 兼容 `ai_task_results[]` 若提交 `Cancel Booking + linked_parent_release_after_child_split`,按请求级 `ADAPTER_CONTRACT_ERROR` 拒绝,不创建业务任务,也不创建 AI transition。 - 当前入站 `Cancel Allotment + cancel_allotment_control_block + linked_parent_release_after_child_split` 可以创建 `CANCEL_ALLOTMENT` 业务卡。 - 同一个 Parent split cluster 重复提交 Parent 候选时,后续重复 Parent 记录为 `adapter_contract_error`,不再创建第二张 Parent 任务卡。 -- Parent 双 key 不一致时不猜测,进入 type-known manual review 或 adapter contract error,不能改成普通 Cancel Booking。 +- Parent 只提供 `group_code` 或只提供 `block_code` 时,adapter 可在派生副本中补齐另一边;原始 payload 不回写。 +- Parent 双 key 不一致时不猜测,满足 `target_object_unclear + parent_identity_candidates[]` 时进入 type-known manual review,否则进入 adapter contract error,不能改成普通 Cancel Booking。 - Parent `child_group_codes[]` 与 `related_source_event_indices[]` 数量、顺序和 Child New Booking 目标一致。 - 普通 FIT / Group Block 的 `Cancel Booking` 不受影响。 - 旧历史数据只读兼容,不迁移、不改写原 payload。 diff --git a/server/src/main/java/cn/nianxx/thhotel/integrations/mcp/superagent/service/impl/SuperAgentMcpServiceImpl.java b/server/src/main/java/cn/nianxx/thhotel/integrations/mcp/superagent/service/impl/SuperAgentMcpServiceImpl.java index 844d767..08791bf 100644 --- a/server/src/main/java/cn/nianxx/thhotel/integrations/mcp/superagent/service/impl/SuperAgentMcpServiceImpl.java +++ b/server/src/main/java/cn/nianxx/thhotel/integrations/mcp/superagent/service/impl/SuperAgentMcpServiceImpl.java @@ -358,8 +358,8 @@ public class SuperAgentMcpServiceImpl implements SuperAgentMcpService { private Map submitTaskResultsSchema() { Map propertiesMap = new LinkedHashMap<>(); propertiesMap.put("hotel_id", stringField("可选酒店上下文 ID;缺省由 TH Hotel 后端解析系统酒店")); - propertiesMap.put("source_provider", nullableStringField("来源提供方,默认 AGENTBUS")); - propertiesMap.put("source_channel", nullableStringField("来源渠道,默认 EMAIL")); + propertiesMap.put("source_provider", nullableStringField("兼容字段;写入工具通常不需要传,后端写入定位不使用该字段")); + propertiesMap.put("source_channel", nullableStringField("兼容字段;写入工具通常不需要传,后端写入定位不使用该字段,AgentBus 实际入库渠道可能是 OUTLOOK")); propertiesMap.put("source_message_id", stringField("外部来源消息 ID,对应 AgentBus source.external_message_id")); propertiesMap.put("ai_task_results", Map.of( "type", "array", 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 26fe246..e7df978 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 @@ -157,6 +157,24 @@ public class MybatisSourceMessageInboxRepository implements SourceMessageInboxRe return Optional.ofNullable(entity).map(this::toSnapshot); } + /** + * 根据酒店和外部邮件 ID 查询所有候选 Inbox 记录,调用方负责处理零条或多条的业务语义。 + */ + @Override + public List findByExternalMessageId(String hotelId, String externalMessageId) { + if (!hasText(hotelId) || !hasText(externalMessageId)) { + return List.of(); + } + return inboxMapper.selectList(Wrappers.lambdaQuery() + .eq(SourceMessageInboxEntity::getHotelId, trim(hotelId)) + .eq(SourceMessageInboxEntity::getExternalMessageId, trim(externalMessageId)) + .orderByDesc(SourceMessageInboxEntity::getReceivedAt) + .orderByDesc(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 13a5704..168b79b 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 @@ -50,6 +50,11 @@ public interface SourceMessageInboxRepository { String channel, String externalMessageId); + /** + * 按酒店和外部邮件 ID 查询 Inbox 快照,用于外部回调只携带 AgentBus external_message_id 的场景。 + */ + List findByExternalMessageId(String hotelId, String externalMessageId); + /** * 按查询条件分页读取 Inbox 快照,分页参数已经由 Service 层归一化。 */ diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationAiTaskIntakeServiceImpl.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationAiTaskIntakeServiceImpl.java index 502c670..fd979c1 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationAiTaskIntakeServiceImpl.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationAiTaskIntakeServiceImpl.java @@ -39,6 +39,7 @@ import java.util.HexFormat; import java.util.LinkedHashSet; import java.util.List; import java.util.Set; +import org.springframework.beans.factory.annotation.Value; import org.springframework.dao.DuplicateKeyException; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; @@ -151,17 +152,21 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta private final ObjectMapper objectMapper; private final SourceMessageInboxRepository sourceMessageInboxRepository; private final ReservationAiWorkflowRepository workflowRepository; + private final boolean allowLegacyInternalSourceMessageId; /** - * 注入 JSON 解析器、SourceMessage 查询边界和 Reservation 持久化边界。 + * 注入 JSON 解析器、SourceMessage 查询边界、Reservation 持久化边界和本地旧夹具兼容开关。 */ public ReservationAiTaskIntakeServiceImpl( ObjectMapper objectMapper, SourceMessageInboxRepository sourceMessageInboxRepository, - ReservationAiWorkflowRepository workflowRepository) { + ReservationAiWorkflowRepository workflowRepository, + @Value("${superagent.task-result.allow-legacy-internal-source-message-id:false}") + boolean allowLegacyInternalSourceMessageId) { this.objectMapper = objectMapper; this.sourceMessageInboxRepository = sourceMessageInboxRepository; this.workflowRepository = workflowRepository; + this.allowLegacyInternalSourceMessageId = allowLegacyInternalSourceMessageId; } /** @@ -370,7 +375,8 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta List responseItems = new ArrayList<>(itemCount); Set acceptedParentSplitCodes = new LinkedHashSet<>(); for (int index = 0; index < messageEvents.size(); index++) { - JsonNode event = messageEvents.get(index); + JsonNode rawEvent = messageEvents.get(index); + JsonNode event = normalizeV3ParentSplitEvent(rawEvent); int arrayIndex = index + 1; V3EventContractIssue contractIssue = inspectV3EventContractIssue(event, messageEvents); if (contractIssue == null) { @@ -381,9 +387,9 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta hotelId, sourceMessageId, batchId, - event, + rawEvent, arrayIndex, - sourceEventIndex(event, arrayIndex), + sourceEventIndex(rawEvent, arrayIndex), null, contractIssue.code(), contractIssue.message(), @@ -396,16 +402,16 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta hotelId, sourceMessageId, batchId, - event, + rawEvent, arrayIndex, - sourceEventIndex(event, arrayIndex), + sourceEventIndex(rawEvent, arrayIndex), null, "EVENT_ROUTE_UNSUPPORTED", "message_events[" + index + "] 无法派生稳定路由。", now)); continue; } - ObjectNode item = normalizeV3EventToTaskResultItem(event, route, arrayIndex); + ObjectNode item = normalizeV3EventToTaskResultItem(rawEvent, event, route, arrayIndex); responseItems.add(createItem(hotelId, sourceMessageId, batchId, item, arrayIndex, now)); } for (int index = 0; index < unhandledIntents.size(); index++) { @@ -636,31 +642,32 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta /** - * 将 V3 业务 event 转为旧任务结果 item 形态,复用现有订单、任务和任务卡创建逻辑。 + * 将 V3 业务 event 转为旧任务结果 item 形态,业务列使用 adapter 派生 event,原始 event 保留在 v3_message_event。 */ private ObjectNode normalizeV3EventToTaskResultItem( - JsonNode event, + JsonNode rawEvent, + JsonNode derivedEvent, ReservationAiRouteDefinition route, int arrayIndex) { ObjectNode item = objectMapper.createObjectNode(); - item.put("source_event_index", sourceEventIndex(event, arrayIndex)); + item.put("source_event_index", sourceEventIndex(rawEvent, arrayIndex)); item.put("catalog_code", V3_CATALOG_CODE); item.put("skill_id", V3_SKILL_ID); item.put("result_type", route.resultType()); item.put("task_type", route.taskType()); item.put("task_subtype", route.taskSubtype()); - copyIfPresent(event, item, "current_or_history"); - copyIfPresent(event, item, "relevant_message_excerpt"); - copyIfPresent(event, item, "case_keys"); - copyIfPresent(event, item, "extracted_fields"); - copyIfPresent(event, item, "manual_review"); - copyIfPresent(event, item, "attachments"); - copyIfPresent(event, item, "file_references"); - copyIfPresent(event, item, "context_used"); - copyIfPresent(event, item, "parent_source_event_index"); - copyIfPresent(event, item, "linked_task_group_id"); - copyIfPresent(event, item, "blocked_until_parent_completed"); - item.set("v3_message_event", event); + copyIfPresent(rawEvent, item, "current_or_history"); + copyIfPresent(rawEvent, item, "relevant_message_excerpt"); + copyIfPresent(derivedEvent, item, "case_keys"); + copyIfPresent(rawEvent, item, "extracted_fields"); + copyIfPresent(rawEvent, item, "manual_review"); + copyIfPresent(rawEvent, item, "attachments"); + copyIfPresent(rawEvent, item, "file_references"); + copyIfPresent(rawEvent, item, "context_used"); + copyIfPresent(rawEvent, item, "parent_source_event_index"); + copyIfPresent(rawEvent, item, "linked_task_group_id"); + copyIfPresent(rawEvent, item, "blocked_until_parent_completed"); + item.set("v3_message_event", rawEvent); item.put("route_code", route.routeCode()); item.put("system_process_category", route.systemProcessCategory().name()); return item; @@ -804,6 +811,36 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta return "linked_parent_release_after_child_split".equals(firstText(extractedFields, event, "relationship_type")); } + /** + * 归一 Parent split 的 Parent Group key;只补齐 adapter 派生用副本,不修改请求原文。 + */ + private JsonNode normalizeV3ParentSplitEvent(JsonNode event) { + if (event == null || !event.isObject() || !isLinkedParentReleaseCandidate(event)) { + return event; + } + ObjectNode normalizedEvent = event.deepCopy(); + JsonNode caseKeysNode = normalizedEvent.path("case_keys"); + if (!caseKeysNode.isObject()) { + return normalizedEvent; + } + ObjectNode caseKeys = (ObjectNode) caseKeysNode; + String groupCode = trimToNull(textAt(caseKeys, "group_code")); + String blockCode = trimToNull(textAt(caseKeys, "block_code")); + if (groupCode == null && blockCode != null) { + caseKeys.put("group_code", blockCode); + return normalizedEvent; + } + if (groupCode != null && blockCode == null) { + caseKeys.put("block_code", groupCode); + return normalizedEvent; + } + if (groupCode != null && blockCode != null && !groupCode.equals(blockCode)) { + caseKeys.putNull("group_code"); + caseKeys.putNull("block_code"); + } + return normalizedEvent; + } + /** * 判断当前 producer 是否仍在输出旧 Parent Cancel Booking 组合。 */ @@ -824,8 +861,8 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta if (!isLinkedParentReleaseCandidate(event)) { return null; } - String parentGroupCode = trimToNull(textAt(event.path("case_keys"), "group_code")); - if (parentGroupCode != null && !acceptedParentSplitCodes.add(parentGroupCode)) { + ParentSplitIdentity identity = parentSplitIdentity(event); + if (identity.normalizedParentCode() != null && !acceptedParentSplitCodes.add(identity.normalizedParentCode())) { return new V3EventContractIssue( "LINKED_PARENT_RELEASE_DUPLICATE_PARENT", "同一个 Parent split cluster 只能有一个 Parent Cancel Allotment 候选。"); @@ -838,25 +875,25 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta */ private boolean validLinkedParentReleaseCandidate(JsonNode event, JsonNode messageEvents) { JsonNode extractedFields = event == null ? null : event.path("extracted_fields"); - JsonNode caseKeys = event == null ? null : event.path("case_keys"); - String parentGroupCode = trimToNull(textAt(caseKeys, "group_code")); - String parentBlockCode = trimToNull(textAt(caseKeys, "block_code")); + ParentSplitIdentity identity = parentSplitIdentity(event); if (!("Cancel Allotment".equals(trimToNull(textAt(event, "event_type"))) && "entire_allotment_control_block".equals(firstText(extractedFields, event, "cancel_scope")) && isBooleanTrue(extractedFields.path("parent_release_or_cancel_candidate")) && "parent_to_child_allocation_split".equals(firstText(extractedFields, event, "release_reason")) - && parentGroupCode != null - && parentGroupCode.equals(firstText(extractedFields, event, "parent_group_code")) && isBooleanTrue(extractedFields.path("allocation_split_from_parent")) && "New Booking".equals(trimToNull(textAt(event, "related_event_type"))) && event != null && isBooleanTrue(event.path("requires_downstream_hard_validation")) - && parentGroupCode != null - && parentGroupCode.equals(parentBlockCode) && nonEmptyArray(extractedFields, "child_group_codes") && nonEmptyArray(event, "related_source_event_indices"))) { return false; } + if (!identity.targetObjectUnclearReview() && !validConcreteParentIdentity(identity, extractedFields)) { + return false; + } + if (identity.targetObjectUnclearReview() && !nonEmptyArray(event.path("context_used"), "parent_identity_candidates")) { + return false; + } List childGroupCodes = stringArrayValues(extractedFields.path("child_group_codes")); List relatedSourceEventIndices = stringArrayValues(event.path("related_source_event_indices")); if (childGroupCodes.isEmpty() @@ -888,6 +925,30 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta return true; } + /** + * 校验普通 Parent split 事件是否已经具备明确 Parent Group identity。 + */ + private boolean validConcreteParentIdentity(ParentSplitIdentity identity, JsonNode extractedFields) { + return identity.normalizedParentCode() != null + && identity.normalizedParentCode().equals(identity.groupCode()) + && identity.normalizedParentCode().equals(identity.blockCode()) + && identity.normalizedParentCode().equals(trimToNull(textAt(extractedFields, "parent_group_code"))); + } + + /** + * 提取 Parent split identity,封装正常归一与 target_object_unclear 复核两种形态。 + */ + private ParentSplitIdentity parentSplitIdentity(JsonNode event) { + JsonNode caseKeys = event == null ? null : event.path("case_keys"); + String groupCode = trimToNull(textAt(caseKeys, "group_code")); + String blockCode = trimToNull(textAt(caseKeys, "block_code")); + String normalizedParentCode = groupCode != null && groupCode.equals(blockCode) ? groupCode : null; + boolean targetObjectUnclearReview = groupCode == null + && blockCode == null + && "target_object_unclear".equals(textAt(event == null ? null : event.path("manual_review"), "reason_code")); + return new ParentSplitIdentity(groupCode, blockCode, normalizedParentCode, targetObjectUnclearReview); + } + /** * 接收 S000/S999 文本入口结果,创建只读来源消息任务,并隐藏其技术订单。 */ @@ -914,13 +975,7 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta ? requireText(defaultHotelId, "default_hotel_id", LENGTH_64) : requireText(resolvedSourceMessage.hotelId(), "source_message.hotel_id", LENGTH_64); SourceMessageInboxSnapshot sourceMessage = resolvedSourceMessage == null - ? sourceMessageInboxRepository - .findByIdempotencyKey( - hotelId, - DEFAULT_SOURCE_PROVIDER, - DEFAULT_SOURCE_CHANNEL, - entryResult.externalSourceMessageId()) - .orElseThrow(() -> error(HttpStatus.NOT_FOUND, "SOURCE_MESSAGE_NOT_FOUND", "SourceMessage 不存在。")) + ? resolveExternalSourceMessageByHotel(hotelId, entryResult.externalSourceMessageId()) : resolvedSourceMessage; String requestPayloadSha256 = sha256(rawBody == null ? "" : rawBody); String batchIdempotencyKey = sha256(BATCH_KEY_PREFIX + "|" + sourceMessage.id() + "|" + requestPayloadSha256); @@ -1575,22 +1630,21 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta if (requestHotelId != null && systemHotelId != null && !requestHotelId.equals(systemHotelId)) { throw error(HttpStatus.BAD_REQUEST, "HOTEL_ID_MISMATCH", "请求 hotel_id 与系统酒店不一致。"); } - if (requestHotelId == null && isLongText(sourceMessageReference)) { - return resolveLegacyInternalSourceMessage(sourceMessageReference, systemHotelId); - } String hotelId = requestHotelId == null ? systemHotelId : requestHotelId; if (hotelId == null) { - return resolveLegacyInternalSourceMessage(sourceMessageReference, null); + if (allowLegacyInternalSourceMessageId) { + return resolveLegacyInternalSourceMessage(sourceMessageReference, null); + } + throw error(HttpStatus.BAD_REQUEST, "HOTEL_ID_REQUIRED", "使用外部 source_message_id 时 hotel_id 不能为空。"); } validateLength(hotelId, "hotel_id", LENGTH_64); - String sourceProvider = optionalText(textAt(root, "source_provider"), "source_provider", LENGTH_32); - String sourceChannel = optionalText(textAt(root, "source_channel"), "source_channel", LENGTH_32); - String provider = sourceProvider == null ? DEFAULT_SOURCE_PROVIDER : sourceProvider; - String channel = sourceChannel == null ? DEFAULT_SOURCE_CHANNEL : sourceChannel; - SourceMessageInboxSnapshot sourceMessage = sourceMessageInboxRepository - .findByIdempotencyKey(hotelId, provider, channel, sourceMessageReference) - .orElseThrow(() -> error(HttpStatus.NOT_FOUND, "SOURCE_MESSAGE_NOT_FOUND", "SourceMessage 不存在。")); - return new ResolvedSourceMessage(sourceMessage, sourceMessageReference); + // 旧 V2 字段只做格式兼容校验;写入定位统一按系统酒店 + external_message_id 匹配真实 Inbox。 + optionalText(textAt(root, "source_provider"), "source_provider", LENGTH_32); + optionalText(textAt(root, "source_channel"), "source_channel", LENGTH_32); + return resolveExternalSourceMessageReferenceByHotel( + hotelId, + sourceMessageReference, + requestHotelId == null && allowLegacyInternalSourceMessageId); } /** @@ -1614,6 +1668,42 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta return new ResolvedSourceMessage(sourceMessage, responseSourceMessageId); } + /** + * 按系统酒店和 AgentBus 外部邮件 ID 解析真实入库 SourceMessage,不要求 SuperAgent 了解数据库 provider/channel。 + */ + private SourceMessageInboxSnapshot resolveExternalSourceMessageByHotel(String hotelId, String externalSourceMessageId) { + List candidates = + sourceMessageInboxRepository.findByExternalMessageId(hotelId, externalSourceMessageId); + if (candidates.isEmpty()) { + throw error(HttpStatus.NOT_FOUND, "SOURCE_MESSAGE_NOT_FOUND", "SourceMessage 不存在。"); + } + if (candidates.size() > 1) { + throw error(HttpStatus.CONFLICT, "SOURCE_MESSAGE_AMBIGUOUS", "同一酒店下存在多个匹配的 SourceMessage。"); + } + return candidates.get(0); + } + + /** + * V2 写入优先按外部消息 ID 解析;仅在无显式 hotel_id 的旧本地夹具场景下兜底内部 SourceMessage ID。 + */ + private ResolvedSourceMessage resolveExternalSourceMessageReferenceByHotel( + String hotelId, + String sourceMessageReference, + boolean allowLegacyInternalFallback) { + List candidates = + sourceMessageInboxRepository.findByExternalMessageId(hotelId, sourceMessageReference); + if (candidates.isEmpty()) { + if (allowLegacyInternalFallback && isLongText(sourceMessageReference)) { + return resolveLegacyInternalSourceMessage(sourceMessageReference, hotelId); + } + throw error(HttpStatus.NOT_FOUND, "SOURCE_MESSAGE_NOT_FOUND", "SourceMessage 不存在。"); + } + if (candidates.size() > 1) { + throw error(HttpStatus.CONFLICT, "SOURCE_MESSAGE_AMBIGUOUS", "同一酒店下存在多个匹配的 SourceMessage。"); + } + return new ResolvedSourceMessage(candidates.get(0), sourceMessageReference); + } + /** * 判断来源消息引用是否为历史本地兼容的内部 SourceMessage ID。 */ @@ -1823,13 +1913,8 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta } validateLength(externalSourceMessageId, "source_message.source_message_id", LENGTH_256); String hotelId = requireText(defaultHotelId, "default_hotel_id", LENGTH_64); - SourceMessageInboxSnapshot sourceMessage = sourceMessageInboxRepository - .findByIdempotencyKey( - hotelId, - DEFAULT_SOURCE_PROVIDER, - DEFAULT_SOURCE_CHANNEL, - externalSourceMessageId) - .orElseThrow(() -> error(HttpStatus.NOT_FOUND, "SOURCE_MESSAGE_NOT_FOUND", "SourceMessage 不存在。")); + SourceMessageInboxSnapshot sourceMessage = + resolveExternalSourceMessageByHotel(hotelId, externalSourceMessageId); return new ResolvedSourceMessage(sourceMessage, externalSourceMessageId); } @@ -2221,6 +2306,17 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta ) { } + /** + * Parent split 父事件身份归一结果。target_object_unclear 代表已知卡型但目标对象需人工确认。 + */ + private record ParentSplitIdentity( + String groupCode, + String blockCode, + String normalizedParentCode, + boolean targetObjectUnclearReview + ) { + } + /** * 将空白字符串转换为 null。 */ diff --git a/server/src/main/resources/application-test.yml b/server/src/main/resources/application-test.yml index 51554b2..205df9f 100644 --- a/server/src/main/resources/application-test.yml +++ b/server/src/main/resources/application-test.yml @@ -35,6 +35,8 @@ superagent: task-result: # test SuperAgent HMAC 密钥;优先使用 test 专属变量,兼容旧通用变量。 hmac-secret: ${SUPERAGENT_TEST_TASK_RESULT_HMAC_SECRET:${SUPERAGENT_TASK_RESULT_HMAC_SECRET:}} + # 仅用于旧本地测试夹具;正式 REST / MCP 联调不得依赖内部 SourceMessage ID。 + allow-legacy-internal-source-message-id: ${SUPERAGENT_TEST_ALLOW_LEGACY_INTERNAL_SOURCE_MESSAGE_ID:true} open-api: # test Debug EML 调用 SuperAgent Open API;默认关闭,联调时显式开启。 enabled: ${SUPERAGENT_TEST_OPEN_API_ENABLED:${SUPERAGENT_OPEN_API_ENABLED:false}} diff --git a/server/src/main/resources/db/migration/V19__add_source_message_external_message_lookup_index.sql b/server/src/main/resources/db/migration/V19__add_source_message_external_message_lookup_index.sql new file mode 100644 index 0000000..d38ee37 --- /dev/null +++ b/server/src/main/resources/db/migration/V19__add_source_message_external_message_lookup_index.sql @@ -0,0 +1,3 @@ +-- M002 SuperAgent 任务结果回调:按系统酒店和外部邮件 ID 反查 SourceMessage Inbox。 +CREATE INDEX idx_source_message_inbox_hotel_external_message + ON platform_source_message_inbox (hotel_id, external_message_id); diff --git a/server/src/test/java/cn/nianxx/thhotel/integrations/mcp/superagent/control/SuperAgentMcpControllerTest.java b/server/src/test/java/cn/nianxx/thhotel/integrations/mcp/superagent/control/SuperAgentMcpControllerTest.java index f80a7f2..ccef289 100644 --- a/server/src/test/java/cn/nianxx/thhotel/integrations/mcp/superagent/control/SuperAgentMcpControllerTest.java +++ b/server/src/test/java/cn/nianxx/thhotel/integrations/mcp/superagent/control/SuperAgentMcpControllerTest.java @@ -132,6 +132,10 @@ class SuperAgentMcpControllerTest { .andExpect(jsonPath("$.result.tools[4].name").value("th_hotel_submit_task_results")) .andExpect(jsonPath("$.result.tools[4].inputSchema.required[0]").value("source_message_id")) .andExpect(jsonPath("$.result.tools[4].inputSchema.required[1]").value("ai_task_results")) + .andExpect(jsonPath("$.result.tools[4].inputSchema.properties.source_provider.description") + .value(containsString("兼容字段"))) + .andExpect(jsonPath("$.result.tools[4].inputSchema.properties.source_channel.description") + .value(containsString("OUTLOOK"))) .andExpect(jsonPath("$.result.tools[4].annotations.readOnlyHint").value(false)) .andExpect(jsonPath("$.result.tools[4].annotations.destructiveHint").value(true)); } diff --git a/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/SuperAgentTaskResultControllerTest.java b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/SuperAgentTaskResultControllerTest.java index 36b9892..79a3b05 100644 --- a/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/SuperAgentTaskResultControllerTest.java +++ b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/SuperAgentTaskResultControllerTest.java @@ -42,7 +42,8 @@ import org.springframework.test.web.servlet.MvcResult; "superagent.task-result.hmac-secret=test-superagent-secret", "superagent.task-result.clock-skew-seconds=300", "superagent.task-result.nonce-ttl-seconds=600", - "superagent.task-result.max-body-bytes=12000" + "superagent.task-result.max-body-bytes=12000", + "superagent.task-result.allow-legacy-internal-source-message-id=true" }) @AutoConfigureMockMvc @ActiveProfiles("test") @@ -646,6 +647,17 @@ class SuperAgentTaskResultControllerTest { .andExpect(jsonPath("$.error_code").value("SOURCE_MESSAGE_NOT_FOUND")); } + @Test + void shouldRejectTextResultAsAmbiguousWhenExternalSourceMessageMatchesMultipleChannels() throws Exception { + String externalMessageId = "mail-s999-ambiguous-channel-001"; + captureSourceMessage(externalMessageId, "HOTEL-TEST", "EMAIL"); + captureSourceMessage(externalMessageId, "HOTEL-TEST", "OUTLOOK"); + + mockMvc.perform(signedPlainPost("S999," + externalMessageId, "nonce-s999-ambiguous-channel-001")) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.error_code").value("SOURCE_MESSAGE_AMBIGUOUS")); + } + @Test void shouldCreateReadOnlySourceMessageOnlyTaskForStructuredS10Result() throws Exception { SourceMessageCaptureResult source = captureSourceMessage("mail-v3-s10-entry-result-001"); @@ -710,6 +722,29 @@ class SuperAgentTaskResultControllerTest { assertThat(taskCount).isEqualTo(1L); } + @Test + void shouldCreateStructuredS10ResultWhenSourceMessageStoredAsOutlookChannel() throws Exception { + String externalMessageId = "mail-v3-s10-outlook-channel-001"; + SourceMessageCaptureResult source = captureSourceMessage(externalMessageId, "HOTEL-TEST", "OUTLOOK"); + String body = structuredS10Body(externalMessageId); + + mockMvc.perform(signedPost(body, "nonce-v3-s10-outlook-channel-001")) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.source_message_id").value(externalMessageId)) + .andExpect(jsonPath("$.items[0].route_code").value("S10")) + .andExpect(jsonPath("$.items[0].system_process_category").value("SOURCE_MESSAGE_NOTIFICATION")); + + Long transitionCount = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) + FROM workflow_reservation_ai_transition + WHERE source_message_id = ? + AND result_type = 'source_message_review_notification' + AND route_code = 'S10' + AND system_process_category = 'SOURCE_MESSAGE_NOTIFICATION' + """, Long.class, source.inboxId()); + assertThat(transitionCount).isEqualTo(1L); + } + @Test void shouldCreateReadOnlySourceMessageOnlyTaskForStructuredS99Result() throws Exception { SourceMessageCaptureResult source = captureSourceMessage("mail-v3-s99-entry-result-001"); @@ -931,6 +966,170 @@ class SuperAgentTaskResultControllerTest { assertThat(transitionCount).isEqualTo(1L); } + @Test + void shouldAcceptV2TaskResultWhenSourceMessageStoredAsOutlookChannelWithoutSourceChannel() throws Exception { + String externalMessageId = "mail-v2-task-result-outlook-channel-001"; + SourceMessageCaptureResult source = captureSourceMessage(externalMessageId, "HOTEL-TEST", "OUTLOOK"); + String body = minimalBodyWithHotel( + externalMessageId, + "New Booking", + "normal_task", + "new_fit_reservation", + """ + "case_keys": {"confirmation_number": "CNF-V2-OUTLOOK-001"}, + "extracted_fields": {"booking_object_type": "FIT Reservation"} + """); + + mockMvc.perform(signedPost(body, "nonce-v2-task-result-outlook-channel-001")) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.accepted_count").value(1)) + .andExpect(jsonPath("$.items[0].route_code").value("R01_NEW_FIT_RESERVATION_NORMAL")); + + Long transitionCount = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) + FROM workflow_reservation_ai_transition + WHERE source_message_id = ? + AND route_code = 'R01_NEW_FIT_RESERVATION_NORMAL' + """, Long.class, source.inboxId()); + assertThat(transitionCount).isEqualTo(1L); + } + + @Test + void shouldAcceptV2TaskResultWhenLegacySourceChannelDoesNotMatchInboxChannel() throws Exception { + String externalMessageId = "mail-v2-task-result-legacy-email-channel-001"; + SourceMessageCaptureResult source = captureSourceMessage(externalMessageId, "HOTEL-TEST", "OUTLOOK"); + String body = minimalBodyWithHotelAndSourceMetadata( + externalMessageId, + "AGENTBUS", + "EMAIL", + "New Booking", + "normal_task", + "new_fit_reservation", + """ + "case_keys": {"confirmation_number": "CNF-V2-LEGACY-EMAIL-001"}, + "extracted_fields": {"booking_object_type": "FIT Reservation"} + """); + + mockMvc.perform(signedPost(body, "nonce-v2-task-result-legacy-email-channel-001")) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.accepted_count").value(1)) + .andExpect(jsonPath("$.items[0].route_code").value("R01_NEW_FIT_RESERVATION_NORMAL")); + + Long transitionCount = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) + FROM workflow_reservation_ai_transition + WHERE source_message_id = ? + AND route_code = 'R01_NEW_FIT_RESERVATION_NORMAL' + """, Long.class, source.inboxId()); + assertThat(transitionCount).isEqualTo(1L); + } + + @Test + void shouldRejectV2TaskResultAsAmbiguousEvenWhenLegacySourceChannelProvided() throws Exception { + String externalMessageId = "mail-v2-task-result-ambiguous-legacy-channel-001"; + captureSourceMessage(externalMessageId, "HOTEL-TEST", "EMAIL"); + captureSourceMessage(externalMessageId, "HOTEL-TEST", "OUTLOOK"); + String body = minimalBodyWithHotelAndSourceMetadata( + externalMessageId, + "AGENTBUS", + "EMAIL", + "New Booking", + "normal_task", + "new_fit_reservation", + """ + "case_keys": {"confirmation_number": "CNF-V2-AMBIGUOUS-001"}, + "extracted_fields": {"booking_object_type": "FIT Reservation"} + """); + + mockMvc.perform(signedPost(body, "nonce-v2-task-result-ambiguous-legacy-channel-001")) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.error_code").value("SOURCE_MESSAGE_AMBIGUOUS")); + } + + @Test + void shouldPreferNumericExternalSourceMessageIdBeforeLegacyInternalId() throws Exception { + String externalMessageId = "1234567890123456789"; + SourceMessageCaptureResult source = captureSourceMessage(externalMessageId, "HOTEL-TEST", "OUTLOOK"); + String body = minimalBody( + externalMessageId, + "New Booking", + "normal_task", + "new_fit_reservation", + """ + "case_keys": {"confirmation_number": "CNF-V2-NUMERIC-001"}, + "extracted_fields": {"booking_object_type": "FIT Reservation"} + """); + + mockMvc.perform(signedPost(body, "nonce-v2-task-result-numeric-external-001")) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.accepted_count").value(1)) + .andExpect(jsonPath("$.items[0].route_code").value("R01_NEW_FIT_RESERVATION_NORMAL")); + + Long transitionCount = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) + FROM workflow_reservation_ai_transition + WHERE source_message_id = ? + AND route_code = 'R01_NEW_FIT_RESERVATION_NORMAL' + """, Long.class, source.inboxId()); + assertThat(transitionCount).isEqualTo(1L); + } + + @Test + void shouldAcceptSourceMessageOnlyTextResultWhenSourceMessageStoredAsOutlookChannel() throws Exception { + String externalMessageId = "mail-s000-outlook-channel-001"; + SourceMessageCaptureResult source = captureSourceMessage(externalMessageId, "HOTEL-TEST", "OUTLOOK"); + + mockMvc.perform(signedPlainPost("S000," + externalMessageId, "nonce-s000-outlook-channel-001")) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.accepted_count").value(1)) + .andExpect(jsonPath("$.items[0].system_process_category").value("SOURCE_MESSAGE_NOTIFICATION")); + + Long transitionCount = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) + FROM workflow_reservation_ai_transition + WHERE source_message_id = ? + AND route_code = 'S000' + AND system_process_category = 'SOURCE_MESSAGE_NOTIFICATION' + """, Long.class, source.inboxId()); + assertThat(transitionCount).isEqualTo(1L); + } + + @Test + void shouldCreateBusinessTaskFromV3BusinessRootWhenSourceMessageStoredAsOutlookChannel() throws Exception { + String externalMessageId = """ + AAMkAGY5YWM5OTNlLTQ1OTktNDQ1Yi05NTk5LTgwNDIzNTAzY2Q4YgBGAAAAAAApDY1IkXlgT51ii6WOFxDvBwAuw1c78vBZRqpnmlmQMZnLAAAAAAEMAAAuw1c78vBZRqpnmlmQMZnLAAKAkhoCAAA= + """.trim(); + SourceMessageCaptureResult source = captureSourceMessage(externalMessageId, "HOTEL-TEST", "OUTLOOK"); + String body = v3BusinessRootBody(externalMessageId, "CNF-V3-OUTLOOK-001"); + + mockMvc.perform(signedPost(body, "nonce-v3-business-root-outlook-001")) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.accepted_count").value(1)) + .andExpect(jsonPath("$.items[0].route_code").value("R01_NEW_FIT_RESERVATION_NORMAL")) + .andExpect(jsonPath("$.items[0].system_process_category").value("BUSINESS_TASK")); + + Long transitionCount = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) + FROM workflow_reservation_ai_transition + WHERE source_message_id = ? + AND route_code = 'R01_NEW_FIT_RESERVATION_NORMAL' + AND system_process_category = 'BUSINESS_TASK' + """, Long.class, source.inboxId()); + assertThat(transitionCount).isEqualTo(1L); + } + + @Test + void shouldRejectV3BusinessRootWhenExternalSourceMessageMatchesMultipleChannels() throws Exception { + String externalMessageId = "mail-v3-business-root-ambiguous-channel-001"; + captureSourceMessage(externalMessageId, "HOTEL-TEST", "EMAIL"); + captureSourceMessage(externalMessageId, "HOTEL-TEST", "OUTLOOK"); + String body = v3BusinessRootBody(externalMessageId, "CNF-V3-AMBIGUOUS-001"); + + mockMvc.perform(signedPost(body, "nonce-v3-business-root-ambiguous-channel-001")) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.error_code").value("SOURCE_MESSAGE_AMBIGUOUS")); + } + @Test void shouldConfirmV3BusinessTaskWithoutLegacyVisibleReason() throws Exception { captureSourceMessage("mail-v3-confirm-without-legacy-visible-reason-001"); @@ -2560,13 +2759,21 @@ class SuperAgentTaskResultControllerTest { } private SourceMessageCaptureResult captureSourceMessage(String externalMessageId, String hotelId) { + return captureSourceMessage(externalMessageId, hotelId, "EMAIL"); + } + + private SourceMessageCaptureResult captureSourceMessage(String externalMessageId, String hotelId, String channel) { + String providerFrameId = "frame-" + externalMessageId; + if (providerFrameId.length() > 128) { + providerFrameId = "frame-" + Integer.toHexString(externalMessageId.hashCode()); + } return captureService.capture(new CaptureSourceMessageCommand( hotelId, "AGENTBUS", - "EMAIL", + channel, externalMessageId, "thread-" + externalMessageId, - "frame-" + externalMessageId, + providerFrameId, "session-m002", Instant.parse("2026-07-07T08:00:00Z"), "guest@example.test", @@ -2686,6 +2893,44 @@ class SuperAgentTaskResultControllerTest { """.formatted(sourceMessageId, resultType, taskType, taskSubtype, itemFields); } + private String minimalBodyWithHotelAndSourceMetadata( + String sourceMessageId, + String sourceProvider, + String sourceChannel, + String taskType, + String resultType, + String taskSubtype, + String itemFields) { + return """ + { + "hotel_id": "HOTEL-TEST", + "source_provider": "%s", + "source_channel": "%s", + "source_message_id": "%s", + "ai_task_results": [ + { + "source_event_index": 1, + "catalog_code": "S01", + "skill_id": "S01_new_booking_skill", + "result_type": "%s", + "task_type": "%s", + "task_subtype": "%s", + "current_or_history": "current", + "visible_reason": "AI extracted a task result.", + "relevant_message_excerpt": "Please handle booking message.", + "attachments": [], + "file_references": [], + "context_used": {}, + %s, + "additional_operations": [], + "idempotency_key": null + } + ], + "extraction_warnings": [] + } + """.formatted(sourceProvider, sourceChannel, sourceMessageId, resultType, taskType, taskSubtype, itemFields); + } + private String twoTaskBody(String sourceMessageId, String groupCode) { return """ { @@ -2820,6 +3065,79 @@ class SuperAgentTaskResultControllerTest { """.formatted(externalSourceMessageId, confirmationNumber, arrivalDate, departureDate); } + private String v3BusinessRootBody(String externalSourceMessageId, String confirmationNumber) { + return """ + { + "source_message": { + "source_message_id": "%s", + "subject": "Outlook new booking", + "from": null, + "cc": [], + "received_at": null, + "source_channel": "Email" + }, + "message_events": [ + { + "event_type": "New Booking", + "event_role": "travel_agent_request", + "source_event_index": "E1", + "current_or_history": "current", + "case_keys": { + "group_code": null, + "confirmation_number": "%s", + "reservation_number": null, + "block_code": null + }, + "relevant_message_excerpt": "Please create a new FIT reservation.", + "attachments": [], + "file_references": [], + "context_used": {}, + "extracted_fields": { + "booking_object_type": "FIT Reservation", + "arrival_date": "2026-09-01" + }, + "manual_review": null + } + ], + "case_candidates": [], + "extraction_warnings": [], + "unhandled_current_intents": [] + } + """.formatted(externalSourceMessageId, confirmationNumber); + } + + private String structuredS10Body(String externalSourceMessageId) { + return """ + { + "source_message": { + "source_message_id": "%s", + "subject": null, + "from": null, + "cc": [], + "received_at": null, + "source_channel": "Email" + }, + "route_code": "S10", + "handler_type": "main_agent_outcome", + "result_type": "source_message_review_notification", + "current_or_history": "current", + "agent_assessment": { + "status": "no_booking_action_detected", + "reason_code": "no_booking_action_detected", + "automation_action": "none" + }, + "notification": { + "required": true, + "notification_type": "source_message_review", + "show_source_message": true, + "requires_user_decision": true, + "visible_message": "未匹配到当前 Agent 支持的业务事件类型,请查看原邮件并决定是否需要回复或进行其他处理。" + }, + "manual_review": null + } + """.formatted(externalSourceMessageId); + } + private String[] createReadyTaskWithTwoOperaOperations( String externalMessageId, String nonce, diff --git a/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/SuperAgentTaskResultP0FixtureRegressionTest.java b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/SuperAgentTaskResultP0FixtureRegressionTest.java index 2ebc7b4..2d2b777 100644 --- a/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/SuperAgentTaskResultP0FixtureRegressionTest.java +++ b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/SuperAgentTaskResultP0FixtureRegressionTest.java @@ -304,6 +304,109 @@ class SuperAgentTaskResultP0FixtureRegressionTest { assertThat(transitionCount).isEqualTo(3L); } + @Test + void shouldNormalizeSingleParentBlockCodeAndCreateParentSplitTask() throws Exception { + ObjectNode root = fixture("parent_split_two_children.json").deepCopy(); + useParentSplitBusinessKeys(root, "PARENT-2608-BLOCK", "CHILD-2608-BLOCK-A", "CHILD-2608-BLOCK-B"); + convertParentSplitRootToP01(root); + ObjectNode parentEvent = (ObjectNode) root.path("message_events").get(2); + ObjectNode parentCaseKeys = (ObjectNode) parentEvent.path("case_keys"); + parentCaseKeys.putNull("group_code"); + parentCaseKeys.put("block_code", "PARENT-2608-BLOCK"); + String externalId = "p0-parent-split-single-block-code-001"; + SourceMessageCaptureResult source = captureSourceMessage(externalId); + + mockMvc.perform(signedPost(withSourceMessageId(root, externalId), "nonce-p0-parent-single-block-001")) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.accepted_count").value(3)) + .andExpect(jsonPath("$.items[0].route_code").value("R02_NEW_GROUP_BLOCK_NORMAL")) + .andExpect(jsonPath("$.items[1].route_code").value("R02_NEW_GROUP_BLOCK_NORMAL")) + .andExpect(jsonPath("$.items[2].route_code").value("R08_CANCEL_ALLOTMENT_CONTROL_BLOCK_NORMAL")); + + String parentCaseKeysJson = jdbcTemplate.queryForObject(""" + SELECT case_keys_json + FROM workflow_reservation_ai_transition + WHERE source_message_id = ? + AND route_code = 'R08_CANCEL_ALLOTMENT_CONTROL_BLOCK_NORMAL' + """, String.class, source.inboxId()); + JsonNode normalizedCaseKeys = objectMapper.readTree(parentCaseKeysJson); + assertThat(normalizedCaseKeys.path("group_code").asText()).isEqualTo("PARENT-2608-BLOCK"); + assertThat(normalizedCaseKeys.path("block_code").asText()).isEqualTo("PARENT-2608-BLOCK"); + + String parentPayloadJson = jdbcTemplate.queryForObject(""" + SELECT ai_payload_json + FROM workflow_reservation_ai_transition + WHERE source_message_id = ? + AND route_code = 'R08_CANCEL_ALLOTMENT_CONTROL_BLOCK_NORMAL' + """, String.class, source.inboxId()); + JsonNode parentPayload = objectMapper.readTree(parentPayloadJson); + assertThat(parentPayload.path("case_keys").path("group_code").asText()).isEqualTo("PARENT-2608-BLOCK"); + assertThat(parentPayload.path("v3_message_event").path("case_keys").path("group_code").isNull()).isTrue(); + assertThat(parentPayload.path("v3_message_event").path("case_keys").path("block_code").asText()) + .isEqualTo("PARENT-2608-BLOCK"); + } + + @Test + void shouldCreateParentSplitManualReviewWhenParentKeysConflict() throws Exception { + ObjectNode root = fixture("parent_split_two_children.json").deepCopy(); + useParentSplitBusinessKeys(root, "PARENT-2608-CONFLICT", "CHILD-2608-CONFLICT-A", "CHILD-2608-CONFLICT-B"); + convertParentSplitRootToP01(root); + ObjectNode parentEvent = (ObjectNode) root.path("message_events").get(2); + ObjectNode parentCaseKeys = (ObjectNode) parentEvent.path("case_keys"); + parentCaseKeys.putNull("group_code"); + parentCaseKeys.putNull("block_code"); + ObjectNode contextUsed = (ObjectNode) parentEvent.path("context_used"); + ArrayNode candidates = contextUsed.putArray("parent_identity_candidates"); + candidates.addObject() + .put("field", "group_code") + .put("value", "PARENT-2608-CONFLICT-A") + .put("evidence_source", "subject"); + candidates.addObject() + .put("field", "block_code") + .put("value", "PARENT-2608-CONFLICT-B") + .put("evidence_source", "attachment"); + ObjectNode manualReview = parentEvent.putObject("manual_review"); + manualReview.put("reason_code", "target_object_unclear"); + manualReview.put("visible_reason", "Parent Group 的 group_code 与 block_code 原始候选冲突,请人工确认目标。"); + manualReview.put("review_record_type", "business_event_review"); + manualReview.putArray("missing_fields") + .add("/case_keys/group_code") + .add("/case_keys/block_code"); + manualReview.putArray("blocking_points") + .add("Parent Group identity cannot be safely normalized."); + manualReview.putArray("conflicting_points") + .add("PARENT-2608-CONFLICT-A") + .add("PARENT-2608-CONFLICT-B"); + manualReview.putArray("suggested_human_actions") + .add("confirm_parent_group_identity"); + manualReview.putArray("evidence_to_check") + .add("parent_identity_candidates"); + manualReview.putObject("known_fields"); + String externalId = "p0-parent-split-key-conflict-001"; + SourceMessageCaptureResult source = captureSourceMessage(externalId); + + mockMvc.perform(signedPost(withSourceMessageId(root, externalId), "nonce-p0-parent-key-conflict-001")) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.accepted_count").value(3)) + .andExpect(jsonPath("$.items[0].route_code").value("R02_NEW_GROUP_BLOCK_NORMAL")) + .andExpect(jsonPath("$.items[1].route_code").value("R02_NEW_GROUP_BLOCK_NORMAL")) + .andExpect(jsonPath("$.items[2].route_code").value("R08_CANCEL_ALLOTMENT_CONTROL_BLOCK_REVIEW")) + .andExpect(jsonPath("$.items[2].adapter_error_code").doesNotExist()); + + Long parentReviewTaskCount = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) + FROM workflow_reservation_task_card card + JOIN workflow_reservation_task task + ON task.id = card.task_id + JOIN workflow_reservation_ai_transition transition + ON transition.id = task.ai_transition_id + WHERE task.source_message_id = ? + AND transition.route_code = 'R08_CANCEL_ALLOTMENT_CONTROL_BLOCK_REVIEW' + AND task.result_type = 'manual_review' + """, Long.class, source.inboxId()); + assertThat(parentReviewTaskCount).isEqualTo(1L); + } + @Test void shouldFailClosedWhenCurrentProducerUsesLegacyParentCancelBooking() throws Exception { ObjectNode root = fixture("parent_split_two_children.json").deepCopy(); diff --git a/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/SuperAgentTaskResultStrictSourceMessageIdControllerTest.java b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/SuperAgentTaskResultStrictSourceMessageIdControllerTest.java new file mode 100644 index 0000000..7d87fcc --- /dev/null +++ b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/SuperAgentTaskResultStrictSourceMessageIdControllerTest.java @@ -0,0 +1,132 @@ +package cn.nianxx.thhotel.workflows.reservation.control; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import cn.nianxx.thhotel.ThHotelApplication; +import cn.nianxx.thhotel.platform.message.common.request.CaptureSourceMessageCommand; +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.util.HexFormat; +import java.util.List; +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +@SpringBootTest( + classes = ThHotelApplication.class, + properties = { + "superagent.task-result.hmac-secret=test-superagent-secret", + "superagent.task-result.clock-skew-seconds=300", + "superagent.task-result.nonce-ttl-seconds=600", + "superagent.task-result.max-body-bytes=12000", + "superagent.task-result.allow-legacy-internal-source-message-id=false" + }) +@AutoConfigureMockMvc +@ActiveProfiles("test") +class SuperAgentTaskResultStrictSourceMessageIdControllerTest { + + private static final String ENDPOINT = "/api/integrations/superagent/task-results"; + private static final String CLIENT_ID = "superagent-test-client"; + private static final String SECRET = "test-superagent-secret"; + + @Autowired + private MockMvc mockMvc; + + @Autowired + private SourceMessageCaptureService captureService; + + @Test + void shouldRejectInternalSourceMessageIdWhenLegacyCompatibilityDisabled() throws Exception { + SourceMessageCaptureResult source = captureSourceMessage("mail-strict-external-id-only-001"); + String body = """ + { + "source_message_id": "%s", + "ai_task_results": [ + { + "source_event_index": 1, + "catalog_code": "S01", + "skill_id": "S01_new_booking_skill", + "result_type": "normal_task", + "task_type": "New Booking", + "task_subtype": "new_fit_reservation", + "current_or_history": "current", + "visible_reason": "AI extracted a task result.", + "relevant_message_excerpt": "Please handle booking message.", + "attachments": [], + "file_references": [], + "context_used": {}, + "case_keys": {}, + "extracted_fields": {}, + "additional_operations": [], + "idempotency_key": null + } + ], + "extraction_warnings": [] + } + """.formatted(source.inboxId()); + + String timestamp = Instant.now().toString(); + String nonce = "nonce-strict-external-id-only-001"; + mockMvc.perform(post(ENDPOINT) + .contentType(MediaType.APPLICATION_JSON) + .content(body) + .header("X-TH-Hotel-SuperAgent-Client-Id", CLIENT_ID) + .header("X-TH-Hotel-SuperAgent-Timestamp", timestamp) + .header("X-TH-Hotel-SuperAgent-Nonce", nonce) + .header("X-TH-Hotel-SuperAgent-Signature", signature(body, nonce, timestamp))) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.error_code").value("SOURCE_MESSAGE_NOT_FOUND")); + } + + private SourceMessageCaptureResult captureSourceMessage(String externalMessageId) { + return captureService.capture(new CaptureSourceMessageCommand( + "HOTEL-TEST", + "AGENTBUS", + "OUTLOOK", + externalMessageId, + "thread-" + externalMessageId, + "frame-" + externalMessageId, + "session-m002", + Instant.parse("2026-07-07T08:00:00Z"), + "guest@example.test", + "M002 SuperAgent intake", + "Please handle booking message.", + "Please handle booking message.", + "{\"source\":{\"external_message_id\":\"" + externalMessageId + "\"}}", + "agentbus-outlook-v1", + List.of() + )); + } + + private String signature(String body, String nonce, String timestamp) throws Exception { + String canonical = "POST\n" + + ENDPOINT + + "\n" + + timestamp + + "\n" + + nonce + + "\n" + + CLIENT_ID + + "\n" + + sha256(body); + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(SECRET.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); + return "sha256=" + HexFormat.of().formatHex(mac.doFinal(canonical.getBytes(StandardCharsets.UTF_8))); + } + + private String sha256(String body) throws Exception { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + return HexFormat.of().formatHex(digest.digest(body.getBytes(StandardCharsets.UTF_8))); + } +}