From 74e429a2cb5324b0dfc6ad2f15019ee3fbf76950 Mon Sep 17 00:00:00 2001 From: andy Date: Fri, 10 Jul 2026 12:05:57 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=9E=E7=8E=B0SuperAgent=E7=89=B9=E6=AE=8A?= =?UTF-8?q?=E5=85=A5=E5=8F=A3=E7=BB=93=E6=9E=9C=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend-to-frontend-notes.md | 22 +- .../debug-eml-page-integration-guide.md | 16 +- .../frontend-to-backend-api-requests.md | 36 ++- docs/project/go-live-notes.md | 11 +- .../integrations/superagent-api-contract.md | 95 +++++-- .../M002-order-task-workflow-v2.md | 92 ++++-- ...002-superagent-task-result-api-contract.md | 103 +++++-- .../SuperAgentTaskResultController.java | 47 +++- .../enums/SourceMessageOnlyResultCode.java | 45 +++ .../DebugEmlSuperAgentRunServiceImpl.java | 98 ++++++- .../dto/ReservationAiQueryOrderSnapshot.java | 2 + .../common/dto/ReservationOrderDraft.java | 16 +- .../common/dto/ReservationOrderSnapshot.java | 12 +- .../common/enums/AiResultType.java | 2 +- .../enums/ReservationOrderVisibility.java | 11 + .../enums/ReservationSystemTaskType.java | 3 +- .../common/enums/ReservationTaskCardType.java | 3 +- .../ReservationSourceMessageOnlyResult.java | 26 ++ .../result/ReservationTaskDetailResult.java | 3 + .../domain/ReservationOrderEntity.java | 4 + ...ybatisReservationAiWorkflowRepository.java | 8 +- .../ReservationAiTaskIntakeService.java | 7 +- ...vationTaskCardFieldDefinitionProvider.java | 3 +- .../ReservationAiTaskIntakeServiceImpl.java | 262 ++++++++++++++++++ .../ReservationFrontendQueryServiceImpl.java | 24 +- .../ReservationTaskWorkflowServiceImpl.java | 35 +++ .../V11__add_reservation_order_visibility.sql | 7 + .../DebugEmlSuperAgentControllerTest.java | 51 +++- .../SuperAgentTaskResultControllerTest.java | 174 ++++++++++++ 29 files changed, 1121 insertions(+), 97 deletions(-) create mode 100644 server/src/main/java/cn/nianxx/thhotel/platform/common/enums/SourceMessageOnlyResultCode.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/enums/ReservationOrderVisibility.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationSourceMessageOnlyResult.java create mode 100644 server/src/main/resources/db/migration/V11__add_reservation_order_visibility.sql diff --git a/docs/project/frontend-backend/backend-to-frontend-notes.md b/docs/project/frontend-backend/backend-to-frontend-notes.md index 9ec1b0e..0167959 100644 --- a/docs/project/frontend-backend/backend-to-frontend-notes.md +++ b/docs/project/frontend-backend/backend-to-frontend-notes.md @@ -2,7 +2,7 @@ ## 1. 文档定位 -本文记录后端侧提醒前端开发时必须注意的项目规范、业务规则、接口边界和安全要求。当前内容服务于 Reservation 任务详情、订单详情、任务列表、Message Notification 等第一版页面。 +本文记录后端侧提醒前端开发时必须注意的项目规范、业务规则、接口边界和安全要求。当前内容服务于 Reservation 任务详情、订单详情、任务列表、S000/S999 特殊只读任务、历史 Message Notification 等第一版页面。 ## 2. 项目开发注意事项 @@ -28,7 +28,10 @@ - 用户可以修改任务字段内容;最终确认后,后端使用 `confirmed_payload_json` 作为 OPERA 模拟输入来源。 - 同一订单下,前置任务未结束时,后续任务只能查看,不能编辑、确认或执行 OPERA 模拟操作。 - 任务状态 `FAILED` 第一版视为结束状态,不阻塞后续任务;但失败的 OPERA 操作不能跳过,必须展示失败原因并允许重试。 -- Message Notification 挂临时订单,只读展示,不参与订单任务执行队列,不阻塞其他任务,也不被其他任务阻塞。 +- SuperAgent 新入口结果只会生成 `normal_task`、`manual_review` 或纯文本 `S000/S999`;`informational_message` 仅历史兼容,新页面不要再按新数据入口依赖它。 +- `S000` 表示纯信息类邮件,`S999` 表示无法形成业务素材包。后端会创建 `SOURCE_MESSAGE_ONLY` 只读特殊任务,任务列表可见,订单列表不可见。 +- `SOURCE_MESSAGE_ONLY` 任务不允许编辑、确认、人工转换订单、执行 OPERA 或重试 OPERA;不参与订单任务执行队列,不阻塞其他任务,也不被其他任务阻塞。 +- 历史 Message Notification 挂临时订单,只读展示,不参与订单任务执行队列,不阻塞其他任务,也不被其他任务阻塞。 - Fallback / manual_review 转为 New / Update / Cancel 时需要展示审计轨迹;登录权限底座已提供,具体业务审计 actor 迁移仍后置。 - 普通任务切换订单接口已确认后置,前端第一版不要把普通任务拖拽或切换订单做成可提交能力。 @@ -39,10 +42,10 @@ | `POST /api/auth/login` | 用户名密码登录 | 成功后返回 `access_token`、当前用户、可访问酒店、权限码和可见菜单;token 只放 `sessionStorage`,不要放 `localStorage`、URL、日志或错误上报。 | | `GET /api/auth/me` | 恢复当前登录态 | 前端启动后带 `Authorization: Bearer ` 调用;401 时清理 token 并进入登录页。 | | `POST /api/auth/logout` | 登出当前 session | 带 `Authorization: Bearer `;成功后前端必须清理本地 token 和当前用户上下文。 | -| `GET /api/reservation/orders` | 查询订单列表 | 默认返回全部订单状态;`open_task_count` 排除 `COMPLETED` 和 `FAILED`;用 `next_processable_task_id` 引导用户继续处理。 | -| `GET /api/reservation/tasks` | 查询任务列表 / 工作台 | 用 `can_process` 和 `readonly_reason_code` 控制入口按钮;列表不返回 AI 原始 payload、邮件正文或附件 URL;已返回来源邮件会话摘要字段,并支持 `order_status` 按任务所属订单状态筛选。 | -| `GET /api/reservation/orders/{orderId}` | 查询订单详情与任务时间线 | `include_tasks=false` 可只取订单摘要;时间线按后端队列顺序返回,前端不要自行按创建时间重排;`tasks[]` 已返回来源邮件会话摘要字段。 | -| `GET /api/reservation/tasks/{taskId}` | 查询任务详情 | 以返回的可处理状态和只读原因控制按钮,不只看任务状态;`fields[]` 已包含 P0 字段元数据。 | +| `GET /api/reservation/orders` | 查询订单列表 | 默认返回全部订单状态;`open_task_count` 排除 `COMPLETED` 和 `FAILED`;隐藏技术订单不返回,因此 S000/S999 不会在订单列表形成订单。 | +| `GET /api/reservation/tasks` | 查询任务列表 / 工作台 | 用 `can_process` 和 `readonly_reason_code` 控制入口按钮;列表不返回 AI 原始 payload、邮件正文或附件 URL;已返回来源邮件会话摘要字段,并支持 `order_status` 按任务所属订单状态筛选;S000/S999 会以 `task_type=SOURCE_MESSAGE_ONLY` 返回。 | +| `GET /api/reservation/orders/{orderId}` | 查询订单详情与任务时间线 | `include_tasks=false` 可只取订单摘要;时间线按后端队列顺序返回,前端不要自行按创建时间重排;`tasks[]` 已返回来源邮件会话摘要字段;隐藏技术订单详情不可作为普通订单页打开。 | +| `GET /api/reservation/tasks/{taskId}` | 查询任务详情 | 以返回的可处理状态和只读原因控制按钮,不只看任务状态;`fields[]` 已包含 P0 字段元数据;`SOURCE_MESSAGE_ONLY` 详情字段列表和 OPERA 操作列表为空,并通过 `source_message_only_result` 返回 S000/S999 入口结果。 | | `PUT /api/reservation/tasks/{taskId}/draft` | 保存任务草稿 | 只保存草稿,不代表用户最终确认。 | | `POST /api/reservation/tasks/{taskId}/confirm` | 最终确认任务 | 后端会做第一版字段校验,通过后进入 `READY`。 | | `POST /api/reservation/tasks/{taskId}/manual-review-conversions` | Fallback 人工转换 | 只用于 manual_review / fallback,不用于普通任务切换订单。 | @@ -66,7 +69,7 @@ | `GET /api/reservation/orders/{orderId}` | 补齐 `tasks[]` 每条任务的来源邮件会话摘要字段。 | `include_tasks=false` 可只取订单摘要;时间线顺序由后端按订单队列返回,前端不要自行按创建时间重排。`include_source_summary` 第一版不作为前端裁剪字段的强约束,前端暂不要依赖它减少返回字段。 | | `GET /api/reservation/tasks/{taskId}` | 补齐顶层来源邮件字段,并扩展 `fields[]` 元数据。 | 顶层来源字段用于打开邮件会话;`fields[]` 中的 `result_type`、`task_type`、`task_subtype`、`default_value_source` 用于前端字段分组、调试和白名单对齐。 | | `GET /api/source-messages/{sourceMessageId}/conversation` | 新增邮件会话详情接口,并补齐 `html_body_sanitized` / `html_render_mode`。 | 当前唯一推荐路径是这个接口;前端渲染邮件 HTML 时优先使用 `html_body_sanitized`;不要调用历史讨论过的 `/api/source-message-conversations/{externalConversationId}`。 | -| `POST /api/system/debug/eml-superagent-runs` | 新增 Debug EML 上传到 SuperAgent 调试接口,并补齐独立 Debug 外部消息 ID、原始 Message-ID 保留和安全 HTML 字段。 | 只用于调试页面;请求为 multipart/form-data;必须传 `X-TH-Hotel-Debug-Upload-Key`,但该 key 不能写进前端源码、构建产物、URL、localStorage 或错误上报。 | +| `POST /api/system/debug/eml-superagent-runs` | 新增 Debug EML 上传到 SuperAgent 调试接口,并补齐独立 Debug 外部消息 ID、原始 Message-ID 保留、安全 HTML 字段和 S000/S999 识别。 | 只用于调试页面;请求为 multipart/form-data;必须传 `X-TH-Hotel-Debug-Upload-Key`,但该 key 不能写进前端源码、构建产物、URL、localStorage 或错误上报;SuperAgent 返回 S000/S999 时不是 JSON 解析失败。 | ### 5.2 登录权限接入注意 @@ -123,6 +126,9 @@ POST /api/auth/logout - `next_processable_task_id` 是后端按同订单队列实时计算出的下一条可处理任务;前端可以用它做“继续处理”入口。 - `display_order_key` 是前端优先展示的订单业务号或临时订单号;`group_code` 和 `confirmation_number` 只有在当前订单业务号类型匹配时返回。 - 订单 ID、任务 ID、SourceMessage ID 在这些前端接口中按字符串返回,前端不要转换成 JavaScript number。 +- `SOURCE_MESSAGE_ONLY` 任务背后有隐藏技术订单用于满足后端任务外键,但订单列表不会返回该订单;任务列表中该类任务的 `display_order_key`、`temporary_order_no`、`group_code`、`confirmation_number` 可能为空,前端不要因此隐藏整条任务。 +- 任务列表里 `task_type=SOURCE_MESSAGE_ONLY`、`task_subtype=S000/S999` 的记录只展示邮件来源和 SuperAgent 入口结果,不展示处理按钮。 +- 任务详情里 `source_message_only_result` 仅对 `SOURCE_MESSAGE_ONLY` 返回,包含 `entry_result_code`、`entry_result_meaning`、`entry_result_description`、`entry_result_source_message_id` 和 `raw_answer`;普通任务该字段为空。 ### 5.6 前端联调演示数据 seed 接口 @@ -159,7 +165,7 @@ Content-Type: application/json - 已完成 New Booking 任务和两条 OPERA 模拟成功记录。 - OPERA 模拟失败任务,可在任务详情看到失败 attempt 和重试入口。 - Fallback / manual_review 任务。 -- Message Notification 只读任务。 +- 历史 Message Notification 只读任务;S000/S999 特殊只读任务可通过 SuperAgent 回调或后续专用夹具补充。 - 同一邮件会话下多封邮件、完整 HTML、附件外链和内联图片外链。 ### 5.7 任务详情字段元数据接入注意 diff --git a/docs/project/frontend-backend/debug-eml-page-integration-guide.md b/docs/project/frontend-backend/debug-eml-page-integration-guide.md index a01e86e..2d3b713 100644 --- a/docs/project/frontend-backend/debug-eml-page-integration-guide.md +++ b/docs/project/frontend-backend/debug-eml-page-integration-guide.md @@ -35,7 +35,7 @@ Debug EML 页面第一版只做一件事: | 执行状态区 | loading、成功、失败、耗时、本次 `debug_run_id` | 提交后禁用按钮,避免重复点击;失败时展示安全错误摘要。 | | SourceMessage 追溯区 | `source_message_id`、`source_provider`、`external_message_id`、`external_conversation_id` | 用于确认已写入 SourceMessage Inbox。 | | 邮件内容预览区 | `html_body_sanitized`、纯文本、附件列表、内联图片列表 | HTML 展示必须优先使用 `html_body_sanitized`。 | -| SuperAgent 结果区 | `superagent_parsed_json`、`superagent_raw_answer`、`warnings[]` | JSON 可以格式化展示;raw answer 用于排查 SuperAgent 非 JSON 输出。 | +| SuperAgent 结果区 | `superagent_parsed_json`、`superagent_raw_answer`、`warnings[]` | JSON 可以格式化展示;S000/S999 会被后端识别成结构化入口结果;raw answer 用于排查其他非 JSON 输出。 | | 调试 Payload 区 | `agentbus_like_payload` | 只用于调试展示,不让用户编辑后重新提交。 | ## 4. 接口 @@ -120,7 +120,7 @@ export async function uploadDebugEml(input: { | `superagent_session_id` | string | SuperAgent session ID。 | | `superagent_run_id` | string | SuperAgent run ID。 | | `superagent_raw_answer` | string | SuperAgent 最终原始文本回答。 | -| `superagent_parsed_json` | object/null | 后端尝试解析出的 JSON;解析失败时可能为空。 | +| `superagent_parsed_json` | object/null | 后端尝试解析出的 JSON;S000/S999 会返回识别后的对象,其他解析失败时可能为空。 | | `warnings[]` | string[] | 安全或解析警告,可在页面顶部或结果区展示。 | | `status` | string | Debug run 状态。 | @@ -143,6 +143,18 @@ export async function uploadDebugEml(input: { - `agentbus_like_payload.source.original_message_id` 是原始邮件 `Message-ID`。 - `external_message_id` 是 Debug 链路生成的独立 ID,不等同于原始 `Message-ID`。 - `superagent_parsed_json` 有值时优先展示格式化 JSON;没有值时展示 `superagent_raw_answer`。 +- 如果 SuperAgent 返回 `S000,source_message_id` 或 `S999,source_message_id`,后端会把它识别为特殊入口结果,不会作为 JSON 解析失败处理。前端可在结果区展示 `entry_result_code`、`entry_result_source_message_id`、`entry_result_meaning` 和 `entry_result_description`。 + +S000/S999 解析示例: + +```json +{ + "entry_result_code": "S000", + "entry_result_source_message_id": "mail-20260708-0001", + "entry_result_meaning": "PURE_INFORMATION", + "entry_result_description": "纯信息类邮件" +} +``` ## 6. 错误响应 diff --git a/docs/project/frontend-backend/frontend-to-backend-api-requests.md b/docs/project/frontend-backend/frontend-to-backend-api-requests.md index 2f0f761..60253f1 100644 --- a/docs/project/frontend-backend/frontend-to-backend-api-requests.md +++ b/docs/project/frontend-backend/frontend-to-backend-api-requests.md @@ -16,7 +16,7 @@ | P0 | 邮件会话详情接口 `GET /api/source-messages/{sourceMessageId}/conversation` | 邮件会话详情页 | 已完成第一版 | | 联调 | 演示数据 seed 接口 `POST /api/system/reservation/demo-data` | 本地 / test 前端页面看效果 | 已完成;仅 dev/test 受控使用 | | 联调 | Debug EML 上传接口 `POST /api/system/debug/eml-superagent-runs` | Debug 页面上传 `.eml` 看 SuperAgent 结果 | 已完成第一版;仅 dev/test 受控使用 | -| P1 | Message Notification 列表 / 详情接口 | 信息提醒页或订单详情只读卡片 | 未完成独立接口;可先通过任务列表 / 任务详情展示 `INFORMATIONAL_MESSAGE` | +| P1 | S000/S999 特殊只读任务展示 | 任务列表、任务详情来源邮件查看 | 已完成后端第一版;通过任务列表 / 任务详情展示 `SOURCE_MESSAGE_ONLY`,不做独立 Message Notification 接口 | | P1 | 任务卡前端字段白名单元数据接口 | 字段白名单调试、版本对齐 | 未完成;若任务详情已透出完整元数据,可后置 | | 后置 | 普通任务切换订单接口 | 任务详情订单归属调整 | 未完成;已确认后置 | @@ -43,7 +43,7 @@ | `POST /api/system/reservation/demo-data` | 已完成 | 仅本地 / test 联调可用 | 默认关闭,必须后端配置访问口令;不能作为生产页面接口。 | | `POST /api/system/debug/eml-superagent-runs` | 已完成第一版 | 仅 dev/test Debug 页面可用 | 默认关闭,必须后端配置访问口令、阿里云 OSS 和 SuperAgent Open API;第一版只展示 SuperAgent 结果,不创建订单和任务。 | | `GET /api/source-message-conversations/{externalConversationId}` | 未发现后端实现 | 不可以 | 历史讨论过的候选路径,当前不提供;前端统一使用 `GET /api/source-messages/{sourceMessageId}/conversation`。 | -| `GET /api/reservation/message-notifications` | 未发现后端实现 | 不可以 | 如需独立信息提醒页再新增;第一版可先用任务接口过滤。 | +| `GET /api/reservation/message-notifications` | 未发现后端实现 | 不可以 | 第一版不做独立接口;新入口 S000/S999 已通过 `SOURCE_MESSAGE_ONLY` 任务展示。 | | `GET /api/reservation/task-card-field-whitelist` | 未发现后端实现 | 不可以 | 若任务详情 `fields[]` 已补齐 3.0 元数据,可后置。 | ## 3. 任务列表 / 工作台接口字段补齐 @@ -86,7 +86,7 @@ GET /api/reservation/tasks | --- | --- | --- | | `hotel_id` | 否 | 酒店 ID。第一版如果只有单酒店,可为空。 | | `order_id` | 否 | 按订单过滤。 | -| `task_type` | 否 | `NEW_BOOKING`、`UPDATE_BOOKING`、`CANCEL_BOOKING`、`MANUAL_REVIEW`、`INFORMATIONAL_MESSAGE`。 | +| `task_type` | 否 | `NEW_BOOKING`、`UPDATE_BOOKING`、`CANCEL_BOOKING`、`MANUAL_REVIEW`、`INFORMATIONAL_MESSAGE`、`SOURCE_MESSAGE_ONLY`。其中 `INFORMATIONAL_MESSAGE` 仅历史兼容,新入口 S000/S999 使用 `SOURCE_MESSAGE_ONLY`。 | | `task_status` | 否 | 任务状态过滤。 | | `task_subtype` | 否 | 任务卡 subtype 过滤。 | | `order_status` | 否 | 按任务所属订单状态过滤,支持 `TEMPORARY`、`ACTIVE`、`ENDED`、`LOGIC_DELETED`;不传时保持当前行为。 | @@ -300,7 +300,7 @@ POST /api/system/reservation/demo-data | `tasks[]` | 生成的任务 ID、任务类型、任务 subtype 和任务状态。 | | `entrypoints` | 可直接访问的任务列表、订单列表、订单详情、任务详情、邮件会话详情 URL。 | -第一版 seed 覆盖:队列阻塞、已完成 OPERA 模拟、OPERA 失败可重试、Fallback 人工复核、Message Notification、邮件会话完整 HTML / 附件 / 内联图片。 +第一版 seed 覆盖:队列阻塞、已完成 OPERA 模拟、OPERA 失败可重试、Fallback 人工复核、历史 Message Notification 只读任务、邮件会话完整 HTML / 附件 / 内联图片。S000/S999 特殊只读任务可通过 SuperAgent 回调或后续专用夹具补充。 前端注意:不要把访问口令写入前端仓库、浏览器环境变量或构建产物;该接口只能由本地联调人员手动调用或由受控测试脚本调用。 @@ -502,11 +502,23 @@ GET /api/reservation/tasks/{taskId} - 如果后端已有更细的字段来源或适用场景元数据,可后续再扩展 `field_source`、`applicable_scenario`,不作为本轮 P0 阻塞项。 - 前端已统一配置 `VITE_RESERVATION_HOTEL_ID`,并会在 `GET /api/reservation/orders`、`GET /api/reservation/tasks`、`GET /api/reservation/orders/{orderId}` 自动传 `hotel_id`。当前 `GET /api/reservation/tasks/{taskId}` 以及任务写操作 Controller 不接收 `hotel_id`;第一版先按 ID 定位,后续多酒店隔离 / 权限方案统一补齐。 -## 9. Message Notification 列表 / 详情接口 +## 9. S000/S999 特殊只读任务与历史 Message Notification -当前状态:未发现后端独立 Message Notification 列表 / 详情接口。当前后端已经支持 `INFORMATIONAL_MESSAGE` 任务类型进入任务体系,第一版前端可以先通过 `GET /api/reservation/tasks?task_type=INFORMATIONAL_MESSAGE` 和 `GET /api/reservation/tasks/{taskId}` 展示信息提醒任务。仅当产品确认需要独立“信息提醒页”时,再新增本节接口。 +当前状态:后端不提供独立 Message Notification 列表 / 详情接口。SuperAgent 新入口返回 `S000,source_message_id` 或 `S999,source_message_id` 时,后端会创建 `SOURCE_MESSAGE_ONLY` 只读特殊任务,第一版前端通过 `GET /api/reservation/tasks?task_type=SOURCE_MESSAGE_ONLY` 和 `GET /api/reservation/tasks/{taskId}` 展示。 -建议路径: +历史 `INFORMATIONAL_MESSAGE` 仍可通过任务列表 / 任务详情兼容展示,但新数据不要依赖它。 + +展示规则: + +- `task_type=SOURCE_MESSAGE_ONLY`、`task_subtype=S000`:纯信息类邮件。 +- `task_type=SOURCE_MESSAGE_ONLY`、`task_subtype=S999`:无法形成业务素材包。 +- 任务列表可见,订单列表不可见。 +- 任务详情只允许查看来源邮件、会话、附件和 SuperAgent 原始返回。 +- 任务详情通过 `source_message_only_result` 返回 `entry_result_code`、`entry_result_meaning`、`entry_result_description`、`entry_result_source_message_id` 和 `raw_answer`。 +- 不显示编辑、确认、人工转换、执行 OPERA 或重试 OPERA 按钮。 +- 不参与订单任务执行顺序阻塞。 + +当前不建议新增路径: ```text GET /api/reservation/message-notifications @@ -529,13 +541,15 @@ GET /api/reservation/message-notifications/{taskId} { "task_id": "10009", "order_id": "20009", - "task_type": "INFORMATIONAL_MESSAGE", + "task_type": "SOURCE_MESSAGE_ONLY", + "task_subtype": "S000", "task_status": "COMPLETED", "queue_participation": false, "readonly": true, - "visible_reason": "FYI message without booking operation", + "visible_reason": "S000 pure information entry result", "relevant_message_excerpt": "Noted with thanks.", - "informational_message": "该消息仅作信息提醒,不需要执行 OPERA 操作。", + "entry_result_code": "S000", + "entry_result_description": "纯信息类邮件", "attachments": [], "source_message_id": "30009", "external_conversation_id": "thread-20260708-009", @@ -615,5 +629,5 @@ POST /api/reservation/tasks/{taskId}/order-binding - 订单列表、任务列表当前统一使用 `items + page` 分页结构;邮件会话详情不分页,返回同一会话全部邮件。 - 邮件会话详情接口已优先使用 `GET /api/source-messages/{sourceMessageId}/conversation`。 - 任务详情 `fields[]` 已由后端直接透出 P0 需要的 3.0 元数据;独立字段白名单接口后置。 -- Message Notification 是否独立成页面,还是只在订单详情中展示。 +- 独立 Message Notification 页面继续后置;新入口 S000/S999 第一版先在任务列表和任务详情展示。 - 邮件会话全文读取的审计策略由后端内部处理;前端不保存原文读取 key。 diff --git a/docs/project/go-live-notes.md b/docs/project/go-live-notes.md index e8bb04d..4230878 100644 --- a/docs/project/go-live-notes.md +++ b/docs/project/go-live-notes.md @@ -12,7 +12,7 @@ - `GET /api/source-messages/{id}/original`:受控读取邮件原文、HTML 和媒体 URL,并记录访问审计。 - `GET /api/system/agentbus-probe`:查看 AgentBus WebSocket 连接状态和安全计数器。 - AgentBus WebSocket 入站链路:默认关闭,开启后只把业务 frame 写入 SourceMessage Inbox。 -- SuperAgent 任务结果接收接口:接收一个外部 `source_message_id` 下的 AI 任务结果,反查 SourceMessage Inbox 后写入 AI 过渡层、订单、任务和任务卡。 +- SuperAgent 任务结果接收接口:接收一个外部 `source_message_id` 下的 AI 任务结果,反查 SourceMessage Inbox 后写入 AI 过渡层、订单、任务和任务卡;同时支持 `S000/S999,source_message_id` 文本入口结果并创建只读特殊任务。 - Reservation 任务详情接口:返回任务字段、队列可处理状态和 OPERA 模拟操作摘要。 - Reservation 任务草稿保存和最终确认接口:按任务卡矩阵做第一版后端校验,确认后生成 `confirmed_payload_json`。 - Reservation OPERA 模拟骨架:已确认任务固定生成两条模拟操作,支持执行、失败重试、attempt 记录和任务审计列表。 @@ -43,6 +43,7 @@ - 所有 Secret 都通过环境变量、部署平台 Secret 或密钥管理系统注入,不写入仓库、镜像、前端环境变量或普通配置文件。 - 生产默认不保存 AgentBus raw frame 样本。 - AgentBus 实时链路开启前,已经确认 WebSocket URL、Token、Bot Address、外部消息幂等键和断线重连语义。 +- S000/S999 特殊入口结果上线前,必须确认 `AGENTBUS_DEFAULT_HOTEL_ID` 已配置且对应 SourceMessage Inbox 入库酒店一致。 - 原文读取接口开启前,已经确认谁可以使用、在哪些场景使用、如何轮换访问 key。 - 日志采集、错误响应和监控面板都不会展示邮件正文、HTML、附件 URL、Token、Cookie、客户姓名、邮箱、电话或支付信息。 @@ -128,7 +129,10 @@ - 查询接口和任务结果通知接口使用同一套 Header、签名串、secret、timestamp 和 nonce 规则。 - SuperAgent 侧也需要配置同一个 secret,并按原始请求体计算 SHA-256。 - 当前第一版只支持一个 HMAC secret,secret 轮换需要协调部署窗口。 -- 任务结果通知接口里的 `source_message_id` 是外部来源消息 ID,对应 AgentBus `source.external_message_id`;正式请求必须带 `hotel_id`,后端用 `hotel_id + provider + channel + external_message_id` 反查内部 SourceMessage Inbox。 +- 任务结果通知接口 JSON body 里的 `source_message_id` 是外部来源消息 ID,对应 AgentBus `source.external_message_id`;正式 JSON 请求必须带 `hotel_id`,后端用 `hotel_id + provider + channel + external_message_id` 反查内部 SourceMessage Inbox。 +- 任务结果通知接口也支持 `text/plain` 的 `S000,source_message_id` 和 `S999,source_message_id`。这类请求不在 body 里带 `hotel_id`,后端使用 `AGENTBUS_DEFAULT_HOTEL_ID` 查询 SourceMessage Inbox。 +- `application/json` 和 `text/plain` 都必须使用原始请求体计算 SHA-256 并参与 HMAC 签名;SuperAgent 侧不能签名格式化后的 JSON 或二次拼接字符串。 +- S000/S999 会创建 `SOURCE_MESSAGE_ONLY` 只读特殊任务和隐藏技术订单,任务列表可见,订单列表不可见,不允许编辑、确认、转换订单或执行 OPERA。 - SuperAgent 查询上下文接口中的 `source_message_id`、`source_event_index` 第一版仅兼容接收,不参与查询和校验;不要依赖它们限制查询范围。 ### 3.6 Debug EML / SuperAgent Open API / 阿里云 OSS @@ -162,6 +166,7 @@ - Debug EML 写入 SourceMessage Inbox 时 `provider=DEBUG_EML_UPLOAD`,不能伪装为 AgentBus 来源。 - Debug EML 写入 SourceMessage Inbox 时 `external_message_id` 是后端生成的 `debug-eml-run-{debugRunId}-{sha256前缀}`;原始邮件 `Message-ID` 只保存在 payload 的 `source.original_message_id`。 - Debug EML 返回 `html_body_sanitized`、`html_sanitize_required`、`html_render_mode`,前端展示 HTML 时应优先使用清洗字段。 +- Debug EML 会识别 SuperAgent Open API 返回的 `S000/S999,source_message_id`,并在 `superagent_parsed_json` 中返回结构化入口结果;这不是 JSON 解析失败。 - Debug EML 第一版只展示 SuperAgent 结果,不创建订单、不创建任务、不调用任务结果通知接口。 - AgentBus 实时收到邮件后自动推 SuperAgent 当前未实现,不能把 Debug EML 链路等同于生产实时自动处理链路。 @@ -178,6 +183,7 @@ - `server/src/main/resources/db/migration/V4__harden_reservation_task_queue_and_manual_conversion.sql` - `server/src/main/resources/db/migration/V5__add_reservation_task_draft_and_confirmation.sql` - `server/src/main/resources/db/migration/V6__create_reservation_opera_simulation_tables.sql` +- `server/src/main/resources/db/migration/V11__add_reservation_order_visibility.sql` 当前 M004 Debug EML 相关 migration: @@ -203,6 +209,7 @@ - 入住日期、离店日期、酒店营业日属于酒店本地业务日期,不应因为 UTC 换算而自动前后偏移。 - 执行 V4 前,如果目标库已有 M002 试运行数据,必须先检查 ACTIVE 订单业务号重复和同订单任务队列序号重复。 - 执行 V5 / V6 前,如果目标库已有 M002 试运行数据,必须确认任务草稿、确认 payload 和 OPERA 模拟操作表允许从空数据开始补齐;不要手工伪造已确认 payload 或 attempt 历史。 +- 执行 V11 前,如果目标库已有手工造数或历史隐藏订单方案,必须确认是否需要回填 `order_visibility`;默认值 `VISIBLE` 会让历史订单继续出现在订单列表。 V4 前置检查 SQL: diff --git a/docs/project/integrations/superagent-api-contract.md b/docs/project/integrations/superagent-api-contract.md index 2d6552d..d82fa8d 100644 --- a/docs/project/integrations/superagent-api-contract.md +++ b/docs/project/integrations/superagent-api-contract.md @@ -4,9 +4,9 @@ | 项目 | 内容 | | --- | --- | -| 文档版本 | 0.3 | -| 日期 | 2026-07-09 | -| 状态 | 已增加邮件会话任务和受控正文查询接口 | +| 文档版本 | 0.4 | +| 日期 | 2026-07-10 | +| 状态 | 已增加 S000/S999 特殊入口结果处理 | | 适用范围 | 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;查询接口和 JSON 任务结果请求体应传入 `hotel_id`,S000/S999 文本结果使用本系统默认酒店。 | | `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 密钥;生产上线前必须更换为新的高强度随机密钥。 | @@ -45,7 +45,7 @@ | Header | 是否必填 | 中文说明 | | --- | --- | --- | -| `Content-Type` | 是 | 固定 `application/json` | +| `Content-Type` | 是 | 查询接口固定 `application/json`;任务结果通知接口支持 `application/json` 或 `text/plain` | | `X-TH-Hotel-SuperAgent-Client-Id` | 是 | SuperAgent 调用方客户端 ID | | `X-TH-Hotel-SuperAgent-Timestamp` | 是 | UTC ISO-8601 时间,例如 `2026-07-08T01:30:00Z` | | `X-TH-Hotel-SuperAgent-Nonce` | 是 | 每次请求唯一随机值,用于防重放 | @@ -81,14 +81,14 @@ X-TH-Hotel-SuperAgent-Signature: sha256= ### 2.3 服务端校验顺序 1. 校验请求体大小。 -2. 校验 HMAC 相关 Header 是否存在。 -3. 校验 timestamp 是否在允许时间窗口内。 -4. 计算原始请求体 SHA-256。 -5. 使用共享 secret 重新计算 HMAC。 -6. 常量时间比较签名。 -7. 校验并记录 `client_id + nonce`,防止重放。 -8. 查询接口校验 `Content-Type` 是否为 `application/json`。 -9. 鉴权和协议校验通过后再解析业务 JSON。 +2. 查询接口校验 `Content-Type` 是否为 `application/json`;任务结果通知接口校验是否为 `application/json` 或 `text/plain`,不支持的媒体类型不消耗 nonce。 +3. 校验 HMAC 相关 Header 是否存在。 +4. 校验 timestamp 是否在允许时间窗口内。 +5. 计算原始请求体 SHA-256。 +6. 使用共享 secret 重新计算 HMAC。 +7. 常量时间比较签名。 +8. 校验并记录 `client_id + nonce`,防止重放。 +9. 鉴权和协议校验通过后再解析业务 JSON 或 S000/S999 文本结果。 ## 3. SourceMessage ID 口径 @@ -437,10 +437,10 @@ SuperAgent 不应知道或依赖内部 SourceMessage Inbox ID。任务结果通 | Method | `POST` | | URL | `{TH_HOTEL_API_BASE_URL}/api/integrations/superagent/task-results` | | request_path | `/api/integrations/superagent/task-results` | -| Content-Type | `application/json` | -| 业务动作 | 接收 AI 任务结果,写入 AI 过渡层、订单、任务和任务卡 | +| Content-Type | `application/json` 或 `text/plain` | +| 业务动作 | 接收 AI 任务结果;JSON 写入业务订单和任务,S000/S999 创建只读特殊任务 | -### 8.2 请求体 +### 8.2 JSON 请求体 ```json { @@ -487,7 +487,7 @@ SuperAgent 不应知道或依赖内部 SourceMessage Inbox ID。任务结果通 | `ai_task_results[].source_event_index` | 是 | AI current 事件序号 | | `ai_task_results[].catalog_code` | 是 | Skill 目录代码 | | `ai_task_results[].skill_id` | 是 | Skill 标识 | -| `ai_task_results[].result_type` | 是 | `normal_task`、`manual_review`、`informational_message` | +| `ai_task_results[].result_type` | 是 | 新入口只接受 `normal_task`、`manual_review`;`informational_message` 仅历史兼容 | | `ai_task_results[].task_type` | 是 | AI 原始任务类型 | | `ai_task_results[].task_subtype` | 否 | 业务动作 subtype | | `ai_task_results[].case_keys` | 否 | 订单关联候选键 | @@ -495,7 +495,33 @@ 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 正式契约。 -### 8.3 成功响应 +`informational_message` 结构化任务仅用于历史兼容。新入口如果是纯信息类邮件或无法形成业务素材包,不要提交空数组,也不要生成 `informational_message`;应使用下面的 S000/S999 文本请求体。 + +### 8.3 S000/S999 文本请求体 + +纯信息类邮件: + +```text +S000,mail-20260708-0001 +``` + +无法形成业务素材包: + +```text +S999,mail-20260708-0001 +``` + +字段规则: + +| 片段 | 中文说明 | +| --- | --- | +| `S000` | 纯信息类邮件,不需要形成业务任务。 | +| `S999` | 入口阶段无法形成业务素材包,不需要进入业务执行。 | +| `mail-20260708-0001` | 外部来源消息 ID,对应 SourceMessage Inbox 的 `external_message_id`。 | + +第一版 S000/S999 不在 body 里传 `hotel_id`,后端使用系统默认酒店 `AGENTBUS_DEFAULT_HOTEL_ID` 查询 SourceMessage Inbox。命中后创建 `SOURCE_MESSAGE_ONLY` 只读特殊任务:任务列表可见,订单列表不可见,不允许编辑、确认、转换订单、执行 OPERA 或重试 OPERA,也不参与同订单任务执行顺序阻塞。 + +### 8.4 成功响应 ```json { @@ -522,6 +548,33 @@ SuperAgent 不应知道或依赖内部 SourceMessage Inbox ID。任务结果通 } ``` +S000/S999 成功响应示例: + +```json +{ + "request_id": "req-004", + "source_message_id": "mail-20260708-0001", + "batch_id": "1900000000000000500", + "idempotent_replay": false, + "accepted_count": 1, + "items": [ + { + "source_event_index": 1, + "array_index": 1, + "ai_transition_id": "1900000000000000550", + "order_id": "1900000000000000600", + "task_id": "1900000000000000700", + "system_task_type": "SOURCE_MESSAGE_ONLY", + "task_card_type": "SOURCE_MESSAGE_ONLY", + "task_status": "COMPLETED", + "order_status": "TEMPORARY", + "execution_order": 1 + } + ], + "warnings": [] +} +``` + ## 9. 错误响应 ### 9.1 查询接口错误响应 @@ -562,13 +615,13 @@ SuperAgent 不应知道或依赖内部 SourceMessage Inbox ID。任务结果通 | `AUTH_SIGNATURE_INVALID` | 401 | 签名不匹配或服务端未配置 secret | | `AUTH_NONCE_REPLAY` | 409 | nonce 已被使用 | | `REQUEST_BODY_TOO_LARGE` | 413 | 请求体超过大小限制 | -| `REQUEST_CONTENT_TYPE_UNSUPPORTED` | 415 | `Content-Type` 不是 `application/json` | -| `REQUEST_BODY_INVALID` | 400 | JSON 不合法 | +| `REQUEST_CONTENT_TYPE_UNSUPPORTED` | 415 | 查询接口 `Content-Type` 不是 `application/json`,或任务结果通知接口不是 `application/json` / `text/plain` | +| `REQUEST_BODY_INVALID` | 400 | JSON 不合法,或 S000/S999 文本格式不符合 `结果码,source_message_id` | | `QUERY_KEY_REQUIRED` | 400 | 查询接口缺少可用业务 key | | `MESSAGE_CONVERSATION_QUERY_KEY_REQUIRED` | 400 | 会话查询缺少 `external_conversation_id` 或 `source_message_id` | | `OBJECT_NOT_FOUND` | 404 | 对象详情查询目标不存在 | | `MESSAGE_CONVERSATION_NOT_FOUND` | 404 | 外部邮件会话尚未写入 SourceMessage Inbox | -| `HOTEL_ID_REQUIRED` | 400 | 五个 SuperAgent 接口缺少必填 `hotel_id` | +| `HOTEL_ID_REQUIRED` | 400 | 查询接口和 JSON 任务结果缺少必填 `hotel_id`;S000/S999 使用系统默认酒店 | | `SOURCE_MESSAGE_NOT_FOUND` | 404 | 任务结果通知或会话锚点引用的外部来源消息尚未写入 SourceMessage Inbox | ## 10. HMAC 上线配置 diff --git a/docs/project/requirements/M002-order-task-workflow-v2.md b/docs/project/requirements/M002-order-task-workflow-v2.md index 3a542cc..8c8251f 100644 --- a/docs/project/requirements/M002-order-task-workflow-v2.md +++ b/docs/project/requirements/M002-order-task-workflow-v2.md @@ -4,8 +4,8 @@ | 项目 | 内容 | | --- | --- | -| 文档版本 | 0.2 | -| 日期 | 2026-07-07 | +| 文档版本 | 0.3 | +| 日期 | 2026-07-10 | | 状态 | 第二版需求与后端阶段实现记录 | | 适用范围 | SourceMessage 之后的 AI 过渡层、订单挂靠、任务卡、人工确认、OPERA 模拟操作主流程 | | 主要读者 | 产品、后端、前端、测试、后续协作 agent | @@ -14,17 +14,18 @@ 本文是 `M002-order-task-workflow-v1.md` 的第二版修正,目标是把本项目已经讨论确认的订单任务主流程,与 2026-07-06 导入的 AI 任务卡契约对齐。 -本文定义业务边界、数据语义和后续实现约束。当前后端已经按拆分 checkpoint 实现了 AI 结果接收、订单任务基础流转、任务草稿保存、最终确认、审计列表、OPERA 模拟骨架、SuperAgent 查询上下文接口 1、2 的最小字段版,以及前端 P0 任务列表 / 订单详情查询接口;前端页面、真实 OPERA、普通任务切换订单、查询接口 3 和查询接口 4 仍未实现。 +本文定义业务边界、数据语义和后续实现约束。当前后端已经按拆分 checkpoint 实现了 AI 结果接收、S000/S999 特殊入口结果、订单任务基础流转、任务草稿保存、最终确认、审计列表、OPERA 模拟骨架、SuperAgent 查询上下文接口 1、2 的最小字段版,以及前端 P0 任务列表 / 订单详情查询接口;前端页面、真实 OPERA、普通任务切换订单、查询接口 3 和查询接口 4 仍未实现。 ## 2. 本版核心修正 相对 V1,本版有以下修正: - 增加 `AI 过渡层`:系统必须先保存 AI 原始输出,再生成订单、任务和任务卡。 -- 不再使用 `EXCEPTION` 作为底层结果类型,AI 第一版结果类型统一以 `normal_task`、`manual_review`、`informational_message` 为准。 -- 系统主任务类型收敛为 `NEW_BOOKING`、`UPDATE_BOOKING`、`CANCEL_BOOKING`、`MANUAL_REVIEW`,另保留 `INFORMATIONAL_MESSAGE` 作为不参与执行队列的只读提醒任务。 +- 不再使用 `EXCEPTION` 作为底层结果类型,SuperAgent 结构化 JSON 新入口只使用 `normal_task` 和 `manual_review`;`informational_message` 仅保留历史兼容。 +- 新增 SuperAgent 纯文本入口结果 `S000,source_message_id` 和 `S999,source_message_id`:两者都生成只读 `SOURCE_MESSAGE_ONLY` 特殊任务,任务列表可见,订单列表不可见。 +- 系统主任务类型收敛为 `NEW_BOOKING`、`UPDATE_BOOKING`、`CANCEL_BOOKING`、`MANUAL_REVIEW`,另保留历史 `INFORMATIONAL_MESSAGE`,并新增 `SOURCE_MESSAGE_ONLY` 用于 S000/S999 特殊入口结果。 - 除 `New Booking`、`Cancel Booking`、`Fallback/manual_review` 外,其他业务处理类卡片原则上都归到 `UPDATE_BOOKING` 下的不同任务卡。 -- `Message Notification` 也挂到临时订单下面,便于归档和详情查看,但不参与订单任务执行队列,不阻塞其他任务,也不被其他任务阻塞。 +- `Message Notification` / `INFORMATIONAL_MESSAGE` 只作为历史兼容路径;新数据中的纯信息类邮件由 `S000` 表达。 - 任务卡后端完整规则以 `任务卡展示编辑矩阵.xlsx` 为权威来源,不在代码里随意扩展或重命名字段路径。 - 前端展示 / 编辑白名单以最新导入的 `任务卡前端展示字段表 3.0.xlsx` 为准;该文件只约束前端展示和可编辑范围,不替代后端完整校验、确认写入和 OPERA 映射规则。 - 系统必须同时保存 AI 原始 `task_type`、系统主任务类型、任务卡类型和业务动作 subtype。 @@ -69,16 +70,27 @@ AgentBus / 未来其他入口 → 系统保存每条模拟操作结果、失败原因和重试记录 ``` +特殊分支: + +```text +SuperAgent 返回 S000,source_message_id 或 S999,source_message_id +→ 本系统按默认酒店 + external_message_id 反查 SourceMessage Inbox +→ 创建隐藏技术订单 + SOURCE_MESSAGE_ONLY 只读任务 +→ 任务列表可见,订单列表不可见 +→ 不允许编辑、确认、转换、OPERA 模拟或重试 +``` + 中文说明: - `SourceMessage Inbox` 是来源事实层,不表达订单、任务或业务结论。 - AI 只输出 JSON,不直接写数据库、不直接创建真实任务卡、不直接写 OPERA。 - 本系统负责保存 AI 原始 JSON、创建任务卡、接收用户修改、生成确认后的 payload、执行 OPERA 模拟并记录结果。 - SuperAgent 不直连本系统数据库,只能通过本系统后端接口查询已有订单和任务上下文。 +- S000/S999 不是业务任务,也不是人工复核任务,只是来源邮件入口处理结论。 ## 5. AI 输出接收边界 -导入文档已经定义了 SuperAgent / Main Agent 应输出的核心业务结构,但还没有定义完整 HTTP 接口契约。 +导入文档已经定义了 SuperAgent / Main Agent 应输出的核心业务结构。当前正式 HTTP 接口同时支持结构化 JSON 和 S000/S999 纯文本入口结果。 AI 聚合输出的顶层结构应包含: @@ -88,14 +100,14 @@ AI 聚合输出的顶层结构应包含: | `ai_task_results[]` | AI 拆分出的一个或多个任务结果,顺序必须保留 | | `extraction_warnings[]` | 抽取警告,不直接等同于业务任务 | -`ai_task_results[]` 中关键字段包括: +结构化 JSON 的 `ai_task_results[]` 中关键字段包括: | 字段 | 中文说明 | | --- | --- | | `source_event_index` | AI 拆分出的 current 事件序号,同一次来源消息内用于排序 | | `catalog_code` | Skill 目录代码,例如 S01、S02 | | `skill_id` | 具体 Skill 标识 | -| `result_type` | AI 结果类型,第一版只使用 `normal_task`、`manual_review`、`informational_message` | +| `result_type` | AI 结果类型,新入口只使用 `normal_task`、`manual_review`;`informational_message` 仅历史兼容 | | `task_type` | AI 原始任务类型,例如 `New Booking`、`Voucher Received` | | `task_subtype` / 业务动作 | 更细的业务动作,用于任务卡字段路由 | | `current_or_history` | 当前事件还是历史证据 | @@ -106,12 +118,27 @@ AI 聚合输出的顶层结构应包含: | `context_used` | AI 使用的上下文摘要 | | `extracted_fields` | 业务字段主体 | | `manual_review` | 人工复核结构化原因和证据 | -| `informational_message` | 信息提醒内容 | +| `informational_message` | 信息提醒内容,仅历史兼容 | | `additional_operations` | AI 建议的附加动作 | | `idempotency_key` | 幂等键,最终来源和生成规则待确认 | HTTP 契约已在 `docs/project/requirements/M002-superagent-task-result-api-contract.md` 和 `docs/project/integrations/superagent-api-contract.md` 中补充。正式契约要求任务结果通知携带 `hotel_id + source_message_id`,其中 `source_message_id` 是外部来源消息 ID;后端会反查内部 SourceMessage Inbox ID 后再写入 `workflow_*` 表。 +纯文本入口结果格式: + +```text +S000,source_message_id +S999,source_message_id +``` + +中文说明: + +- `S000` 表示纯信息类邮件,不形成业务素材包。 +- `S999` 表示入口阶段无法形成业务素材包。 +- `source_message_id` 是外部来源消息 ID,对应 AgentBus `source.external_message_id`。 +- 第一版使用系统默认酒店反查 SourceMessage Inbox。 +- 命中 SourceMessage 后创建 `SOURCE_MESSAGE_ONLY` 只读任务,并挂到 `order_visibility=HIDDEN_SYSTEM` 的技术订单下。 + ## 6. AI 过渡层数据要求 系统接收 AI 输出后,应先写入 AI 过渡层,再创建任务卡。过渡层用于追溯、幂等、筛选、人工确认和 OPERA 参数组装。 @@ -124,7 +151,7 @@ HTTP 契约已在 `docs/project/requirements/M002-superagent-task-result-api-con | `case_keys_json` | AI 给出的订单关联候选键 | | `extracted_fields_json` | AI 抽取的业务字段 | | `manual_review_json` | 人工复核信息 | -| `informational_message_json` | 信息提醒内容 | +| `informational_message_json` | 信息提醒内容,仅历史兼容 | | `attachments_json` | 附件和文件引用 | | `context_used_json` | AI 使用的上下文 | | `confirmed_payload_json` | 用户修改并确认后的最终参数 | @@ -137,9 +164,9 @@ HTTP 契约已在 `docs/project/requirements/M002-superagent-task-result-api-con | `source_event_index` | AI 事件序号 | | `catalog_code` | Skill 目录代码 | | `skill_id` | Skill 标识 | -| `result_type` | AI 结果类型 | +| `result_type` | AI 结果类型;S000/S999 内部保存为 `source_message_only` | | `ai_task_type` | AI 原始任务类型 | -| `system_task_type` | 系统主任务类型 | +| `system_task_type` | 系统主任务类型,S000/S999 为 `SOURCE_MESSAGE_ONLY` | | `task_card_type` | 任务卡类型 | | `task_subtype` | 业务动作 subtype | | `current_or_history` | 当前或历史标识 | @@ -159,15 +186,17 @@ HTTP 契约已在 `docs/project/requirements/M002-superagent-task-result-api-con ### 7.1 AI 结果类型 -AI 第一版结果类型只接受: +AI 新入口结构化 JSON 结果类型只接受: | `result_type` | 中文说明 | 是否可直接 OPERA 模拟 | | --- | --- | --- | | `normal_task` | 可生成业务任务卡的正常任务 | 需用户确认后才允许 | | `manual_review` | 需要人工复核的结构化任务 | 不允许直接写 OPERA | -| `informational_message` | 只读信息提醒 | 不允许写 OPERA | +| `informational_message` | 只读信息提醒,仅历史兼容 | 不允许写 OPERA | -不使用 `exception_task` 和 `no_action`。原来讨论中的异常任务,在本版统一落为 `manual_review`;没有业务动作的信息提醒,统一落为 `informational_message`。 +不使用 `exception_task` 和 `no_action`。原来讨论中的异常任务,在本版统一落为 `manual_review`;没有业务动作的信息类邮件,新入口不再生成 `informational_message` JSON,而是返回 `S000,source_message_id`。 + +S000/S999 不属于结构化 `result_type`,它们是纯文本入口结果,系统内部保存为 `source_message_only` 过渡记录和 `SOURCE_MESSAGE_ONLY` 只读任务。 ### 7.2 系统主任务类型 @@ -179,7 +208,8 @@ AI 第一版结果类型只接受: | `UPDATE_BOOKING` | 更新订单任务,下面承载多个不同任务卡 | | `CANCEL_BOOKING` | 取消订单任务 | | `MANUAL_REVIEW` | 人工复核或 Fallback 任务 | -| `INFORMATIONAL_MESSAGE` | 信息提醒任务,只归档展示,不参与执行队列 | +| `INFORMATIONAL_MESSAGE` | 信息提醒任务,只归档展示,不参与执行队列,仅历史兼容 | +| `SOURCE_MESSAGE_ONLY` | S000/S999 来源消息入口结果任务,只读展示,不参与执行队列 | ### 7.3 AI 任务类型到系统任务和卡片的映射 @@ -193,16 +223,38 @@ AI 第一版结果类型只接受: | `Trace / Reservation Notes` | `UPDATE_BOOKING` | `TRACE_RESERVATION_NOTES` | Trace 或备注卡,可作为联动任务 | | `TA Recorder` | `UPDATE_BOOKING` | `TA_RECORDER` | TA Recorder 维护卡,通常由 Rooming List 派生 | | `Cancel Booking` | `CANCEL_BOOKING` | `CANCEL_BOOKING` | 取消 FIT、Group Block、Allotment / Control Block | -| `Message Notification` | `INFORMATIONAL_MESSAGE` | `MESSAGE_NOTIFICATION` | 挂临时订单,只读展示,不参与执行队列 | +| `Message Notification` | `INFORMATIONAL_MESSAGE` | `MESSAGE_NOTIFICATION` | 历史兼容路径;新纯信息类邮件改用 S000 | | `Fallback` | `MANUAL_REVIEW` | `FALLBACK_REVIEW` | 人工复核任务 | +| `S000` 文本结果 | `SOURCE_MESSAGE_ONLY` | `SOURCE_MESSAGE_ONLY` | 纯信息类邮件,任务列表可见,订单列表不可见 | +| `S999` 文本结果 | `SOURCE_MESSAGE_ONLY` | `SOURCE_MESSAGE_ONLY` | 无法形成业务素材包,任务列表可见,订单列表不可见 | 系统不得丢弃 AI 原始 `task_type`。任务落库时应同时保存 AI 原始任务类型、系统主任务类型、任务卡类型和业务动作 subtype。 -## 8. Message Notification 处理规则 +## 8. S000/S999 和历史 Message Notification 处理规则 + +### 8.1 S000/S999 处理规则 + +`S000` / `S999` 是 SuperAgent 入口阶段返回的纯文本结果,不是结构化任务 JSON。 + +本系统处理规则: + +- `S000` 表示纯信息类邮件。 +- `S999` 表示无法形成业务素材包。 +- 两者都按外部 `source_message_id` 反查 SourceMessage Inbox。 +- 第一版使用系统默认酒店,不要求请求体携带 `hotel_id`。 +- 创建 `SOURCE_MESSAGE_ONLY` 只读任务,`task_subtype` 分别为 `S000` 或 `S999`。 +- 创建 `order_visibility=HIDDEN_SYSTEM` 的技术订单,仅用于满足任务归属,不进入订单列表和订单详情。 +- 任务状态为 `COMPLETED`,`queue_participation=false`。 +- 任务列表可见;订单列表不可见。 +- 任务详情可查看来源邮件、会话、附件和 SuperAgent 原始返回。 +- 不允许保存草稿、最终确认、人工转换、普通切换订单、执行 OPERA 模拟或重试 OPERA。 +- 不阻塞任何订单任务,也不被任何订单任务阻塞。 + +### 8.2 历史 Message Notification 处理规则 `Message Notification` 用于感谢、知会、已收到、转发说明、信息同步等没有明确业务执行动作的消息。 -本系统处理规则: +该路径仅为历史兼容,新数据应由 SuperAgent 返回 `S000,source_message_id`。历史数据处理规则: - 创建临时订单作为归档容器。 - 创建只读信息提醒任务卡。 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 34ae3e3..cf73d21 100644 --- a/docs/project/requirements/M002-superagent-task-result-api-contract.md +++ b/docs/project/requirements/M002-superagent-task-result-api-contract.md @@ -4,15 +4,15 @@ | 项目 | 内容 | | --- | --- | -| 文档版本 | 0.2 | -| 日期 | 2026-07-07 | +| 文档版本 | 0.3 | +| 日期 | 2026-07-10 | | 状态 | 后端接口契约草稿 | | 适用范围 | SuperAgent / Main Agent 调用本系统提交 AI 任务结果 | | 主要读者 | 后端、SuperAgent 对接方、测试、后续协作 agent | ## 1. 文档定位 -本文定义 SuperAgent / Main Agent 向本系统提交 `ai_task_results[]` 的第一版后端接口契约。 +本文定义 SuperAgent / Main Agent 向本系统提交 `ai_task_results[]` 结构化结果,以及提交 S000/S999 特殊入口结果的后端接口契约。 本文承接 `M002-order-task-workflow-v2.md`,只定义本系统入站接口、鉴权、幂等、请求响应和技术校验边界,不定义 SuperAgent 内部 prompt、Skill 实现、OPERA 真实接口或前端展示细节。 @@ -22,17 +22,19 @@ | --- | --- | | Method | `POST` | | Path | `/api/integrations/superagent/task-results` | -| Content-Type | `application/json` | +| Content-Type | `application/json` 或 `text/plain` | | 响应格式 | `application/json` | | 一次请求范围 | 只能包含一个 `source_message_id` | -| 业务动作 | 接收 AI 结果、写入 AI 过渡层、生成订单 / 任务 / 任务卡 | +| 业务动作 | 接收 AI 结果或入口结果、写入 AI 过渡层、生成订单 / 任务 / 任务卡 | | 鉴权方式 | HMAC-SHA256 签名 | 中文说明: - 该接口是服务到服务的入站接口,不给前端直接调用。 -- SuperAgent 不直连数据库,只能通过本接口提交任务结果。 +- SuperAgent 不直连数据库,只能通过本接口提交任务结果或入口处理结果。 - 本接口只做技术校验和系统接收,不替代用户确认和 OPERA 模拟操作。 +- `application/json` 用于 `normal_task` / `manual_review` 结构化任务。 +- `text/plain` 用于 `S000,source_message_id` / `S999,source_message_id` 特殊入口结果。 ### 2.1 SourceMessage ID 口径 @@ -114,13 +116,16 @@ X-TH-Hotel-SuperAgent-Signature: sha256= 4. 计算原始请求体 SHA-256。 5. 使用 HMAC secret 重新计算签名。 6. 使用常量时间比较签名。 -7. 鉴权通过后再解析 JSON。 +7. 校验 `Content-Type` 是否为 `application/json` 或 `text/plain`,避免不支持的媒体类型消耗 nonce。 +8. 鉴权通过后再按 body 内容解析 JSON 或 S000/S999 文本。 日志和错误响应不得输出 secret、签名原文、完整请求体、邮件正文、附件 URL 或个人敏感信息。 ## 4. 请求体 -请求体沿用 AI 导入文档定义的聚合结构。 +### 4.1 JSON 结构化任务请求体 + +JSON 请求体沿用 AI 导入文档定义的聚合结构。 ```json { @@ -155,7 +160,7 @@ X-TH-Hotel-SuperAgent-Signature: sha256= } ``` -### 4.1 顶层字段 +### 4.2 顶层字段 | 字段 | 是否必填 | 中文说明 | | --- | --- | --- | @@ -166,16 +171,16 @@ X-TH-Hotel-SuperAgent-Signature: sha256= | `ai_task_results[]` | 是 | AI 拆分出的任务结果列表,必须保留数组顺序 | | `extraction_warnings[]` | 否 | 抽取警告;不直接等同于业务任务 | -第一版要求 `ai_task_results[]` 至少包含一条记录。没有业务动作时应由 AI 输出 `Message Notification` 或 `Fallback/manual_review`,而不是提交空数组。 +第一版要求 `ai_task_results[]` 至少包含一条记录。没有业务动作的纯信息类邮件不要提交空数组,也不要新生成 `informational_message`;应改用 `S000,source_message_id` 文本结果。 -### 4.2 `ai_task_results[]` 字段 +### 4.3 `ai_task_results[]` 字段 | 字段 | 是否必填 | 中文说明 | | --- | --- | --- | | `source_event_index` | 是 | AI current 事件序号,建议从 1 开始 | | `catalog_code` | 是 | Skill 目录代码,例如 S01、S02 | | `skill_id` | 是 | Skill 标识 | -| `result_type` | 是 | 只接受 `normal_task`、`manual_review`、`informational_message` | +| `result_type` | 是 | 新入口只接受 `normal_task`、`manual_review`;`informational_message` 仅历史兼容 | | `task_type` | 是 | AI 原始任务类型 | | `task_subtype` | 否 | 业务动作 subtype;有则用于任务卡路由 | | `current_or_history` | 否 | 当前或历史标识 | @@ -186,10 +191,42 @@ X-TH-Hotel-SuperAgent-Signature: sha256= | `context_used` | 否 | AI 使用的上下文 | | `extracted_fields` | 否 | 业务字段主体 | | `manual_review` | 条件必填 | `result_type=manual_review` 时应提供 | -| `informational_message` | 条件必填 | `result_type=informational_message` 时应提供 | +| `informational_message` | 条件必填 | 仅历史兼容;新数据不再生成 | | `additional_operations` | 否 | 附加动作建议 | | `idempotency_key` | 否 | 可忽略;本系统第一版自行生成幂等键 | +### 4.4 S000/S999 文本结果请求体 + +当 SuperAgent 入口阶段没有结构化任务 JSON 时,可以直接提交纯文本 body: + +```text +S000,mail-20260708-0001 +``` + +或: + +```text +S999,mail-20260708-0001 +``` + +字段说明: + +| 片段 | 中文说明 | +| --- | --- | +| `S000` | 纯信息类邮件,不形成业务素材包 | +| `S999` | 入口阶段无法形成业务素材包 | +| `source_message_id` | 逗号后面的值,外部来源消息 ID,对应 AgentBus `source.external_message_id` | + +处理规则: + +- 第一版使用系统默认酒店反查 SourceMessage Inbox,不要求文本 body 携带 `hotel_id`。 +- 后端按 `默认酒店 + AGENTBUS + EMAIL + external_message_id` 查询 SourceMessage。 +- 命中后创建 `SOURCE_MESSAGE_ONLY` 只读任务。 +- 任务列表可见,订单列表不可见。 +- 不允许编辑、确认、转换订单、执行 OPERA 或重试 OPERA。 +- 同一 SourceMessage 重复提交相同文本 body 返回幂等重放。 +- 同一 SourceMessage 已经存在不同 AI 结果请求时返回幂等冲突。 + ## 5. 技术校验边界 本接口只做技术校验,不做业务合法性判断。 @@ -198,12 +235,13 @@ X-TH-Hotel-SuperAgent-Signature: sha256= - 鉴权签名合法。 - 请求体大小不超过限制。 -- JSON 可解析。 -- `hotel_id` 存在。仅本地旧夹具兼容缺少 `hotel_id` 且 `source_message_id` 为内部数字 ID 的调用。 +- JSON body 可解析;S000/S999 文本 body 必须符合 `结果码,source_message_id`。 +- JSON body 中 `hotel_id` 存在。仅本地旧夹具兼容缺少 `hotel_id` 且 `source_message_id` 为内部数字 ID 的调用。 +- S000/S999 文本 body 第一版使用系统默认酒店,不读取 `hotel_id`。 - 顶层只有一个 `source_message_id`。 - `source_message_id` 对应的外部来源消息已经写入 SourceMessage Inbox。 - `ai_task_results[]` 是非空数组。 -- `result_type` 属于允许值。 +- JSON `result_type` 属于允许值。 - `task_type` 属于当前系统可识别的稳定值或可进入 Fallback 处理。 - 同一个请求内 `source_event_index` 和数组顺序可保存。 - 关键字符串长度不超过数据库限制。 @@ -246,6 +284,7 @@ sha256( - 相同请求体重复提交时识别为幂等重放。 - 同一个外部 `source_message_id` 解析到的内部 SourceMessage 如果提交了不同请求体,不会被误认为同一个批次。 +- S000/S999 文本 body 也使用同一批次幂等规则。 ### 6.3 item 幂等键 @@ -296,7 +335,8 @@ sha256( | `case_keys` | `case_keys_json` | 保存订单候选键 | | `extracted_fields` | `extracted_fields_json` | 保存业务字段 | | `manual_review` | `manual_review_json` | 保存人工复核结构 | -| `informational_message` | `informational_message_json` | 保存信息提醒 | +| `informational_message` | `informational_message_json` | 仅历史兼容的信息提醒结构 | +| `S000/S999` 文本结果 | `SOURCE_MESSAGE_ONLY` 只读特殊任务 | 保存入口阶段原始结果,不创建真实业务订单 | 系统主任务类型映射以 `M002-order-task-workflow-v2.md` 为准。 @@ -331,6 +371,35 @@ sha256( } ``` +S000 / S999 文本结果创建成功时,同样返回 `201 Created`。这类结果会创建只读特殊任务和隐藏技术订单,供任务列表展示和任务详情查看来源邮件;该隐藏技术订单不会出现在订单列表。 + +```json +{ + "request_id": "req-20260707-0003", + "source_message_id": "mail-20260708-0001", + "batch_id": "1900000000000005001", + "idempotent_replay": false, + "accepted_count": 1, + "items": [ + { + "source_event_index": 1, + "array_index": 1, + "ai_transition_id": "1900000000000006001", + "order_id": "1900000000000007001", + "task_id": "1900000000000008001", + "system_task_type": "SOURCE_MESSAGE_ONLY", + "task_card_type": "SOURCE_MESSAGE_ONLY", + "task_status": "COMPLETED", + "order_status": "TEMPORARY", + "execution_order": 1 + } + ], + "warnings": [] +} +``` + +前端通过任务详情查看该类任务时,字段列表和 OPERA 操作列表为空,按钮应全部只读;`source_message_only_result` 会返回 `entry_result_code`、`entry_result_meaning`、`entry_result_description`、`entry_result_source_message_id` 和 `raw_answer`。 + ### 8.2 幂等重放 相同请求体重复提交时返回 `200 OK`。 diff --git a/server/src/main/java/cn/nianxx/thhotel/integrations/ai/superagent/control/SuperAgentTaskResultController.java b/server/src/main/java/cn/nianxx/thhotel/integrations/ai/superagent/control/SuperAgentTaskResultController.java index c4b87b3..f9f1200 100644 --- a/server/src/main/java/cn/nianxx/thhotel/integrations/ai/superagent/control/SuperAgentTaskResultController.java +++ b/server/src/main/java/cn/nianxx/thhotel/integrations/ai/superagent/control/SuperAgentTaskResultController.java @@ -4,10 +4,12 @@ import cn.nianxx.thhotel.integrations.ai.superagent.common.request.SuperAgentTas import cn.nianxx.thhotel.integrations.ai.superagent.service.SuperAgentTaskResultSecurityService; import cn.nianxx.thhotel.integrations.ai.superagent.service.impl.SuperAgentTaskResultException; import cn.nianxx.thhotel.integrations.ai.superagent.service.impl.SuperAgentTaskResultProperties; +import cn.nianxx.thhotel.integrations.messaging.agentbus.adapter.AgentBusProperties; import cn.nianxx.thhotel.workflows.reservation.common.result.SuperAgentTaskResultResponse; import cn.nianxx.thhotel.workflows.reservation.service.ReservationAiTaskIntakeService; import java.nio.charset.StandardCharsets; import org.springframework.http.HttpStatus; +import org.springframework.http.InvalidMediaTypeException; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.PostMapping; @@ -27,6 +29,7 @@ public class SuperAgentTaskResultController { private final SuperAgentTaskResultSecurityService securityService; private final SuperAgentTaskResultProperties properties; + private final AgentBusProperties agentBusProperties; private final ReservationAiTaskIntakeService intakeService; /** @@ -35,18 +38,21 @@ public class SuperAgentTaskResultController { public SuperAgentTaskResultController( SuperAgentTaskResultSecurityService securityService, SuperAgentTaskResultProperties properties, + AgentBusProperties agentBusProperties, ReservationAiTaskIntakeService intakeService) { this.securityService = securityService; this.properties = properties; + this.agentBusProperties = agentBusProperties; this.intakeService = intakeService; } /** - * 接收 SuperAgent AI 任务结果,先限制请求体大小,再完成 HMAC 鉴权,最后委托业务服务创建 CP1-3 数据。 + * 接收 SuperAgent AI 任务结果,支持 JSON 和 S000/S999 文本结果;HMAC 始终基于原始请求体校验。 */ - @PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE) + @PostMapping(produces = MediaType.APPLICATION_JSON_VALUE) public ResponseEntity accept( @RequestBody(required = false) String rawBody, + @RequestHeader(name = "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, @@ -54,6 +60,7 @@ public class SuperAgentTaskResultController { @RequestHeader(name = "X-TH-Hotel-Request-Id", required = false) String requestId) { String requestBody = rawBody == null ? "" : rawBody; rejectBodyWhenTooLarge(requestBody); + rejectUnsupportedContentType(contentType); securityService.verify(new SuperAgentTaskResultSecurityRequest( "POST", REQUEST_PATH, @@ -63,11 +70,45 @@ public class SuperAgentTaskResultController { signature, requestBody )); - SuperAgentTaskResultResponse response = intakeService.accept(requestBody, clientId, requestId); + SuperAgentTaskResultResponse response = intakeService.accept( + requestBody, + clientId, + requestId, + agentBusProperties.getCapture().getDefaultHotelId()); HttpStatus status = response.idempotentReplay() ? HttpStatus.OK : HttpStatus.CREATED; return ResponseEntity.status(status).body(response); } + /** + * 仅允许 JSON 结构化任务或 text/plain 入口结果文本,避免其他媒体类型误入业务解析。 + */ + private void rejectUnsupportedContentType(String contentType) { + String rawContentType = contentType == null ? "" : contentType.trim(); + if (rawContentType.isEmpty()) { + throw unsupportedContentType(); + } + MediaType mediaType; + try { + mediaType = MediaType.parseMediaType(rawContentType); + } catch (InvalidMediaTypeException exception) { + throw unsupportedContentType(); + } + if (!MediaType.APPLICATION_JSON.isCompatibleWith(mediaType) + && !MediaType.TEXT_PLAIN.isCompatibleWith(mediaType)) { + throw unsupportedContentType(); + } + } + + /** + * 构造统一的 Content-Type 不支持错误,避免把外部原始 Header 写入响应。 + */ + private SuperAgentTaskResultException unsupportedContentType() { + return new SuperAgentTaskResultException( + HttpStatus.UNSUPPORTED_MEDIA_TYPE, + "REQUEST_CONTENT_TYPE_UNSUPPORTED", + "Content-Type 仅支持 application/json 或 text/plain。"); + } + /** * 在解析 JSON 和校验 Header 之前限制请求体大小,避免超限请求进入后续处理。 */ diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/common/enums/SourceMessageOnlyResultCode.java b/server/src/main/java/cn/nianxx/thhotel/platform/common/enums/SourceMessageOnlyResultCode.java new file mode 100644 index 0000000..049b0ed --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/common/enums/SourceMessageOnlyResultCode.java @@ -0,0 +1,45 @@ +package cn.nianxx.thhotel.platform.common.enums; + +import java.util.Arrays; +import java.util.Optional; + +/** + * SuperAgent 入口阶段非结构化结果码。该结果只说明来源消息处理结论,不代表业务任务。 + */ +public enum SourceMessageOnlyResultCode { + /** 纯信息类邮件,不需要形成业务素材包。 */ + S000("S000", "PURE_INFORMATION", "纯信息类邮件"), + /** 入口问题导致无法形成业务素材包。 */ + S999("S999", "MATERIAL_PACKAGE_UNAVAILABLE", "无法形成业务素材包"); + + private final String code; + private final String meaning; + private final String description; + + SourceMessageOnlyResultCode(String code, String meaning, String description) { + this.code = code; + this.meaning = meaning; + this.description = description; + } + + public String code() { + return code; + } + + public String meaning() { + return meaning; + } + + public String description() { + return description; + } + + /** + * 按 SuperAgent 返回的稳定代码查找枚举,大小写敏感,避免误吞其他文本。 + */ + public static Optional fromCode(String code) { + return Arrays.stream(values()) + .filter(value -> value.code.equals(code)) + .findFirst(); + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/debug/service/impl/DebugEmlSuperAgentRunServiceImpl.java b/server/src/main/java/cn/nianxx/thhotel/platform/debug/service/impl/DebugEmlSuperAgentRunServiceImpl.java index 917b727..86401b5 100644 --- a/server/src/main/java/cn/nianxx/thhotel/platform/debug/service/impl/DebugEmlSuperAgentRunServiceImpl.java +++ b/server/src/main/java/cn/nianxx/thhotel/platform/debug/service/impl/DebugEmlSuperAgentRunServiceImpl.java @@ -9,6 +9,7 @@ import cn.nianxx.thhotel.integrations.storage.aliyunoss.common.result.ObjectStor import cn.nianxx.thhotel.integrations.storage.aliyunoss.service.ObjectStorageService; import cn.nianxx.thhotel.integrations.storage.aliyunoss.service.impl.AliyunOssProperties; import cn.nianxx.thhotel.integrations.storage.aliyunoss.service.impl.ObjectStorageException; +import cn.nianxx.thhotel.platform.common.enums.SourceMessageOnlyResultCode; import cn.nianxx.thhotel.platform.debug.common.dto.DebugEmlSuperAgentRunDraft; import cn.nianxx.thhotel.platform.debug.common.dto.DebugEmlSuperAgentRunStatusUpdate; import cn.nianxx.thhotel.platform.debug.common.dto.DebugEmlSuperAgentRunUpdate; @@ -27,8 +28,11 @@ import cn.nianxx.thhotel.platform.message.service.EmlMessageParseService; import cn.nianxx.thhotel.platform.message.service.SourceMessageCaptureService; import cn.nianxx.thhotel.platform.message.service.SourceMessageHtmlSanitizerService; import cn.nianxx.thhotel.platform.message.service.impl.EmlMessageParseException; +import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import java.net.SocketTimeoutException; import java.net.URLDecoder; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; @@ -47,6 +51,8 @@ import java.util.regex.Pattern; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.client.ResourceAccessException; +import org.springframework.web.client.RestClientResponseException; /** * Debug EML 上传到 SuperAgent 服务实现。编排解析、OSS、SourceMessage 和 SuperAgent 调用。 @@ -139,7 +145,7 @@ public class DebugEmlSuperAgentRunServiceImpl implements DebugEmlSuperAgentRunSe "OSS 上传失败。", exception); } catch (SuperAgentOpenApiException exception) { - markFailed(runId, "SuperAgent 调用失败。", DebugEmlSuperAgentRunStatus.SUPERAGENT_FAILED, nowUtc()); + markFailed(runId, superAgentFailureSummary(exception), DebugEmlSuperAgentRunStatus.SUPERAGENT_FAILED, nowUtc()); throw new DebugEmlSuperAgentException( HttpStatus.BAD_GATEWAY, "SUPERAGENT_OPEN_API_FAILED", @@ -155,6 +161,66 @@ public class DebugEmlSuperAgentRunServiceImpl implements DebugEmlSuperAgentRunSe } } + /** + * 生成 SuperAgent 失败安全摘要,只记录错误类型,不保存响应 body、API Key、正文或附件 URL。 + */ + private String superAgentFailureSummary(SuperAgentOpenApiException exception) { + Throwable rootCause = rootCause(exception); + if (rootCause instanceof RestClientResponseException responseException) { + return "SuperAgent Open API HTTP 调用失败,HTTP 状态:" + responseException.getStatusCode().value() + "。"; + } + if (hasCause(exception, SocketTimeoutException.class)) { + return "SuperAgent Open API 调用超时。"; + } + if (hasCause(exception, ResourceAccessException.class)) { + return "SuperAgent Open API 网络连接失败。"; + } + if (rootCause instanceof JsonProcessingException) { + return "SuperAgent Open API 响应 JSON 解析失败。"; + } + String message = trimToNull(exception.getMessage()); + if (message != null && !"SuperAgent Open API 调用失败。".equals(message)) { + return safeErrorSummary(message); + } + return "SuperAgent Open API 调用失败。"; + } + + /** + * 取最底层异常,便于判断 HTTP、网络和解析失败类型。 + */ + private Throwable rootCause(Throwable throwable) { + Throwable current = throwable; + while (current.getCause() != null) { + current = current.getCause(); + } + return current; + } + + /** + * 判断异常链中是否包含指定类型。 + */ + private boolean hasCause(Throwable throwable, Class causeType) { + Throwable current = throwable; + while (current != null) { + if (causeType.isInstance(current)) { + return true; + } + current = current.getCause(); + } + return false; + } + + /** + * 限制错误摘要长度,避免外部异常消息过长进入调试表。 + */ + private String safeErrorSummary(String message) { + String value = message.trim(); + if (value.length() <= 512) { + return value; + } + return value.substring(0, 512); + } + /** * 执行已创建 runId 的主流程。 */ @@ -534,6 +600,10 @@ public class DebugEmlSuperAgentRunServiceImpl implements DebugEmlSuperAgentRunSe warnings.add("SuperAgent 最终回答为空。"); return null; } + JsonNode sourceMessageOnlyResult = parseSourceMessageOnlyResult(rawAnswer); + if (sourceMessageOnlyResult != null) { + return sourceMessageOnlyResult; + } try { return objectMapper.readTree(rawAnswer); } catch (Exception exception) { @@ -542,6 +612,32 @@ public class DebugEmlSuperAgentRunServiceImpl implements DebugEmlSuperAgentRunSe } } + /** + * 识别 Debug 链路中的 S000/S999 入口结果,避免把可识别文本误报为 JSON 解析失败。 + */ + private JsonNode parseSourceMessageOnlyResult(String rawAnswer) { + String trimmedAnswer = trimToNull(rawAnswer); + if (trimmedAnswer == null) { + return null; + } + int separatorIndex = trimmedAnswer.indexOf(','); + String code = separatorIndex < 0 ? trimmedAnswer : trimmedAnswer.substring(0, separatorIndex).trim(); + SourceMessageOnlyResultCode resultCode = SourceMessageOnlyResultCode.fromCode(code).orElse(null); + if (resultCode == null || separatorIndex < 0) { + return null; + } + String sourceMessageId = trimToNull(trimmedAnswer.substring(separatorIndex + 1)); + if (sourceMessageId == null) { + return null; + } + ObjectNode result = objectMapper.createObjectNode(); + result.put("entry_result_code", resultCode.code()); + result.put("entry_result_source_message_id", sourceMessageId); + result.put("entry_result_meaning", resultCode.meaning()); + result.put("entry_result_description", resultCode.description()); + return result; + } + /** * 标记 Debug 运行失败。 */ diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/ReservationAiQueryOrderSnapshot.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/ReservationAiQueryOrderSnapshot.java index 06f6c01..98020e1 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/ReservationAiQueryOrderSnapshot.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/ReservationAiQueryOrderSnapshot.java @@ -12,6 +12,7 @@ import java.time.LocalDateTime; * @param activeBusinessKey 当前生效业务号 * @param temporaryOrderCode 临时订单号 * @param orderStatus 订单状态 + * @param orderVisibility 订单前端可见性 * @param businessKeySource 业务号来源 * @param displayName 前端和 Skill 可读展示名 * @param sourceMessageId 订单来源 SourceMessage ID @@ -30,6 +31,7 @@ public record ReservationAiQueryOrderSnapshot( String activeBusinessKey, String temporaryOrderCode, String orderStatus, + String orderVisibility, String businessKeySource, String displayName, Long sourceMessageId, diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/ReservationOrderDraft.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/ReservationOrderDraft.java index ddb8041..2817d70 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/ReservationOrderDraft.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/ReservationOrderDraft.java @@ -3,7 +3,20 @@ package cn.nianxx.thhotel.workflows.reservation.common.dto; import java.time.LocalDateTime; /** - * 订单入库草稿。用于创建临时订单或带业务号的有效订单。 + * 订单入库草稿。用于创建临时订单、带业务号的有效订单或隐藏技术订单。 + * + * @param hotelId 酒店上下文 ID + * @param orderKeyType 订单业务号类型 + * @param orderBusinessKey 订单业务号 + * @param activeBusinessKey ACTIVE 状态唯一约束辅助业务号 + * @param temporaryOrderCode 临时订单号 + * @param orderStatus 订单状态 + * @param orderVisibility 订单前端可见性 + * @param businessKeySource 业务号来源 + * @param businessKeyBackfilledAt 业务号回填 UTC 时间 + * @param displayName 前端展示名 + * @param sourceMessageId 来源消息 ID + * @param now 创建和更新时间 */ public record ReservationOrderDraft( String hotelId, @@ -12,6 +25,7 @@ public record ReservationOrderDraft( String activeBusinessKey, String temporaryOrderCode, String orderStatus, + String orderVisibility, String businessKeySource, LocalDateTime businessKeyBackfilledAt, String displayName, diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/ReservationOrderSnapshot.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/ReservationOrderSnapshot.java index e85e9f1..c2f3a05 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/ReservationOrderSnapshot.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/ReservationOrderSnapshot.java @@ -2,6 +2,15 @@ package cn.nianxx.thhotel.workflows.reservation.common.dto; /** * 订单快照。Service 通过快照完成挂靠,不直接使用数据库 Entity。 + * + * @param id 订单主键 + * @param hotelId 酒店上下文 ID + * @param orderKeyType 订单业务号类型 + * @param orderBusinessKey 订单业务号 + * @param activeBusinessKey ACTIVE 状态唯一约束辅助业务号 + * @param temporaryOrderCode 临时订单号 + * @param orderStatus 订单状态 + * @param orderVisibility 订单前端可见性 */ public record ReservationOrderSnapshot( Long id, @@ -10,6 +19,7 @@ public record ReservationOrderSnapshot( String orderBusinessKey, String activeBusinessKey, String temporaryOrderCode, - String orderStatus + String orderStatus, + String orderVisibility ) { } diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/enums/AiResultType.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/enums/AiResultType.java index 0a7d9d6..e795446 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/enums/AiResultType.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/enums/AiResultType.java @@ -1,7 +1,7 @@ package cn.nianxx.thhotel.workflows.reservation.common.enums; /** - * AI 结果类型稳定代码。第一版只允许 normal_task、manual_review 和 informational_message。 + * AI 结构化结果类型稳定代码。新入口只使用 normal_task 和 manual_review,informational_message 仅历史兼容。 */ public enum AiResultType { NORMAL_TASK("normal_task"), diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/enums/ReservationOrderVisibility.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/enums/ReservationOrderVisibility.java new file mode 100644 index 0000000..0c27d52 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/enums/ReservationOrderVisibility.java @@ -0,0 +1,11 @@ +package cn.nianxx.thhotel.workflows.reservation.common.enums; + +/** + * 订单前端可见性。用于区分真实业务订单和仅为系统任务挂靠使用的技术订单。 + */ +public enum ReservationOrderVisibility { + /** 普通业务订单,默认在订单列表和订单详情中可见。 */ + VISIBLE, + /** 系统隐藏订单,仅用于 S000/S999 等来源消息结果任务挂靠。 */ + HIDDEN_SYSTEM +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/enums/ReservationSystemTaskType.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/enums/ReservationSystemTaskType.java index 2a328f3..044b6d1 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/enums/ReservationSystemTaskType.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/enums/ReservationSystemTaskType.java @@ -8,5 +8,6 @@ public enum ReservationSystemTaskType { UPDATE_BOOKING, CANCEL_BOOKING, MANUAL_REVIEW, - INFORMATIONAL_MESSAGE + INFORMATIONAL_MESSAGE, + SOURCE_MESSAGE_ONLY } diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/enums/ReservationTaskCardType.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/enums/ReservationTaskCardType.java index 758b2ce..8b38129 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/enums/ReservationTaskCardType.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/enums/ReservationTaskCardType.java @@ -13,5 +13,6 @@ public enum ReservationTaskCardType { TRACE_RESERVATION_NOTES, TA_RECORDER, MESSAGE_NOTIFICATION, - FALLBACK_REVIEW + FALLBACK_REVIEW, + SOURCE_MESSAGE_ONLY } diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationSourceMessageOnlyResult.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationSourceMessageOnlyResult.java new file mode 100644 index 0000000..8fd8cbb --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationSourceMessageOnlyResult.java @@ -0,0 +1,26 @@ +package cn.nianxx.thhotel.workflows.reservation.common.result; + +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * S000/S999 特殊入口结果详情。仅用于 SOURCE_MESSAGE_ONLY 任务,不承载普通任务 AI 原始 payload。 + * + * @param entryResultCode SuperAgent 入口结果码 + * @param entryResultMeaning 入口结果语义 + * @param entryResultDescription 入口结果中文说明 + * @param entryResultSourceMessageId SuperAgent 回传的外部来源消息 ID + * @param rawAnswer SuperAgent 原始文本返回 + */ +public record ReservationSourceMessageOnlyResult( + @JsonProperty("entry_result_code") + String entryResultCode, + @JsonProperty("entry_result_meaning") + String entryResultMeaning, + @JsonProperty("entry_result_description") + String entryResultDescription, + @JsonProperty("entry_result_source_message_id") + String entryResultSourceMessageId, + @JsonProperty("raw_answer") + String rawAnswer +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationTaskDetailResult.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationTaskDetailResult.java index 3ebd594..e1485bc 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationTaskDetailResult.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationTaskDetailResult.java @@ -21,6 +21,7 @@ import java.util.List; * @param fieldContractVersion 字段矩阵契约版本 * @param draftPayload 草稿 payload * @param confirmedPayload 最终确认 payload + * @param sourceMessageOnlyResult S000/S999 特殊入口结果,普通任务为空 * @param availability 当前可处理状态 * @param fields 按字段矩阵生成的字段列表 * @param operaOperations OPERA 模拟操作列表 @@ -54,6 +55,8 @@ public record ReservationTaskDetailResult( Object draftPayload, @JsonProperty("confirmed_payload") Object confirmedPayload, + @JsonProperty("source_message_only_result") + ReservationSourceMessageOnlyResult sourceMessageOnlyResult, ReservationTaskAvailabilityResult availability, List fields, @JsonProperty("opera_operations") diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/domain/ReservationOrderEntity.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/domain/ReservationOrderEntity.java index 62b9546..73ecdf2 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/domain/ReservationOrderEntity.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/domain/ReservationOrderEntity.java @@ -26,6 +26,8 @@ public class ReservationOrderEntity { private String temporaryOrderCode; /** 订单状态。 */ private String orderStatus; + /** 订单前端可见性,系统隐藏订单不进入订单列表。 */ + private String orderVisibility; /** 业务号来源,例如 AI 候选、用户确认或 OPERA 模拟回填。 */ private String businessKeySource; /** New Booking 成功后回填真实业务号的 UTC 时间。 */ @@ -63,6 +65,8 @@ public class ReservationOrderEntity { public void setTemporaryOrderCode(String temporaryOrderCode) { this.temporaryOrderCode = temporaryOrderCode; } public String getOrderStatus() { return orderStatus; } public void setOrderStatus(String orderStatus) { this.orderStatus = orderStatus; } + public String getOrderVisibility() { return orderVisibility; } + public void setOrderVisibility(String orderVisibility) { this.orderVisibility = orderVisibility; } public String getBusinessKeySource() { return businessKeySource; } public void setBusinessKeySource(String businessKeySource) { this.businessKeySource = businessKeySource; } public LocalDateTime getBusinessKeyBackfilledAt() { return businessKeyBackfilledAt; } diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/repository/MybatisReservationAiWorkflowRepository.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/repository/MybatisReservationAiWorkflowRepository.java index cc58623..bb35051 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/repository/MybatisReservationAiWorkflowRepository.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/repository/MybatisReservationAiWorkflowRepository.java @@ -22,6 +22,7 @@ import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationOrderLi import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationTaskWorkbenchQueryRequest; import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationOrderKeyType; import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationOrderStatus; +import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationOrderVisibility; import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationTaskStatus; import cn.nianxx.thhotel.workflows.reservation.domain.ReservationAiBatchEntity; import cn.nianxx.thhotel.workflows.reservation.domain.ReservationAiTransitionEntity; @@ -192,6 +193,7 @@ public class MybatisReservationAiWorkflowRepository implements ReservationAiWork entity.setActiveBusinessKey(draft.activeBusinessKey()); entity.setTemporaryOrderCode(draft.temporaryOrderCode()); entity.setOrderStatus(draft.orderStatus()); + entity.setOrderVisibility(draft.orderVisibility()); entity.setBusinessKeySource(draft.businessKeySource()); entity.setBusinessKeyBackfilledAt(draft.businessKeyBackfilledAt()); entity.setDisplayName(draft.displayName()); @@ -385,6 +387,7 @@ public class MybatisReservationAiWorkflowRepository implements ReservationAiWork return orderMapper.selectList(Wrappers.lambdaQuery() .eq(ReservationOrderEntity::getHotelId, hotelId) .in(ReservationOrderEntity::getSourceMessageId, sourceMessageIds) + .ne(ReservationOrderEntity::getOrderVisibility, ReservationOrderVisibility.HIDDEN_SYSTEM.name()) .orderByDesc(ReservationOrderEntity::getUpdatedAt)) .stream() .map(this::toAiQueryOrderSnapshot) @@ -404,6 +407,7 @@ public class MybatisReservationAiWorkflowRepository implements ReservationAiWork Page page = orderMapper.selectPage(Page.of(pageNum, pageSize), Wrappers.lambdaQuery() .eq(ReservationOrderEntity::getHotelId, request.hotelId()) + .ne(ReservationOrderEntity::getOrderVisibility, ReservationOrderVisibility.HIDDEN_SYSTEM.name()) .eq(hasText(request.orderStatus()), ReservationOrderEntity::getOrderStatus, trim(request.orderStatus())) @@ -930,7 +934,8 @@ public class MybatisReservationAiWorkflowRepository implements ReservationAiWork entity.getOrderBusinessKey(), entity.getActiveBusinessKey(), entity.getTemporaryOrderCode(), - entity.getOrderStatus()); + entity.getOrderStatus(), + entity.getOrderVisibility()); } /** @@ -945,6 +950,7 @@ public class MybatisReservationAiWorkflowRepository implements ReservationAiWork entity.getActiveBusinessKey(), entity.getTemporaryOrderCode(), entity.getOrderStatus(), + entity.getOrderVisibility(), entity.getBusinessKeySource(), entity.getDisplayName(), entity.getSourceMessageId(), diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/ReservationAiTaskIntakeService.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/ReservationAiTaskIntakeService.java index 52ef73b..fadc6f0 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/ReservationAiTaskIntakeService.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/ReservationAiTaskIntakeService.java @@ -3,7 +3,7 @@ package cn.nianxx.thhotel.workflows.reservation.service; import cn.nianxx.thhotel.workflows.reservation.common.result.SuperAgentTaskResultResponse; /** - * Reservation AI 任务结果接收服务。负责把 SuperAgent JSON 转换为 AI 过渡层、订单、任务和任务卡。 + * Reservation AI 任务结果接收服务。负责把 SuperAgent JSON 或入口结果文本转换为内部可追溯任务。 */ public interface ReservationAiTaskIntakeService { @@ -11,4 +11,9 @@ public interface ReservationAiTaskIntakeService { * 接收已通过鉴权的 SuperAgent 原始请求体,并完成 CP1-3 范围内的持久化和任务创建。 */ SuperAgentTaskResultResponse accept(String rawBody, String clientId, String requestId); + + /** + * 接收已通过鉴权的 SuperAgent 原始请求体;defaultHotelId 仅用于 S000/S999 文本结果反查 SourceMessage。 + */ + SuperAgentTaskResultResponse accept(String rawBody, String clientId, String requestId, String defaultHotelId); } diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/JsonReservationTaskCardFieldDefinitionProvider.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/JsonReservationTaskCardFieldDefinitionProvider.java index 2e8a3e1..1c37580 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/JsonReservationTaskCardFieldDefinitionProvider.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/JsonReservationTaskCardFieldDefinitionProvider.java @@ -30,7 +30,8 @@ public class JsonReservationTaskCardFieldDefinitionProvider implements Reservati Map.entry(ReservationTaskCardType.TRACE_RESERVATION_NOTES.name(), "Trace / Reservation Notes 卡"), Map.entry(ReservationTaskCardType.TA_RECORDER.name(), "TA Recorder 卡"), Map.entry(ReservationTaskCardType.MESSAGE_NOTIFICATION.name(), "Message Notification 信息提醒卡"), - Map.entry(ReservationTaskCardType.FALLBACK_REVIEW.name(), "Fallback 人工复核卡") + Map.entry(ReservationTaskCardType.FALLBACK_REVIEW.name(), "Fallback 人工复核卡"), + Map.entry(ReservationTaskCardType.SOURCE_MESSAGE_ONLY.name(), "Source Message Only 只读卡") ); private final ObjectMapper objectMapper; 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 fa0468e..7a11b24 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 @@ -2,6 +2,7 @@ package cn.nianxx.thhotel.workflows.reservation.service.impl; import cn.nianxx.thhotel.platform.message.common.dto.SourceMessageInboxSnapshot; import cn.nianxx.thhotel.platform.message.repository.SourceMessageInboxRepository; +import cn.nianxx.thhotel.platform.common.enums.SourceMessageOnlyResultCode; import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationAiBatchDraft; import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationAiBatchSnapshot; import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationAiTransitionDraft; @@ -13,6 +14,7 @@ import cn.nianxx.thhotel.workflows.reservation.common.enums.AiResultType; import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationBusinessKeySource; import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationOrderKeyType; import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationOrderStatus; +import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationOrderVisibility; import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationSystemTaskType; import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationTaskCardType; import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationTaskStatus; @@ -23,6 +25,7 @@ import cn.nianxx.thhotel.workflows.reservation.repository.ReservationAiWorkflowR import cn.nianxx.thhotel.workflows.reservation.service.ReservationAiTaskIntakeService; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; @@ -45,6 +48,9 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta private static final String FIELD_CONTRACT_VERSION = "code-v1"; private static final String BATCH_KEY_PREFIX = "superagent-task-result-batch:v1"; private static final String ITEM_KEY_PREFIX = "superagent-task-result-item:v1"; + private static final String SOURCE_MESSAGE_ONLY_RESULT_TYPE = "source_message_only"; + private static final String SOURCE_MESSAGE_ONLY_CATALOG_CODE = "ENTRY_RESULT"; + private static final String SOURCE_MESSAGE_ONLY_SKILL_ID = "superagent_entry_router"; private static final String DEFAULT_SOURCE_PROVIDER = "AGENTBUS"; private static final String DEFAULT_SOURCE_CHANNEL = "EMAIL"; private static final int LENGTH_32 = 32; @@ -75,6 +81,25 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta @Override @Transactional public SuperAgentTaskResultResponse accept(String rawBody, String clientId, String requestId) { + return accept(rawBody, clientId, requestId, null); + } + + /** + * 接收已通过 HMAC 鉴权的 SuperAgent 任务结果。S000/S999 文本结果使用默认酒店反查来源邮件。 + */ + @Override + @Transactional + public SuperAgentTaskResultResponse accept(String rawBody, String clientId, String requestId, String defaultHotelId) { + String requestBody = rawBody == null ? "" : rawBody; + SourceMessageOnlyEntryResult sourceMessageOnlyEntryResult = parseSourceMessageOnlyEntryResult(requestBody); + if (sourceMessageOnlyEntryResult != null) { + return acceptSourceMessageOnlyEntryResult( + sourceMessageOnlyEntryResult, + clientId, + requestId, + defaultHotelId, + requestBody); + } JsonNode root = parseJson(rawBody); ResolvedSourceMessage resolvedSourceMessage = resolveSourceMessage(root); SourceMessageInboxSnapshot sourceMessage = resolvedSourceMessage.snapshot(); @@ -136,6 +161,183 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta ); } + /** + * 接收 S000/S999 文本入口结果,创建只读来源消息任务,并隐藏其技术订单。 + */ + private SuperAgentTaskResultResponse acceptSourceMessageOnlyEntryResult( + SourceMessageOnlyEntryResult entryResult, + String clientId, + String requestId, + String defaultHotelId, + String rawBody) { + String hotelId = requireText(defaultHotelId, "default_hotel_id", LENGTH_64); + SourceMessageInboxSnapshot sourceMessage = sourceMessageInboxRepository + .findByIdempotencyKey( + hotelId, + DEFAULT_SOURCE_PROVIDER, + DEFAULT_SOURCE_CHANNEL, + entryResult.externalSourceMessageId()) + .orElseThrow(() -> error(HttpStatus.NOT_FOUND, "SOURCE_MESSAGE_NOT_FOUND", "SourceMessage 不存在。")); + String requestPayloadSha256 = sha256(rawBody == null ? "" : rawBody); + String batchIdempotencyKey = sha256(BATCH_KEY_PREFIX + "|" + sourceMessage.id() + "|" + requestPayloadSha256); + ReservationAiBatchSnapshot existingBatch = workflowRepository + .findBatchBySourceMessageId(hotelId, sourceMessage.id()) + .orElse(null); + if (existingBatch != null) { + return handleExistingBatch( + requestId, + entryResult.externalSourceMessageId(), + requestPayloadSha256, + existingBatch); + } + + LocalDateTime now = nowUtc(); + String safeRequestId = optionalText(requestId, "request_id", LENGTH_128); + ReservationAiBatchDraft batchDraft = new ReservationAiBatchDraft( + hotelId, + sourceMessage.id(), + requestPayloadSha256, + batchIdempotencyKey, + requireText(clientId, "clientId", LENGTH_128), + safeRequestId, + now, + 1, + null + ); + Long batchId = insertBatchOrReplay(batchDraft); + if (batchId == null) { + return handleExistingBatch( + requestId, + entryResult.externalSourceMessageId(), + requestPayloadSha256, + workflowRepository.findBatchBySourceMessageId(hotelId, sourceMessage.id()) + .orElseThrow(() -> error(HttpStatus.CONFLICT, "IDEMPOTENCY_CONFLICT", "AI 批次并发写入状态不确定。"))); + } + + SuperAgentTaskResultItemResponse item = createSourceMessageOnlyItem( + hotelId, + sourceMessage.id(), + batchId, + entryResult, + rawBody, + now); + return new SuperAgentTaskResultResponse( + safeRequestId, + entryResult.externalSourceMessageId(), + batchId.toString(), + false, + 1, + List.of(item), + List.of()); + } + + /** + * 创建 S000/S999 对应的只读任务。该任务只用于任务列表和详情展示,不参与订单执行队列。 + */ + private SuperAgentTaskResultItemResponse createSourceMessageOnlyItem( + String hotelId, + Long sourceMessageId, + Long batchId, + SourceMessageOnlyEntryResult entryResult, + String rawBody, + LocalDateTime now) { + ObjectNode itemPayload = objectMapper.createObjectNode(); + itemPayload.put("entry_result_code", entryResult.resultCode().code()); + itemPayload.put("entry_result_meaning", entryResult.resultCode().meaning()); + itemPayload.put("entry_result_description", entryResult.resultCode().description()); + itemPayload.put("source_message_id", entryResult.externalSourceMessageId()); + itemPayload.put("raw_answer", rawBody); + String itemPayloadJson = nodeJson(itemPayload); + String itemPayloadSha256 = sha256(itemPayloadJson); + String itemIdempotencyKey = sha256(ITEM_KEY_PREFIX + + "|" + sourceMessageId + + "|1|1|" + + SOURCE_MESSAGE_ONLY_CATALOG_CODE + + "|" + + SOURCE_MESSAGE_ONLY_SKILL_ID + + "|" + + SOURCE_MESSAGE_ONLY_RESULT_TYPE + + "|" + + entryResult.resultCode().code() + + "|" + + entryResult.resultCode().code() + + "|" + + itemPayloadSha256); + Long transitionId = workflowRepository.insertTransition(new ReservationAiTransitionDraft( + hotelId, + batchId, + sourceMessageId, + 1, + 1, + 1, + SOURCE_MESSAGE_ONLY_CATALOG_CODE, + SOURCE_MESSAGE_ONLY_SKILL_ID, + SOURCE_MESSAGE_ONLY_RESULT_TYPE, + entryResult.resultCode().code(), + ReservationSystemTaskType.SOURCE_MESSAGE_ONLY.name(), + ReservationTaskCardType.SOURCE_MESSAGE_ONLY.name(), + entryResult.resultCode().code(), + null, + null, + null, + itemPayloadSha256, + itemIdempotencyKey, + null, + null, + null, + Boolean.FALSE, + itemPayloadJson, + null, + null, + null, + null, + null, + null, + now + )); + ReservationOrderSnapshot order = createHiddenSourceMessageOnlyOrder(hotelId, sourceMessageId, now); + TaskTypeMapping mapping = new TaskTypeMapping( + ReservationSystemTaskType.SOURCE_MESSAGE_ONLY, + ReservationTaskCardType.SOURCE_MESSAGE_ONLY); + TaskCreation taskCreation = insertTaskWithQueueRetry( + hotelId, + sourceMessageId, + transitionId, + SOURCE_MESSAGE_ONLY_RESULT_TYPE, + entryResult.resultCode().code(), + mapping, + entryResult.resultCode().code(), + ReservationTaskStatus.COMPLETED.name(), + false, + null, + null, + Boolean.FALSE, + now, + order, + now + ); + workflowRepository.insertTaskCard(new ReservationTaskCardDraft( + hotelId, + taskCreation.taskId(), + ReservationTaskCardType.SOURCE_MESSAGE_ONLY.name(), + FIELD_CONTRACT_VERSION, + itemPayloadJson, + now + )); + return new SuperAgentTaskResultItemResponse( + 1, + 1, + transitionId.toString(), + order.id().toString(), + taskCreation.taskId().toString(), + ReservationSystemTaskType.SOURCE_MESSAGE_ONLY.name(), + ReservationTaskCardType.SOURCE_MESSAGE_ONLY.name(), + ReservationTaskStatus.COMPLETED.name(), + order.orderStatus(), + taskCreation.executionOrder() + ); + } + /** * 插入批次;如果并发请求已经写入同一 SourceMessage,则交由调用方按幂等规则重新读取处理。 */ @@ -395,6 +597,7 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta businessKey.value(), temporaryOrderCode(sourceMessageId, arrayIndex), ReservationOrderStatus.ACTIVE.name(), + ReservationOrderVisibility.VISIBLE.name(), ReservationBusinessKeySource.AI_CANDIDATE.name(), null, businessKey.value(), @@ -424,6 +627,31 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta null, temporaryCode, ReservationOrderStatus.TEMPORARY.name(), + ReservationOrderVisibility.VISIBLE.name(), + null, + null, + temporaryCode, + sourceMessageId, + now + )); + } + + /** + * 创建 S000/S999 专用隐藏技术订单。它只满足任务外键归属,不进入前端订单列表。 + */ + private ReservationOrderSnapshot createHiddenSourceMessageOnlyOrder( + String hotelId, + Long sourceMessageId, + LocalDateTime now) { + String temporaryCode = temporaryOrderCode(sourceMessageId, 1); + return workflowRepository.insertOrder(new ReservationOrderDraft( + hotelId, + ReservationOrderKeyType.TEMPORARY.name(), + null, + null, + temporaryCode, + ReservationOrderStatus.TEMPORARY.name(), + ReservationOrderVisibility.HIDDEN_SYSTEM.name(), null, null, temporaryCode, @@ -476,6 +704,31 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta }; } + /** + * 解析 SuperAgent 入口阶段的纯文本结果。只支持 S000/S999,不把其他文本误判为业务结果。 + */ + private SourceMessageOnlyEntryResult parseSourceMessageOnlyEntryResult(String rawBody) { + String trimmedBody = trimToNull(rawBody); + if (trimmedBody == null) { + return null; + } + int separatorIndex = trimmedBody.indexOf(','); + String code = separatorIndex < 0 ? trimmedBody : trimmedBody.substring(0, separatorIndex).trim(); + SourceMessageOnlyResultCode resultCode = SourceMessageOnlyResultCode.fromCode(code).orElse(null); + if (resultCode == null) { + return null; + } + if (separatorIndex < 0) { + throw error(HttpStatus.BAD_REQUEST, "SOURCE_MESSAGE_REQUIRED", "S000/S999 结果缺少 source_message_id。"); + } + String externalSourceMessageId = trimToNull(trimmedBody.substring(separatorIndex + 1)); + if (externalSourceMessageId == null) { + throw error(HttpStatus.BAD_REQUEST, "SOURCE_MESSAGE_REQUIRED", "S000/S999 结果缺少 source_message_id。"); + } + validateLength(externalSourceMessageId, "source_message_id", LENGTH_256); + return new SourceMessageOnlyEntryResult(resultCode, externalSourceMessageId); + } + /** * 解析请求体 JSON,解析失败返回受控错误,不输出原始请求体。 */ @@ -703,6 +956,15 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta ) { } + /** + * SuperAgent S000/S999 文本结果解析值。 + */ + private record SourceMessageOnlyEntryResult( + SourceMessageOnlyResultCode resultCode, + String externalSourceMessageId + ) { + } + /** * 任务创建结果,包含任务 ID 和最终写入的同订单执行序号。 */ diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationFrontendQueryServiceImpl.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationFrontendQueryServiceImpl.java index 119f39c..d1a8a3e 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationFrontendQueryServiceImpl.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationFrontendQueryServiceImpl.java @@ -8,6 +8,7 @@ import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationAiQueryTask import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationPageSnapshot; import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationTaskSnapshot; import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationOrderKeyType; +import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationOrderVisibility; import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationTaskStatus; import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationOrderListQueryRequest; import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationTaskWorkbenchQueryRequest; @@ -146,6 +147,12 @@ public class ReservationFrontendQueryServiceImpl implements ReservationFrontendQ HttpStatus.NOT_FOUND, "ORDER_NOT_FOUND", "订单不存在。")); + if (isHiddenSystemOrder(order)) { + throw new ReservationTaskWorkflowException( + HttpStatus.NOT_FOUND, + "ORDER_NOT_FOUND", + "订单不存在。"); + } List taskSnapshots = Boolean.FALSE.equals(includeTasks) ? List.of() : workflowRepository.findAiQueryTasksByOrderIds(normalizedHotelId, List.of(order.id())); @@ -316,7 +323,7 @@ public class ReservationFrontendQueryServiceImpl implements ReservationFrontendQ task.orderId().toString(), task.hotelId(), displayOrderKey(order), - order == null ? null : order.temporaryOrderCode(), + order == null || isHiddenSystemOrder(order) ? null : order.temporaryOrderCode(), task.systemTaskType(), task.taskSubtype(), task.taskStatus(), @@ -504,7 +511,7 @@ public class ReservationFrontendQueryServiceImpl implements ReservationFrontendQ * 生成订单展示键,优先真实业务号,其次临时订单号。 */ private String displayOrderKey(ReservationAiQueryOrderSnapshot order) { - if (order == null) { + if (order == null || isHiddenSystemOrder(order)) { return null; } String activeBusinessKey = trimToNull(order.activeBusinessKey()); @@ -519,6 +526,9 @@ public class ReservationFrontendQueryServiceImpl implements ReservationFrontendQ * 从订单快照中提取 Group Code。 */ private String groupCode(ReservationAiQueryOrderSnapshot order) { + if (isHiddenSystemOrder(order)) { + return null; + } if (ReservationOrderKeyType.GROUP_CODE.name().equals(order.orderKeyType())) { return displayOrderKey(order); } @@ -529,12 +539,22 @@ public class ReservationFrontendQueryServiceImpl implements ReservationFrontendQ * 从订单快照中提取 Confirmation No.。 */ private String confirmationNumber(ReservationAiQueryOrderSnapshot order) { + if (isHiddenSystemOrder(order)) { + return null; + } if (ReservationOrderKeyType.CONFIRMATION_NUMBER.name().equals(order.orderKeyType())) { return displayOrderKey(order); } return null; } + /** + * 判断订单是否为系统隐藏技术订单。该类订单只支撑特殊任务挂靠,不进入普通订单体验。 + */ + private boolean isHiddenSystemOrder(ReservationAiQueryOrderSnapshot order) { + return order != null && ReservationOrderVisibility.HIDDEN_SYSTEM.name().equals(order.orderVisibility()); + } + /** * 标准化酒店 ID。第一版未接用户酒店上下文时使用本地默认酒店。 */ diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationTaskWorkflowServiceImpl.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationTaskWorkflowServiceImpl.java index 7e1c5b0..7d39938 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationTaskWorkflowServiceImpl.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationTaskWorkflowServiceImpl.java @@ -26,6 +26,7 @@ import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationTaskPay import cn.nianxx.thhotel.workflows.reservation.common.result.ManualReviewConversionResult; import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationOperaOperationAttemptResult; import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationOperaOperationResult; +import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationSourceMessageOnlyResult; import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationTaskAuditListResult; import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationTaskAuditLogResult; import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationTaskAvailabilityResult; @@ -109,6 +110,7 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl ReservationTaskAvailabilityResult availability = availabilityResolver.calculateAvailability(task); List fields = buildFieldResults(task, taskCard); List operaOperations = findOperaOperationResults(task); + ReservationSourceMessageOnlyResult sourceMessageOnlyResult = buildSourceMessageOnlyResult(task, taskCard); SourceMessageDetailContext sourceContext = findSourceMessageDetailContext(task); return new ReservationTaskDetailResult( task.id().toString(), @@ -125,6 +127,7 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl taskCard.fieldContractVersion(), jsonPayloadToObject(taskCard.draftPayloadJson()), jsonPayloadToObject(taskCard.confirmedPayloadJson()), + sourceMessageOnlyResult, availability, fields, operaOperations); @@ -836,6 +839,24 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl } } + /** + * 只为 S000/S999 特殊任务透出入口结果,避免普通任务详情暴露完整 AI 原始 payload。 + */ + private ReservationSourceMessageOnlyResult buildSourceMessageOnlyResult( + ReservationTaskSnapshot task, + ReservationTaskCardSnapshot taskCard) { + if (!ReservationSystemTaskType.SOURCE_MESSAGE_ONLY.name().equals(task.systemTaskType())) { + return null; + } + JsonNode payload = parseJson(taskCard.aiPayloadJson()); + return new ReservationSourceMessageOnlyResult( + textValue(payload, "entry_result_code"), + textValue(payload, "entry_result_meaning"), + textValue(payload, "entry_result_description"), + textValue(payload, "source_message_id"), + textValue(payload, "raw_answer")); + } + /** * 写入草稿保存或最终确认审计。审计只记录 payload 摘要,不保存完整客户字段。 */ @@ -1339,6 +1360,20 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl } } + /** + * 从 JSON 节点安全读取文本字段,缺失或 null 时返回 null。 + */ + private String textValue(JsonNode node, String fieldName) { + if (node == null || fieldName == null) { + return null; + } + JsonNode valueNode = node.path(fieldName); + if (valueNode.isMissingNode() || valueNode.isNull()) { + return null; + } + return valueNode.asText(); + } + /** * 解析人工转换目标类型,只允许 New、Update 和 Cancel。 */ diff --git a/server/src/main/resources/db/migration/V11__add_reservation_order_visibility.sql b/server/src/main/resources/db/migration/V11__add_reservation_order_visibility.sql new file mode 100644 index 0000000..71f39ad --- /dev/null +++ b/server/src/main/resources/db/migration/V11__add_reservation_order_visibility.sql @@ -0,0 +1,7 @@ +-- M002 S000/S999:订单可见性。技术订单只用于挂靠来源消息结果任务,不进入前端订单列表。 +ALTER TABLE workflow_reservation_order + ADD COLUMN order_visibility VARCHAR(32) NOT NULL DEFAULT 'VISIBLE' COMMENT '订单前端可见性:VISIBLE 普通业务订单,HIDDEN_SYSTEM 系统隐藏技术订单'; + +-- M002 S000/S999:订单列表和按状态查询默认只读取可见业务订单。 +CREATE INDEX idx_reservation_order_visibility_status + ON workflow_reservation_order (hotel_id, order_visibility, order_status, updated_at); diff --git a/server/src/test/java/cn/nianxx/thhotel/platform/debug/control/DebugEmlSuperAgentControllerTest.java b/server/src/test/java/cn/nianxx/thhotel/platform/debug/control/DebugEmlSuperAgentControllerTest.java index 9ac177e..d91f9c7 100644 --- a/server/src/test/java/cn/nianxx/thhotel/platform/debug/control/DebugEmlSuperAgentControllerTest.java +++ b/server/src/test/java/cn/nianxx/thhotel/platform/debug/control/DebugEmlSuperAgentControllerTest.java @@ -31,6 +31,7 @@ import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.mock.web.MockMultipartFile; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.web.servlet.MockMvc; +import org.springframework.web.client.RestClientResponseException; @SpringBootTest( classes = ThHotelApplication.class, @@ -212,6 +213,43 @@ class DebugEmlSuperAgentControllerTest { .andExpect(jsonPath("$.html_render_mode").value("SANITIZED_HTML")); } + @Test + void shouldTreatSuperAgentS000AnswerAsRecognizedEntryResult() throws Exception { + when(objectStorageService.putObject(any())).thenAnswer(invocation -> { + ObjectStoragePutRequest request = invocation.getArgument(0); + return new ObjectStoragePutResult( + request.objectKey(), + "https://oss.example.test/" + request.objectKey(), + request.contentType(), + request.sizeBytes()); + }); + when(superAgentOpenApiClient.invokeMailDebug(any())).thenReturn(new SuperAgentOpenApiResult( + "session-debug-s000", + "run-debug-s000", + "profile-debug", + "profile-version-debug", + "debug-model", + "S000,debug-eml-run-source", + 11, + 3, + 14, + List.of("metadata", "values", "end"))); + + mockMvc.perform(multipart(ENDPOINT) + .file(emlFile()) + .param("hotel_id", "HOTEL-TEST") + .param("run_label", "s000-debug-upload") + .header("X-TH-Hotel-Debug-Upload-Key", "test-debug-upload-key")) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.superagent_raw_answer").value("S000,debug-eml-run-source")) + .andExpect(jsonPath("$.superagent_parsed_json.entry_result_code").value("S000")) + .andExpect(jsonPath("$.superagent_parsed_json.entry_result_source_message_id") + .value("debug-eml-run-source")) + .andExpect(jsonPath("$.superagent_parsed_json.entry_result_meaning").value("PURE_INFORMATION")) + .andExpect(jsonPath("$.status").value("SUPERAGENT_SUCCEEDED")) + .andExpect(content().string(not(containsString("不是合法 JSON")))); + } + @Test void shouldKeepCapturedSourceMessageWhenSuperAgentFails() throws Exception { when(objectStorageService.putObject(any())).thenAnswer(invocation -> { @@ -223,7 +261,15 @@ class DebugEmlSuperAgentControllerTest { request.sizeBytes()); }); when(superAgentOpenApiClient.invokeMailDebug(any())) - .thenThrow(new SuperAgentOpenApiException("SuperAgent Open API 调用失败。")); + .thenThrow(new SuperAgentOpenApiException( + "SuperAgent Open API 调用失败。", + new RestClientResponseException( + "401 Unauthorized", + 401, + "Unauthorized", + null, + new byte[0], + StandardCharsets.UTF_8))); mockMvc.perform(multipart(ENDPOINT) .file(emlFile()) @@ -232,6 +278,7 @@ class DebugEmlSuperAgentControllerTest { .andExpect(status().isBadGateway()) .andExpect(jsonPath("$.error_code").value("SUPERAGENT_OPEN_API_FAILED")) .andExpect(content().string(not(containsString("test-debug-upload-key")))) + .andExpect(content().string(not(containsString("401")))) .andExpect(content().string(not(containsString("Please create booking")))); Long linkedFailedRunCount = jdbcTemplate.queryForObject(""" @@ -241,7 +288,7 @@ class DebugEmlSuperAgentControllerTest { AND run_status = 'SUPERAGENT_FAILED' AND source_message_id IS NOT NULL AND original_eml_oss_url IS NOT NULL - AND safe_error_summary = 'SuperAgent 调用失败。' + AND safe_error_summary = 'SuperAgent Open API HTTP 调用失败,HTTP 状态:401。' """, Long.class); org.assertj.core.api.Assertions.assertThat(linkedFailedRunCount).isEqualTo(1L); } 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 719699f..12cd8f2 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 @@ -446,6 +446,160 @@ class SuperAgentTaskResultControllerTest { org.assertj.core.api.Assertions.assertThat(queueParticipationCount).isEqualTo(1L); } + @Test + void shouldCreateReadOnlySourceMessageOnlyTaskForS000TextResult() throws Exception { + SourceMessageCaptureResult source = captureSourceMessage("mail-s000-entry-result-001"); + String body = "S000,mail-s000-entry-result-001"; + + MvcResult result = mockMvc.perform(signedPlainPost(body, "nonce-s000-entry-result-001")) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.source_message_id").value("mail-s000-entry-result-001")) + .andExpect(jsonPath("$.accepted_count").value(1)) + .andExpect(jsonPath("$.items[0].source_event_index").value(1)) + .andExpect(jsonPath("$.items[0].system_task_type").value("SOURCE_MESSAGE_ONLY")) + .andExpect(jsonPath("$.items[0].task_card_type").value("SOURCE_MESSAGE_ONLY")) + .andExpect(jsonPath("$.items[0].task_status").value("COMPLETED")) + .andExpect(jsonPath("$.items[0].order_status").value("TEMPORARY")) + .andExpect(content().string(not(containsString(SECRET)))) + .andReturn(); + String taskId = com.jayway.jsonpath.JsonPath.read(result.getResponse().getContentAsString(), "$.items[0].task_id"); + String orderId = com.jayway.jsonpath.JsonPath.read(result.getResponse().getContentAsString(), "$.items[0].order_id"); + + mockMvc.perform(get("/api/reservation/tasks/{taskId}", taskId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.system_task_type").value("SOURCE_MESSAGE_ONLY")) + .andExpect(jsonPath("$.task_card_type").value("SOURCE_MESSAGE_ONLY")) + .andExpect(jsonPath("$.task_status").value("COMPLETED")) + .andExpect(jsonPath("$.availability.read_only").value(true)) + .andExpect(jsonPath("$.availability.editable").value(false)) + .andExpect(jsonPath("$.availability.confirmable").value(false)) + .andExpect(jsonPath("$.availability.executable").value(false)) + .andExpect(jsonPath("$.source_message_only_result.entry_result_code").value("S000")) + .andExpect(jsonPath("$.source_message_only_result.entry_result_meaning").value("PURE_INFORMATION")) + .andExpect(jsonPath("$.source_message_only_result.entry_result_source_message_id") + .value("mail-s000-entry-result-001")) + .andExpect(jsonPath("$.source_message_only_result.raw_answer").value(body)) + .andExpect(jsonPath("$.fields.length()").value(0)) + .andExpect(jsonPath("$.opera_operations.length()").value(0)); + + mockMvc.perform(put("/api/reservation/tasks/{taskId}/draft", taskId) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "field_values": {} + } + """)) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.error_code").value("TASK_STATUS_NOT_EDITABLE")); + + mockMvc.perform(post("/api/reservation/tasks/{taskId}/manual-review-conversions", taskId) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "target_task_type": "NEW_BOOKING", + "reason": "特殊入口结果不允许人工转换" + } + """)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error_code").value("TASK_NOT_MANUAL_REVIEW")); + + mockMvc.perform(get("/api/reservation/tasks") + .param("hotel_id", "HOTEL-TEST") + .param("task_type", "SOURCE_MESSAGE_ONLY") + .param("keyword", "mail-s000-entry-result-001")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.items[0].task_id").value(taskId)) + .andExpect(jsonPath("$.items[0].task_type").value("SOURCE_MESSAGE_ONLY")) + .andExpect(jsonPath("$.items[0].card_name").value("SOURCE_MESSAGE_ONLY")) + .andExpect(jsonPath("$.items[0].task_subtype").value("S000")) + .andExpect(jsonPath("$.items[0].queue_participation").value(false)) + .andExpect(jsonPath("$.items[0].can_process").value(false)); + + mockMvc.perform(get("/api/reservation/orders") + .param("hotel_id", "HOTEL-TEST") + .param("keyword", "mail-s000-entry-result-001")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.page.total").value(0)); + + Long hiddenOrderCount = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) + FROM workflow_reservation_order + WHERE id = ? + AND order_visibility = 'HIDDEN_SYSTEM' + """, Long.class, Long.valueOf(orderId)); + Long sourceOnlyTaskCount = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) + FROM workflow_reservation_task + WHERE id = ? + AND source_message_id = ? + AND system_task_type = 'SOURCE_MESSAGE_ONLY' + AND task_card_type = 'SOURCE_MESSAGE_ONLY' + AND task_subtype = 'S000' + AND queue_participation = 0 + AND task_status = 'COMPLETED' + """, Long.class, Long.valueOf(taskId), source.inboxId()); + assertThat(hiddenOrderCount).isEqualTo(1L); + assertThat(sourceOnlyTaskCount).isEqualTo(1L); + } + + @Test + void shouldCreateReadOnlySourceMessageOnlyTaskForS999TextResult() throws Exception { + SourceMessageCaptureResult source = captureSourceMessage("mail-s999-entry-result-001"); + String body = "S999,mail-s999-entry-result-001"; + + MvcResult result = mockMvc.perform(signedPlainPost(body, "nonce-s999-entry-result-001")) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.source_message_id").value("mail-s999-entry-result-001")) + .andExpect(jsonPath("$.items[0].system_task_type").value("SOURCE_MESSAGE_ONLY")) + .andExpect(jsonPath("$.items[0].task_card_type").value("SOURCE_MESSAGE_ONLY")) + .andExpect(jsonPath("$.items[0].task_status").value("COMPLETED")) + .andReturn(); + String taskId = com.jayway.jsonpath.JsonPath.read(result.getResponse().getContentAsString(), "$.items[0].task_id"); + + Long sourceOnlyTaskCount = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) + FROM workflow_reservation_task + WHERE id = ? + AND source_message_id = ? + AND system_task_type = 'SOURCE_MESSAGE_ONLY' + AND task_subtype = 'S999' + AND queue_participation = 0 + AND task_status = 'COMPLETED' + """, Long.class, Long.valueOf(taskId), source.inboxId()); + assertThat(sourceOnlyTaskCount).isEqualTo(1L); + } + + @Test + void shouldReturnIdempotentReplayForSameS000TextResultWithNewNonce() throws Exception { + captureSourceMessage("mail-s000-idempotent-001"); + String body = "S000,mail-s000-idempotent-001"; + + mockMvc.perform(signedPlainPost(body, "nonce-s000-idempotent-001")) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.idempotent_replay").value(false)); + + mockMvc.perform(signedPlainPost(body, "nonce-s000-idempotent-002")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.idempotent_replay").value(true)) + .andExpect(jsonPath("$.warnings[0].code").value("IDEMPOTENT_REPLAY")); + } + + @Test + void shouldRejectTextResultWhenExternalSourceMessageIdNotFound() throws Exception { + mockMvc.perform(signedPlainPost("S000,missing-source-message-001", "nonce-s000-source-missing-001")) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.error_code").value("SOURCE_MESSAGE_NOT_FOUND")); + } + + @Test + void shouldRejectUnsupportedContentTypeForTaskResultCallback() throws Exception { + String body = "S000,source-message-unsupported-content-type-001"; + + mockMvc.perform(signedPostWithContentType(body, "nonce-unsupported-content-type-001", MediaType.APPLICATION_XML)) + .andExpect(status().isUnsupportedMediaType()) + .andExpect(jsonPath("$.error_code").value("REQUEST_CONTENT_TYPE_UNSUPPORTED")); + } + @Test void shouldMarkLaterTaskReadOnlyUntilPreviousQueueTaskIsCompletedOrFailed() throws Exception { SourceMessageCaptureResult source = captureSourceMessage("mail-queue-readonly-001"); @@ -1130,6 +1284,26 @@ class SuperAgentTaskResultControllerTest { return signedPostWithClientId(body, nonce, CLIENT_ID); } + private org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder signedPlainPost( + String body, + String nonce) throws Exception { + return signedPostWithContentType(body, nonce, MediaType.TEXT_PLAIN); + } + + private org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder signedPostWithContentType( + String body, + String nonce, + MediaType contentType) throws Exception { + String timestamp = Instant.now().toString(); + return post(ENDPOINT) + .contentType(contentType) + .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, CLIENT_ID)); + } + private org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder signedPostWithClientId( String body, String nonce,