From 6be14f2b4063e773780c5eedd25a8556189f4717 Mon Sep 17 00:00:00 2001 From: andy Date: Sat, 18 Jul 2026 17:32:07 +0700 Subject: [PATCH] =?UTF-8?q?=E5=AE=9E=E7=8E=B0=20M002=20V4=20=E5=85=A5?= =?UTF-8?q?=E7=AB=99=E8=A7=A3=E6=9E=90=E5=9F=BA=E7=BA=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- PROJECT_STATE.md | 19 +- docs/project/README.md | 4 +- .../backend-to-frontend-notes.md | 4 + .../integrations/superagent-api-contract.md | 135 ++- .../M002-order-task-workflow-v3.md | 8 +- .../M002-v4-agent-callback-field-contract.md | 27 +- .../security-access-control-boundary.md | 2 +- .../ReservationAiTaskIntakeServiceImpl.java | 947 +++++++++++++++++- .../SuperAgentTaskResultControllerTest.java | 330 ++++++ 9 files changed, 1445 insertions(+), 31 deletions(-) diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index 9416816..1153eef 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -4,21 +4,21 @@ | --- | --- | | 最近更新 | 2026-07-18 | | 当前分支 | `feature/huangting` | -| 当前阶段 | 项目文档标准化与业务能力迭代并行 | -| 当前重点 | 按 AI-NSES 建立新 Agent 可快速进入项目的文档入口 | +| 当前阶段 | M002 V4 入站基线与业务能力迭代并行 | +| 当前重点 | SuperAgent V4 回调入站解析、现有任务链路过渡适配和后续 V4 多卡模型规划 | ## 1. 当前 Checkpoint -- 名称:`checkpoint-docs-ai-native-entry` -- 状态:In Progress,等待本次文档改动 Review。 -- 目标:新增 AI-NSES 通用标准、模板目录、项目上下文、项目当前状态和本项目采用说明。 -- 边界:只改文档,不搬迁历史文档,不删除文档,不改代码。 +- 名称:`M002-V4-CP1-agent-callback-intake-baseline` +- 状态:In Progress,当前后端实现、测试和文档同步中。 +- 目标:按 2026-07-18 V4 Agent 回调字段契约接收 `source_message + order_contexts[] + message_events[]`,完成入站解析、基础校验、路由适配和 adapter error 最小落库。 +- 边界:不做完整 V4 多卡领域模型、不做 OPERA / OHIP、不做前端页面、不做历史数据迁移。 ## 2. 当前优先级 1. 先把 AI-NSES 的入口文档落地,让新 Agent 不依赖聊天记录也能理解项目。 2. 保持 `docs/project/README.md`、`CONTEXT.md`、`PROJECT_STATE.md` 三个入口之间一致。 -3. 后续开发继续以当前有效的 M002 V3 / P0.1、字段控件契约、SuperAgent 契约和安全边界文档为准。 +3. 后续开发继续以当前有效的 M002 V4 字段契约、M002 V3 / P0.1 历史实现说明、字段控件契约、SuperAgent 契约和安全边界文档为准。 ## 3. 已确认事实 @@ -34,11 +34,12 @@ - `docs/import/` 下按日期导入的资料是输入材料,不等同于当前权威开发契约;当前开发应优先看 `docs/project/README.md` 标记为当前有效或权威契约的文档。 - 后续每完成一个 Feature 或 Checkpoint,需要更新本文件,避免项目状态继续沉淀在聊天记录里。 - M010 Rooming List Excel 生成后端 CP1 和前端 V1 已实现:前端 `/reservation/rooming-lists/new` 上传来源名单和手工字段,后端同步生成 `.xlsx` 直接下载,第一版不落库、不上传 OSS。 +- M002 V4 CP1 当前只完成入站解析和现有任务链路过渡适配;Basic Information 独立卡、V4 订单任务多卡模型和 V4 前端页面仍未完成。 ## 5. Next Steps -- Review 本次 AI-NSES 文档入口改动。 -- 确认通过后,可按后续具体需求继续进入业务开发。 +- Review M002 V4 CP1 入站基线改动。 +- 后续如继续做 M002 V4,应优先规划订单任务 + 多卡领域模型、Basic Information 独立卡、前端 V4 页面模型和目录校验能力。 - 后续新增重要功能时,优先在 `docs/project/requirements/` 或未来 `docs/specs/` 中形成 Spec,再实现代码。 - M010 后续如需预览、历史记录、OSS 下载、订单 / 任务预填或客户字段目录化,再单独开前后端 checkpoint。 diff --git a/docs/project/README.md b/docs/project/README.md index 9b3ac2e..e10d4be 100644 --- a/docs/project/README.md +++ b/docs/project/README.md @@ -45,7 +45,7 @@ | `requirements/M002-order-task-workflow-v2.md` | 阶段记录 | M002 订单任务主流程 V2,记录当前已阶段实现的 AI 过渡层、S000/S999 兼容、订单任务流转、任务确认和 OPERA 模拟骨架。 | | `requirements/M002-order-task-workflow-v3.md` | 当前有效 | M002 订单任务主流程 V3,基于 2026-07-11 P0 冻结基线和 2026-07-12 P0.1 Parent Group 修订,记录 S10/S99、40 路由、方案 C、type-known manual review 同卡解阻和 fail-closed 边界。 | | `requirements/M002-task-field-control-contract-v1.md` | 当前有效 | M002 任务卡字段控件契约 V1,记录任务详情 `fields[]` 控件元数据、人工复核控件复用和前后端开发边界。 | -| `requirements/M002-v4-agent-callback-field-contract.md` | 当前有效 | M002 V4 Agent 回调字段契约,基于 2026-07-18 业务基线和最新答复,冻结 `source_message`、`order_contexts`、`message_events`、订单级 Basic Information、六类 Event、S10 和校验口径;当前代码尚未按 V4 改造。 | +| `requirements/M002-v4-agent-callback-field-contract.md` | 当前有效 | M002 V4 Agent 回调字段契约,基于 2026-07-18 业务基线和最新答复,冻结 `source_message`、`order_contexts`、`message_events`、订单级 Basic Information、六类 Event、S10 和校验口径;后端已完成 V4 入站解析 CP1,完整 V4 多卡主流程仍待后续实现。 | | `requirements/M002-superagent-task-result-api-contract.md` | 阶段记录 | M002 SuperAgent 任务结果入站接口契约阶段记录;对外总契约以 `integrations/superagent-api-contract.md` 为准。 | | `requirements/M002-ai-query-minimal-fields.md` | 阶段记录 | M002 SuperAgent 查询上下文接口 1、2 最小字段落地记录;对外总契约以 `integrations/superagent-api-contract.md` 为准。 | | `requirements/M002-backend-data-model-design.md` | 阶段记录 | M002 后端数据模型设计,记录 AI 过渡层、订单、任务、任务卡、审计和 OPERA 模拟结果表。 | @@ -90,6 +90,6 @@ - 接口暴露、权限、酒店隔离和审计边界以 `security-access-control-boundary.md` 为总检查清单;具体 SuperAgent / MCP / AgentBus 请求响应契约仍以 `integrations/` 下对应文档为准。 - AI-NSES 的通用标准以 `../import/reusable/ai-native-software-engineering-standard.md` 为复用来源;本项目采用方式以 `ai-native-adoption.md` 为准。 - M002 V1 只作为历史参考;V2 记录当前阶段实现;后续 M002 新开发以 `requirements/M002-order-task-workflow-v3.md` 为开发基线。 -- 2026-07-18 导入的业务基线已形成 `requirements/M002-v4-agent-callback-field-contract.md` 字段契约;M002 V4 主流程、后端数据模型和接口改造尚未落地,开发前需要继续形成 V4 主流程方案。 +- 2026-07-18 导入的业务基线已形成 `requirements/M002-v4-agent-callback-field-contract.md` 字段契约;M002 V4 入站解析 CP1 已落地,V4 订单任务 + 多卡领域模型、前端页面模型和完整主流程仍需继续形成 checkpoint。 - 前端展示 / 编辑字段以 2026-07-11 P0 冻结基线中的前端字段表、0712 字段控件说明和 `requirements/M002-task-field-control-contract-v1.md` 为白名单和控件契约基线;后端完整校验和 OPERA 映射仍以任务卡完整矩阵、0711 runtime 契约和后端规则为准。 - 时间点语义以 `backend-time-design.md` 为准;数据库时间点按 UTC 理解,API 返回带 `Z` 的 UTC 时间,页面再按酒店或用户时区展示。 diff --git a/docs/project/frontend-backend/backend-to-frontend-notes.md b/docs/project/frontend-backend/backend-to-frontend-notes.md index 1b16932..ab21592 100644 --- a/docs/project/frontend-backend/backend-to-frontend-notes.md +++ b/docs/project/frontend-backend/backend-to-frontend-notes.md @@ -445,6 +445,7 @@ RESERVATION_ROOMING_LIST_GENERATE - `POST /api/system/reservation/demo-data` 只用于 dev/test 联调造数,不是生产业务页面接口;访问口令不能进入前端代码。 - `POST /api/system/debug/eml-superagent-runs` 只用于 dev/test Debug 页面,不是生产普通业务页面接口;访问口令不能进入前端代码或构建产物。 - `POST /api/integrations/superagent/task-results` 是 SuperAgent 到后端的服务到服务入站接口。 +- `POST /api/integrations/superagent/task-results` 已支持 M002 V4 入站解析基线;这是第三方回调能力,不是前端页面接口,前端只通过任务列表 / 任务详情观察后端派生后的结果。 - `POST /api/ai-query/v1/case-context` 和 `POST /api/ai-query/v1/object-detail` 是 SuperAgent 查询上下文接口,不是前端页面接口。 - `GET /api/source-message-conversations/{externalConversationId}` 是历史讨论过的候选路径,当前后端不提供,前端不要接入。 - AgentBus probe、fixture、replay、system 类接口不应放到普通业务前端页面。 @@ -452,6 +453,9 @@ RESERVATION_ROOMING_LIST_GENERATE ## 7. 需要持续提醒的后置事项 - 普通任务切换订单接口继续后置。 +- M002 V4 入站解析与数据模型基线已完成第一版:后端可接收 `source_message + order_contexts[] + message_events[]`,识别 `NEW_BOOKING`、`UPDATE_BOOKING`、`CANCEL_BOOKING`、`TRACE_RESERVATION_NOTES`、`ROOMING_LIST`、`PAYMENT`,并保存 V4 原始 payload、`route_code`、系统处理分类和 `field_contract_version=20260718-v4`。前端暂不需要直接调用 V4 回调接口。 +- V4 可映射 event 现阶段仍复用现有任务详情结构;任务详情中若出现 `field_contract_version=20260718-v4` 或 AI payload 内的 `v4_source_message`、`v4_order_context`、`v4_message_event`,前端第一版只读展示即可,不要据此假定完整 V4 多卡页面已经完成。 +- V4 `PAYMENT.attachment_ids[]` 不匹配、`UPDATE_BOOKING` 携带 `rate_code` 等问题会出现在任务详情同批次的 `adapter_contract_errors[]` 只读诊断块中,不展示保存、确认、执行或重试按钮。 - M002 V3 的结构化 `S10/S99` 入站、40 条 P0.1 路由枚举 / 稳定配置、`UNHANDLED_CURRENT_INTENT`、`adapter_contract_error` transition 最小落库、任务列表 / 订单时间线 / 任务详情 V3 路由字段和只读诊断块透出、type-known manual review 同卡解阻第一版、typed infrastructure error、P0 fixtures 回归基线和 Parent Group / Cancel Allotment 路由修订均已完成。 - 系统管理后台 V1 已完成;后续若要做用户搜索更多筛选、批量操作、密码策略增强、MFA、登录设备管理,应单独开需求。 - 现有 Reservation / SourceMessage 业务接口的强制登录、强制权限和业务审计 actor 全量迁移仍后置。 diff --git a/docs/project/integrations/superagent-api-contract.md b/docs/project/integrations/superagent-api-contract.md index 59dfa00..aa3fb84 100644 --- a/docs/project/integrations/superagent-api-contract.md +++ b/docs/project/integrations/superagent-api-contract.md @@ -4,9 +4,9 @@ | 项目 | 内容 | | --- | --- | -| 文档版本 | 0.7 | -| 日期 | 2026-07-11 | -| 状态 | 当前代码契约已支持 V2 `ai_task_results[]` 兼容、结构化 S10/S99、V3 业务根基础解析、旧 S000/S999 兼容和单酒店 hotel_id 后端解析 | +| 文档版本 | 0.8 | +| 日期 | 2026-07-18 | +| 状态 | 当前代码契约已支持 V4 入站解析基线、V2 `ai_task_results[]` 兼容、结构化 S10/S99、V3 业务根基础解析、旧 S000/S999 兼容和单酒店 hotel_id 后端解析 | | 适用范围 | SuperAgent 调用本系统查询上下文、查询邮件会话、提交 AI 任务结果 | | 主要读者 | SuperAgent 对接方、后端、测试、运维 | @@ -88,7 +88,7 @@ X-TH-Hotel-SuperAgent-Signature: sha256= 6. 使用共享 secret 重新计算 HMAC。 7. 常量时间比较签名。 8. 校验并记录 `client_id + nonce`,防止重放。 -9. 鉴权和协议校验通过后再解析业务 JSON、结构化 S10/S99、V3 业务根或 S000/S999 文本结果。 +9. 鉴权和协议校验通过后再解析业务 JSON、V4 包、结构化 S10/S99、V3 业务根或 S000/S999 文本结果。 ## 3. SourceMessage ID 口径 @@ -455,9 +455,124 @@ SuperAgent 不应知道或依赖内部 SourceMessage Inbox ID,也不需要为 | URL | `{TH_HOTEL_API_BASE_URL}/api/integrations/superagent/task-results` | | request_path | `/api/integrations/superagent/task-results` | | Content-Type | `application/json` 或 `text/plain` | -| 业务动作 | 接收 AI 任务结果;V3 JSON 支持结构化 S10/S99 和业务根,V2 JSON 继续兼容,旧 S000/S999 文本创建只读特殊任务 | +| 业务动作 | 接收 AI 任务结果;V4 JSON 支持 S10/S99 和业务根入站基线,V3 / V2 JSON 继续兼容,旧 S000/S999 文本创建只读特殊任务 | -### 8.2 V3 S10/S99 结构化请求体 +### 8.2 V4 JSON 请求体(当前推荐) + +V4 普通业务包示例: + +```json +{ + "route_code": null, + "source_message": { + "source_message_id": "mail-20260718-0001", + "conversation_id": "thread-001", + "subject": "Group booking and payment", + "sender": "agent@example.test", + "sent_at": "2026-07-18T02:10:00Z", + "body": "Please create group GRP-001 and note payment attached.", + "body_content_type": "text/plain", + "attachments": [ + { + "id": "att-pay-1", + "name": "payment-slip.jpg", + "content_type": "image/jpeg", + "url": "https://oss.example.test/payment-slip.jpg", + "size": 251524 + } + ] + }, + "order_contexts": [ + { + "order_ref": "order-1", + "basic_information": { + "account_code": "QBD_TRAVEL", + "manual_review": null + } + } + ], + "message_events": [ + { + "order_ref": "order-1", + "event_type": "NEW_BOOKING", + "target_order": { + "booking_type": "GROUP", + "locator_type": "GROUP_CODE", + "locator_value": "GRP-001" + }, + "arrival_date": "2026-07-26", + "departure_date": "2026-07-29", + "rate_code": "BAR", + "booking_scenario": "STANDARD", + "room_items": [ + { + "room_type_code": "TWN", + "room_count": 2 + } + ], + "manual_review": null + }, + { + "order_ref": "order-1", + "event_type": "PAYMENT", + "target_order": { + "booking_type": "GROUP", + "locator_type": "GROUP_CODE", + "locator_value": "GRP-001" + }, + "attachment_ids": ["att-pay-1"], + "manual_review": null + } + ] +} +``` + +V4 纯通知包示例: + +```json +{ + "route_code": "S10", + "source_message": { + "source_message_id": "mail-20260718-0002", + "conversation_id": "thread-002", + "subject": "Thanks", + "sender": "guest@example.test", + "sent_at": "2026-07-18T02:10:00Z", + "body": "Noted with thanks.", + "body_content_type": "text/plain", + "attachments": [] + }, + "order_contexts": [], + "message_events": [] +} +``` + +V4 字段说明: + +| 字段 | 是否必填 | 中文说明 | +| --- | --- | --- | +| `route_code` | 是 | 普通业务固定为 `null`;入口通知第一版支持 `S10/S99`,当前新业务语义以 `S10` 为准,`S99` 仅作为兼容入口保留。 | +| `source_message.source_message_id` | 是 | 外部来源消息 ID,对应 SourceMessage Inbox 的 `external_message_id`。 | +| `source_message.body_content_type` | 是 | 只能是 `text/plain` 或 `text/html`。 | +| `source_message.attachments[]` | 是 | 包级附件数组;每个附件必须有 `id/name/content_type/url`,`size` 可省略或为 `null`。 | +| `order_contexts[]` | 普通业务必填 | 每个 `order_ref` 一项,第一版必须带 `basic_information.account_code` 和 `basic_information.manual_review`。 | +| `message_events[]` | 普通业务必填 | 逐 event 入站,后端按数组顺序处理。 | +| `message_events[].event_type` | 是 | 第一版支持 `NEW_BOOKING`、`UPDATE_BOOKING`、`CANCEL_BOOKING`、`TRACE_RESERVATION_NOTES`、`ROOMING_LIST`、`PAYMENT`。 | +| `message_events[].target_order` | 是 | `GROUP + GROUP_CODE`,或 `FIT + BOOKING_CODE / CONFIRMATION_NUMBER`。 | +| `message_events[].manual_review` | 是 | 只能是 `null` 或布尔 `true`;`true` 必须能由当前对象中的未解决字段解释。 | +| `PAYMENT.attachment_ids[]` | PAYMENT 必填 | 必须引用同包 `source_message.attachments[].id`。 | + +当前已支持的 V4 行为: + +- 命中 SourceMessage 后保存 AI batch / transition,并按 `message_events[]` 顺序处理。 +- 可映射 event 先复用现有订单 / 任务 / 任务卡链路,`ai_payload_json` 会保留 `v4_source_message`、`v4_order_context`、`v4_message_event`。 +- `route_code=S10/S99` 创建只读 `SOURCE_MESSAGE_ONLY` 任务,任务列表可见,订单列表不可见。 +- `PAYMENT.attachment_ids[]` 引用不存在的附件、`UPDATE_BOOKING` 携带 `rate_code`、以及其他 V4 event 契约错误,只写 `adapter_contract_error` transition,不创建用户可处理业务任务。 +- 技术契约错误不会自动转为 S10/S99,也不会创建前端可处理业务任务。 + +当前 V4 入站仍未完成完整多卡模型:Basic Information 独立卡、V4 页面模型、真实 OPERA / OHIP、普通任务切换订单均后置。 + +### 8.3 V3 S10/S99 结构化请求体 S10 示例: @@ -493,7 +608,7 @@ S10 示例: S99 与 S10 使用相同结构,但 `route_code=S99`,`agent_assessment.status=material_package_unavailable`,且 `manual_review` 必须是完整入口复核对象。 -### 8.3 V3 业务根请求体 +### 8.4 V3 业务根请求体 ```json { @@ -589,7 +704,7 @@ V3 字段说明: } ``` -### 8.4 V2 JSON 兼容请求体 +### 8.5 V2 JSON 兼容请求体 ```json { @@ -645,7 +760,7 @@ V3 字段说明: `informational_message` 结构化任务仅用于历史兼容。新数据如果是纯信息类邮件或无法形成业务素材包,应优先使用 V3 结构化 `S10/S99`;旧联调或兼容场景仍可使用下面的 `S000/S999` 文本请求体。 -### 8.5 S000/S999 文本请求体 +### 8.6 S000/S999 文本请求体 纯信息类邮件: @@ -669,7 +784,7 @@ S999,mail-20260708-0001 S000/S999 不在 body 里传 `hotel_id`,后端使用平台酒店表唯一 `ACTIVE` 酒店和外部消息 ID 查询 SourceMessage Inbox。命中后创建 `SOURCE_MESSAGE_ONLY` 只读特殊任务:任务列表可见,订单列表不可见,不允许编辑、确认、转换订单、执行 OPERA 或重试 OPERA,也不参与同订单任务执行顺序阻塞。该文本格式仅为兼容路径,新数据优先使用结构化 `S10/S99`。 -### 8.6 成功响应 +### 8.7 成功响应 ```json { diff --git a/docs/project/requirements/M002-order-task-workflow-v3.md b/docs/project/requirements/M002-order-task-workflow-v3.md index 8c49fa2..3e53ca2 100644 --- a/docs/project/requirements/M002-order-task-workflow-v3.md +++ b/docs/project/requirements/M002-order-task-workflow-v3.md @@ -432,10 +432,14 @@ V3 P0.1 不做以下事项: ## 14. 当前代码现状提醒 -截至 M002 V3 CP9 后端第一版落地后,当前后端已经实现: +截至 M002 V4 入站解析基线落地后,当前后端已经实现: - `S000/S999` 文本结果兼容处理。 - 结构化 `S10/S99` 入站处理,复用 `SOURCE_MESSAGE_ONLY` 只读特殊任务。 +- V4 包级 `route_code=S10/S99` 入站处理,复用 `SOURCE_MESSAGE_ONLY` 只读特殊任务;普通 V4 业务包要求 `route_code=null`。 +- V4 业务根 `source_message + order_contexts[] + message_events[]` 基础解析;`source_message.source_message_id` 按 SourceMessage Inbox 的 `external_message_id` 反查邮件。 +- V4 第一版识别 `NEW_BOOKING`、`UPDATE_BOOKING`、`CANCEL_BOOKING`、`TRACE_RESERVATION_NOTES`、`ROOMING_LIST`、`PAYMENT`,可映射 event 先复用现有订单 / 任务 / 任务卡链路,并保存 `catalog_code=M002V4`、`skill_id=booking-desk-event-v4`、`field_contract_version=20260718-v4` 和 V4 原始三元组 / 原始 event payload。 +- V4 `PAYMENT.attachment_ids[]` 必须匹配 `source_message.attachments[].id`;V4 `UPDATE_BOOKING` 不接受 `rate_code` 或 `after.rate_code`;这类契约错误只落 `adapter_contract_error` transition,不创建用户可处理业务任务。 - 40 条 P0.1 路由枚举 / 稳定配置。 - V3 业务根 `source_message + message_events[]` 基础解析;能派生到稳定路由的 event 创建业务任务,无法派生的 event、显式 `contract_errors`、根 `missing_fields`、不完整 `manual_review`、当前 producer 旧 Parent Cancel Booking 和不完整 parent split 候选只落 `adapter_contract_error` transition。 - P0.1 Parent split 已支持单边 Parent key 归一;Parent key 候选冲突且符合 `target_object_unclear + parent_identity_candidates[]` 时进入同卡 type-known manual review,不再误判为 adapter contract error。 @@ -464,4 +468,6 @@ V3 P0.1 不做以下事项: 仍需后续 checkpoint 实现: +- V4 订单任务 + 多卡领域模型重建,尤其是 Basic Information 订单级独立卡、邮件展示卡和各业务卡独立确认 / 锁定。 +- V4 前端页面模型、任务详情字段矩阵和目录校验完全切换。 - 真实 OPERA / OHIP、普通任务任意切换订单、字段矩阵从当前扁平结构整体迁移到 0711 P0 新结构、历史旧 Parent Cancel Booking payload 批量迁移。 diff --git a/docs/project/requirements/M002-v4-agent-callback-field-contract.md b/docs/project/requirements/M002-v4-agent-callback-field-contract.md index 8f82026..e0f3ead 100644 --- a/docs/project/requirements/M002-v4-agent-callback-field-contract.md +++ b/docs/project/requirements/M002-v4-agent-callback-field-contract.md @@ -4,9 +4,9 @@ | 项目 | 内容 | | --- | --- | -| 文档版本 | 1.0 | +| 文档版本 | 1.1 | | 日期 | 2026-07-18 | -| 状态 | 当前 V4 字段基线;后续需同步 SuperAgent、Adapter、MCP Schema 和信息系统 DTO | +| 状态 | 当前 V4 字段基线;后端已完成 CP1 入站解析与数据模型基线,完整 V4 多卡模型仍需后续 checkpoint | | 适用范围 | 0718 业务基线下,Agent → Adapter / MCP → 信息系统的业务回调字段 | | 不适用范围 | 数据库表设计、前端视觉细节、真实 PMS API、技术失败后台重试、旧 M002 V3 数据兼容 | @@ -14,7 +14,7 @@ 本文把 2026-07-18 导入的业务基线、Agent 回调问题答复、草案审查答复和剩余 10 项确认回复,整理为 M002 V4 的 Agent 回调字段契约。 -本契约用于后续 M002 V4 主流程设计、后端领域建模、前端页面模型、Adapter / MCP Schema 对齐和 SuperAgent 联调。当前代码尚未按本文完成改造。 +本契约用于后续 M002 V4 主流程设计、后端领域建模、前端页面模型、Adapter / MCP Schema 对齐和 SuperAgent 联调。当前后端已按本文完成 V4 入站解析基线:能识别 V4 包、校验关键契约、保存 AI transition / 任务卡原始 payload,并把可映射的六类 event 先接入现有订单任务链路。 当前已确认开发阶段数据可以清空,因此 M002 V4 后续可以按新模型重建,不要求兼容旧任务数据、旧草稿、旧 OPERA 模拟、旧 `S000/S999`、旧 Fallback 或旧 `case_keys`。 @@ -681,3 +681,24 @@ AI 回调包 - 当前无 PMS API,不生成 OPERA 模拟操作和 PMS 成功语义。 - 技术异常不创建用户可见任务。 - 开发阶段不兼容老数据,允许清空旧任务相关数据后迁移。 + +## 25. 后端 CP1 已落地范围 + +2026-07-18 后端已完成 M002 V4 入站解析与数据模型基线,当前代码支持: + +- `POST /api/integrations/superagent/task-results` 接收 V4 JSON 包:`route_code`、`source_message`、`order_contexts[]`、`message_events[]`。 +- `source_message.source_message_id` 按 SourceMessage Inbox 的 `external_message_id` 定位当前邮件;SuperAgent 不传内部数据库 ID。 +- `route_code=S10/S99` 复用现有 `SOURCE_MESSAGE_ONLY` 只读特殊任务机制;任务列表可见,订单列表不可见,不可编辑和执行。 +- 普通业务包要求 `route_code=null`,并按 `message_events[]` 数组顺序处理。 +- 第一版识别六类 `event_type`:`NEW_BOOKING`、`UPDATE_BOOKING`、`CANCEL_BOOKING`、`TRACE_RESERVATION_NOTES`、`ROOMING_LIST`、`PAYMENT`。 +- 能映射到现有稳定任务卡的 event 会创建业务任务,并在 `ai_payload_json` 中保存 `v4_source_message`、`v4_order_context`、`v4_message_event`、`route_code`、系统处理分类和 `field_contract_version=20260718-v4`。 +- `PAYMENT.attachment_ids[]` 必须能匹配 `source_message.attachments[].id`;不匹配时只写 `adapter_contract_error` transition,不创建用户可处理业务任务。 +- `UPDATE_BOOKING` 中出现 `rate_code` 或 `after.rate_code` 时按 `UPDATE_RATE_CODE_NOT_ALLOWED` 写入 `adapter_contract_error` transition。 +- `manual_review` 只接受 `null` 或布尔 `true`;`true` 必须能由当前对象中可识别的未解决字段解释。 + +当前 CP1 仍未完成: + +- 尚未重建 V4 订单任务 + 多卡领域模型;Basic Information 仍只是保存在 V4 原始 payload / order context 中,未作为独立可确认任务卡落地。 +- 尚未取消 V3 草稿 / OPERA 模拟骨架;现有可映射 event 仍复用 M002 V3 任务状态和任务卡创建链路。 +- 尚未接入真实 PMS / OPERA / OHIP。 +- 尚未改造前端 V4 页面模型;前端第一版只能通过现有任务详情字段和原始 payload 观察 V4 入站结果。 diff --git a/docs/project/security-access-control-boundary.md b/docs/project/security-access-control-boundary.md index 36abd82..42cf2f3 100644 --- a/docs/project/security-access-control-boundary.md +++ b/docs/project/security-access-control-boundary.md @@ -96,7 +96,7 @@ | `POST /api/ai-query/v1/object-detail` | `THIRD_PARTY_SUPERAGENT` | HMAC 鉴权 | 保持 HMAC;返回最小必要上下文 | 记录请求 ID、client_id 和安全错误 | | `POST /api/ai-query/v1/conversation-tasks` | `THIRD_PARTY_SUPERAGENT` | HMAC 鉴权 | 保持 HMAC;不返回邮件原文 | 记录请求 ID、client_id 和安全错误 | | `POST /api/ai-query/v1/conversation-source` | `THIRD_PARTY_SUPERAGENT` | HMAC 鉴权 | 保持 HMAC;只按契约返回需要字段 | 记录请求 ID、client_id 和安全错误 | -| `POST /api/integrations/superagent/task-results` | `THIRD_PARTY_SUPERAGENT` | HMAC + nonce + timestamp + body hash | 保持;外部 `source_message_id` 必须匹配 Inbox | 记录 batch、transition、错误和幂等结果 | +| `POST /api/integrations/superagent/task-results` | `THIRD_PARTY_SUPERAGENT` | HMAC + nonce + timestamp + body hash | 保持;V4 / V3 / V2 共存期均必须用外部 `source_message_id` 匹配 Inbox,技术契约错误只落 adapter error,不创建用户可处理任务 | 记录 batch、transition、adapter error、幂等结果和安全错误 | | `/mcp` | `THIRD_PARTY_MCP` | Bearer Token;提交工具可独立开关 | 保持;工具级能力限制,不暴露无关接口 | 记录工具调用结果和业务入站结果 | | AgentBus WebSocket | `THIRD_PARTY_AGENTBUS` | AgentBus Token + capture 开关 | 保持;只入 SourceMessage,不直接建业务任务 | 记录 SourceMessage、payload hash 和 dispatch run | 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 0e36dae..114331e 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 @@ -37,8 +37,10 @@ import java.time.LocalDateTime; import java.time.ZoneOffset; import java.util.ArrayList; import java.util.HexFormat; +import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; +import java.util.Map; import java.util.Set; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -57,6 +59,7 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta private static final Logger LOGGER = LoggerFactory.getLogger(ReservationAiTaskIntakeServiceImpl.class); private static final String FIELD_CONTRACT_VERSION = "20260711-p0"; + private static final String V4_FIELD_CONTRACT_VERSION = "20260718-v4"; 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"; @@ -64,6 +67,8 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta private static final String SOURCE_MESSAGE_ONLY_SKILL_ID = "superagent_entry_router"; private static final String V3_CATALOG_CODE = "M002V3"; private static final String V3_SKILL_ID = "booking-desk-event"; + private static final String V4_CATALOG_CODE = "M002V4"; + private static final String V4_SKILL_ID = "booking-desk-event-v4"; private static final String ADAPTER_CONTRACT_ERROR_CODE = "adapter_contract_error"; private static final String DEFAULT_SOURCE_PROVIDER = "AGENTBUS"; private static final String DEFAULT_SOURCE_CHANNEL = "EMAIL"; @@ -115,6 +120,18 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta "case_candidates", "extraction_warnings", "unhandled_current_intents"); + private static final Set V4_ALLOWED_EVENT_TYPES = Set.of( + "NEW_BOOKING", + "UPDATE_BOOKING", + "CANCEL_BOOKING", + "TRACE_RESERVATION_NOTES", + "ROOMING_LIST", + "PAYMENT"); + private static final Set V4_ALLOWED_BOOKING_TYPES = Set.of("GROUP", "FIT"); + private static final Set V4_ALLOWED_LOCATOR_TYPES = Set.of( + "GROUP_CODE", + "BOOKING_CODE", + "CONFIRMATION_NUMBER"); private static final List V3_EVENT_REQUIRED_KEYS = List.of( "event_type", "event_role", @@ -195,6 +212,12 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta if (isInfrastructureInputError(root)) { throw error(HttpStatus.BAD_REQUEST, "INFRASTRUCTURE_INPUT_ERROR", "SuperAgent 返回基础设施输入错误。"); } + if (isV4SourceMessageNotification(root)) { + return acceptV4SourceMessageNotification(root, clientId, requestId, defaultHotelId, requestBody); + } + if (isV4BusinessRoot(root)) { + return acceptV4BusinessRoot(root, clientId, requestId, defaultHotelId, requestBody); + } if (isV3SourceMessageNotification(root)) { return acceptV3SourceMessageNotification(root, clientId, requestId, defaultHotelId, requestBody); } @@ -431,6 +454,809 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta ); } + /** + * 接收 V4 S10/S99 来源邮件通知。V4 不再要求旧 result_type 和通知对象,按包级 route_code 建只读任务。 + */ + private SuperAgentTaskResultResponse acceptV4SourceMessageNotification( + JsonNode root, + String clientId, + String requestId, + String defaultHotelId, + String rawBody) { + validateV4PackageRoot(root, true); + String routeCode = trimToNull(textAt(root, "route_code")); + ReservationAiRouteDefinition route = ReservationAiRouteDefinition.findByRouteCode(routeCode) + .filter(ReservationAiRouteDefinition::sourceMessageNotification) + .orElseThrow(() -> error(HttpStatus.BAD_REQUEST, "ADAPTER_CONTRACT_ERROR", "V4 通知 route_code 无效。")); + ResolvedSourceMessage resolvedSourceMessage = resolveV3SourceMessage(root, defaultHotelId); + SourceMessageOnlyEntryResult entryResult = new SourceMessageOnlyEntryResult( + SourceMessageOnlyResultCode.fromCode(route.taskSubtype()) + .orElseThrow(() -> error(HttpStatus.BAD_REQUEST, "ADAPTER_CONTRACT_ERROR", "V4 通知结果码无效。")), + resolvedSourceMessage.responseSourceMessageId(), + AiResultType.SOURCE_MESSAGE_REVIEW_NOTIFICATION.code(), + route.routeCode() + ); + return acceptSourceMessageOnlyEntryResult( + entryResult, + clientId, + requestId, + defaultHotelId, + rawBody, + resolvedSourceMessage.snapshot()); + } + + /** + * 接收 V4 业务根。第一版按 message_events[] 顺序派生现有任务卡,event 契约错误只落 transition。 + */ + private SuperAgentTaskResultResponse acceptV4BusinessRoot( + JsonNode root, + String clientId, + String requestId, + String defaultHotelId, + String rawBody) { + V4PackageContext packageContext = validateV4PackageRoot(root, false); + ResolvedSourceMessage resolvedSourceMessage = resolveV3SourceMessage(root, defaultHotelId); + SourceMessageInboxSnapshot sourceMessage = resolvedSourceMessage.snapshot(); + String hotelId = sourceMessage.hotelId(); + Long sourceMessageId = sourceMessage.id(); + JsonNode messageEvents = root.path("message_events"); + if (messageEvents.isEmpty()) { + throw error(HttpStatus.BAD_REQUEST, "TASK_RESULTS_EMPTY", "V4 message_events 不能为空。"); + } + String requestPayloadSha256 = sha256(rawBody == null ? "" : rawBody); + String batchIdempotencyKey = sha256(BATCH_KEY_PREFIX + "|" + sourceMessageId + "|" + requestPayloadSha256); + + ReservationAiBatchSnapshot existingBatch = workflowRepository + .findBatchBySourceMessageId(hotelId, sourceMessageId) + .orElse(null); + if (existingBatch != null) { + return handleExistingBatch( + requestId, + resolvedSourceMessage.responseSourceMessageId(), + requestPayloadSha256, + existingBatch); + } + + LocalDateTime now = nowUtc(); + String safeRequestId = optionalText(requestId, "request_id", LENGTH_128); + ReservationAiBatchDraft batchDraft = new ReservationAiBatchDraft( + hotelId, + sourceMessageId, + requestPayloadSha256, + batchIdempotencyKey, + requireText(clientId, "clientId", LENGTH_128), + safeRequestId, + now, + messageEvents.size(), + null + ); + Long batchId = insertBatchOrReplay(batchDraft); + if (batchId == null) { + return handleExistingBatch( + requestId, + resolvedSourceMessage.responseSourceMessageId(), + requestPayloadSha256, + workflowRepository.findBatchBySourceMessageId(hotelId, sourceMessageId) + .orElseThrow(() -> error(HttpStatus.CONFLICT, "IDEMPOTENCY_CONFLICT", "AI 批次并发写入状态不确定。"))); + } + + List responseItems = new ArrayList<>(messageEvents.size()); + Map targetOrderByOrderRef = new LinkedHashMap<>(); + for (int index = 0; index < messageEvents.size(); index++) { + JsonNode event = messageEvents.get(index); + int arrayIndex = index + 1; + V3EventContractIssue contractIssue = inspectV4EventContractIssue(event, packageContext, targetOrderByOrderRef); + if (contractIssue != null) { + responseItems.add(createV4AdapterContractErrorTransition( + hotelId, + sourceMessageId, + batchId, + event, + arrayIndex, + contractIssue.code(), + contractIssue.message(), + now)); + continue; + } + ReservationAiRouteDefinition route = deriveV4EventRoute(event); + if (route == null) { + responseItems.add(createV4AdapterContractErrorTransition( + hotelId, + sourceMessageId, + batchId, + event, + arrayIndex, + "EVENT_ROUTE_UNSUPPORTED", + "V4 message_events[" + index + "] 无法派生稳定路由。", + now)); + continue; + } + ObjectNode item = normalizeV4EventToTaskResultItem(root, event, route, arrayIndex); + responseItems.add(createItem(hotelId, sourceMessageId, batchId, item, arrayIndex, now)); + } + + return new SuperAgentTaskResultResponse( + safeRequestId, + resolvedSourceMessage.responseSourceMessageId(), + batchId.toString(), + false, + responseItems.size(), + responseItems, + List.of() + ); + } + + /** + * 校验 V4 包级结构,并提取后续 event 校验需要的 order_ref 和附件 ID。 + */ + private V4PackageContext validateV4PackageRoot(JsonNode root, boolean sourceMessageNotification) { + if (root == null + || !root.isObject() + || !root.has("route_code") + || !root.has("source_message") + || !root.path("order_contexts").isArray() + || !root.path("message_events").isArray()) { + throw error(HttpStatus.BAD_REQUEST, "ADAPTER_CONTRACT_ERROR", "V4 根结构字段不完整。"); + } + V3EventContractIssue sourceMessageIssue = inspectV4SourceMessageIssue(root.path("source_message")); + if (sourceMessageIssue != null) { + throw error(HttpStatus.BAD_REQUEST, "ADAPTER_CONTRACT_ERROR", sourceMessageIssue.message()); + } + Set attachmentIds = collectV4AttachmentIds(root.path("source_message").path("attachments")); + Set orderRefs = collectV4OrderRefs(root.path("order_contexts")); + String routeCode = trimToNull(textAt(root, "route_code")); + if (sourceMessageNotification) { + if (!("S10".equals(routeCode) || "S99".equals(routeCode))) { + throw error(HttpStatus.BAD_REQUEST, "ADAPTER_CONTRACT_ERROR", "V4 通知 route_code 只能为 S10 或 S99。"); + } + if (!root.path("order_contexts").isEmpty() || !root.path("message_events").isEmpty()) { + throw error(HttpStatus.BAD_REQUEST, "ADAPTER_CONTRACT_ERROR", "V4 通知不能携带 order_contexts 或 message_events。"); + } + return new V4PackageContext(orderRefs, attachmentIds); + } + if (routeCode != null) { + throw error(HttpStatus.BAD_REQUEST, "ADAPTER_CONTRACT_ERROR", "V4 普通业务 route_code 必须为 null。"); + } + if (orderRefs.isEmpty()) { + throw error(HttpStatus.BAD_REQUEST, "ADAPTER_CONTRACT_ERROR", "V4 普通业务 order_contexts 不能为空。"); + } + return new V4PackageContext(orderRefs, attachmentIds); + } + + /** + * 校验 V4 source_message 最小业务字段。正文只做结构校验,不在错误中回显原文。 + */ + private V3EventContractIssue inspectV4SourceMessageIssue(JsonNode sourceMessage) { + if (sourceMessage == null + || !sourceMessage.isObject() + || trimToNull(textAt(sourceMessage, "source_message_id")) == null + || !isStringOrNull(sourceMessage.path("conversation_id")) + || !isStringOrNull(sourceMessage.path("subject")) + || !isStringOrNull(sourceMessage.path("sender")) + || !isStringOrNull(sourceMessage.path("sent_at")) + || !isStringOrNull(sourceMessage.path("body")) + || !("text/plain".equals(textAt(sourceMessage, "body_content_type")) + || "text/html".equals(textAt(sourceMessage, "body_content_type"))) + || !sourceMessage.path("attachments").isArray()) { + return new V3EventContractIssue("SOURCE_MESSAGE_CONTRACT_INVALID", "V4 source_message 结构不符合契约。"); + } + for (JsonNode attachment : sourceMessage.path("attachments")) { + V3EventContractIssue attachmentIssue = inspectV4AttachmentIssue(attachment); + if (attachmentIssue != null) { + return attachmentIssue; + } + } + return null; + } + + /** + * 校验 V4 附件引用。URL 仅检查是否存在,不在日志或错误中输出。 + */ + private V3EventContractIssue inspectV4AttachmentIssue(JsonNode attachment) { + if (attachment == null + || !attachment.isObject() + || trimToNull(textAt(attachment, "id")) == null + || trimToNull(textAt(attachment, "name")) == null + || trimToNull(textAt(attachment, "content_type")) == null + || trimToNull(textAt(attachment, "url")) == null + || !(attachment.path("size").isMissingNode() + || attachment.path("size").isNull() + || attachment.path("size").isNumber())) { + return new V3EventContractIssue("SOURCE_MESSAGE_ATTACHMENT_INVALID", "V4 source_message.attachments 结构无效。"); + } + return null; + } + + /** + * 收集 V4 包级附件 ID,并拒绝重复附件 ID,保证 Payment 引用可唯一定位。 + */ + private Set collectV4AttachmentIds(JsonNode attachments) { + Set attachmentIds = new LinkedHashSet<>(); + for (JsonNode attachment : attachments) { + String attachmentId = trimToNull(textAt(attachment, "id")); + if (attachmentId == null || !attachmentIds.add(attachmentId)) { + throw error(HttpStatus.BAD_REQUEST, "ADAPTER_CONTRACT_ERROR", "V4 source_message.attachments.id 重复或缺失。"); + } + validateLength(attachmentId, "source_message.attachments.id", LENGTH_128); + } + return attachmentIds; + } + + /** + * 收集 V4 order_ref,并校验每个订单级 Basic Information 只出现一次。 + */ + private Set collectV4OrderRefs(JsonNode orderContexts) { + Set orderRefs = new LinkedHashSet<>(); + for (JsonNode orderContext : orderContexts) { + String orderRef = trimToNull(textAt(orderContext, "order_ref")); + if (orderRef == null || !orderRefs.add(orderRef)) { + throw error(HttpStatus.BAD_REQUEST, "ADAPTER_CONTRACT_ERROR", "V4 order_contexts.order_ref 重复或缺失。"); + } + validateLength(orderRef, "order_contexts.order_ref", LENGTH_128); + V3EventContractIssue basicIssue = inspectV4BasicInformationIssue(orderContext.path("basic_information")); + if (basicIssue != null) { + throw error(HttpStatus.BAD_REQUEST, "ADAPTER_CONTRACT_ERROR", basicIssue.message()); + } + } + return orderRefs; + } + + /** + * 校验订单级 Basic Information。V4 manual_review 只允许 null 或 true,true 必须由 account_code 缺失解释。 + */ + private V3EventContractIssue inspectV4BasicInformationIssue(JsonNode basicInformation) { + if (basicInformation == null + || !basicInformation.isObject() + || !basicInformation.has("account_code") + || !basicInformation.has("manual_review") + || !isStringOrNull(basicInformation.path("account_code")) + || !isV4ManualReviewNullOrTrue(basicInformation.path("manual_review"))) { + return new V3EventContractIssue("BASIC_INFORMATION_CONTRACT_INVALID", "V4 Basic Information 结构无效。"); + } + boolean manualReview = isBooleanTrue(basicInformation.path("manual_review")); + boolean accountUnresolved = basicInformation.path("account_code").isNull(); + if (manualReview != accountUnresolved) { + return new V3EventContractIssue( + "BASIC_INFORMATION_MANUAL_REVIEW_INVALID", + "V4 Basic Information manual_review 必须能由 account_code 缺失解释。"); + } + return null; + } + + /** + * 校验 V4 单个 event。业务字段错误只影响该 event,不阻断同包其他 sibling event。 + */ + private V3EventContractIssue inspectV4EventContractIssue( + JsonNode event, + V4PackageContext packageContext, + Map targetOrderByOrderRef) { + if (event == null + || !event.isObject() + || trimToNull(textAt(event, "order_ref")) == null + || trimToNull(textAt(event, "event_type")) == null + || !event.path("target_order").isObject() + || !event.has("manual_review") + || !isV4ManualReviewNullOrTrue(event.path("manual_review"))) { + return new V3EventContractIssue("EVENT_CONTRACT_INCOMPLETE", "V4 message_event 公共字段不完整。"); + } + String orderRef = trimToNull(textAt(event, "order_ref")); + if (!packageContext.orderRefs().contains(orderRef)) { + return new V3EventContractIssue("ORDER_REF_NOT_FOUND", "V4 message_event 引用了不存在的 order_ref。"); + } + String eventType = trimToNull(textAt(event, "event_type")); + if (!V4_ALLOWED_EVENT_TYPES.contains(eventType)) { + return new V3EventContractIssue("EVENT_TYPE_UNSUPPORTED", "V4 event_type 不在第一版支持范围。"); + } + V3EventContractIssue targetIssue = inspectV4TargetOrderIssue(event.path("target_order"), event.path("manual_review")); + if (targetIssue != null) { + return targetIssue; + } + V3EventContractIssue targetConsistencyIssue = inspectV4TargetOrderConsistency( + orderRef, + event.path("target_order"), + targetOrderByOrderRef); + if (targetConsistencyIssue != null) { + return targetConsistencyIssue; + } + V3EventContractIssue specificIssue = inspectV4EventSpecificIssue(event, packageContext); + if (specificIssue != null) { + return specificIssue; + } + return inspectV4ManualReviewConsistencyIssue(event); + } + + /** + * 校验 V4 target_order 合法组合。定位字段未解决时必须由 manual_review=true 承接。 + */ + private V3EventContractIssue inspectV4TargetOrderIssue(JsonNode targetOrder, JsonNode manualReview) { + if (!targetOrder.has("booking_type") + || !targetOrder.has("locator_type") + || !targetOrder.has("locator_value") + || !isStringOrNull(targetOrder.path("booking_type")) + || !isStringOrNull(targetOrder.path("locator_type")) + || !isStringOrNull(targetOrder.path("locator_value"))) { + return new V3EventContractIssue("TARGET_ORDER_CONTRACT_INVALID", "V4 target_order 结构无效。"); + } + boolean unresolved = hasV4TargetOrderUnresolvedMarker(targetOrder); + if (unresolved) { + return isBooleanTrue(manualReview) + ? null + : new V3EventContractIssue("TARGET_ORDER_MANUAL_REVIEW_REQUIRED", "V4 target_order 未解决时必须 manual_review=true。"); + } + String bookingType = textAt(targetOrder, "booking_type"); + String locatorType = textAt(targetOrder, "locator_type"); + if (!V4_ALLOWED_BOOKING_TYPES.contains(bookingType) || !V4_ALLOWED_LOCATOR_TYPES.contains(locatorType)) { + return new V3EventContractIssue("TARGET_ORDER_COMBINATION_INVALID", "V4 target_order 枚举值无效。"); + } + if ("GROUP".equals(bookingType) && !"GROUP_CODE".equals(locatorType)) { + return new V3EventContractIssue("TARGET_ORDER_COMBINATION_INVALID", "Group 订单只能使用 GROUP_CODE 定位。"); + } + if ("FIT".equals(bookingType) + && !("BOOKING_CODE".equals(locatorType) || "CONFIRMATION_NUMBER".equals(locatorType))) { + return new V3EventContractIssue("TARGET_ORDER_COMBINATION_INVALID", "Fit 订单只能使用 BOOKING_CODE 或 CONFIRMATION_NUMBER 定位。"); + } + return null; + } + + /** + * 校验同一 order_ref 下非空 target_order 必须一致,避免同一订单任务被拆错或合错。 + */ + private V3EventContractIssue inspectV4TargetOrderConsistency( + String orderRef, + JsonNode targetOrder, + Map targetOrderByOrderRef) { + if (hasV4TargetOrderUnresolvedMarker(targetOrder)) { + return null; + } + String key = textAt(targetOrder, "booking_type") + + "|" + + textAt(targetOrder, "locator_type") + + "|" + + textAt(targetOrder, "locator_value"); + String existing = targetOrderByOrderRef.putIfAbsent(orderRef, key); + if (existing != null && !existing.equals(key)) { + return new V3EventContractIssue("ORDER_REF_TARGET_ORDER_CONFLICT", "同一 order_ref 下 target_order 不一致。"); + } + return null; + } + + /** + * 校验 V4 event 专属字段。这里仅做入站基线和第一版 contract-error 分流,不执行完整业务确认。 + */ + private V3EventContractIssue inspectV4EventSpecificIssue(JsonNode event, V4PackageContext packageContext) { + return switch (textAt(event, "event_type")) { + case "NEW_BOOKING" -> inspectV4NewBookingIssue(event); + case "UPDATE_BOOKING" -> inspectV4UpdateBookingIssue(event); + case "CANCEL_BOOKING" -> null; + case "TRACE_RESERVATION_NOTES" -> inspectV4TraceIssue(event); + case "ROOMING_LIST" -> inspectV4RoomingListIssue(event); + case "PAYMENT" -> inspectV4PaymentIssue(event, packageContext); + default -> new V3EventContractIssue("EVENT_TYPE_UNSUPPORTED", "V4 event_type 不在第一版支持范围。"); + }; + } + + /** + * 校验 V4 New Booking 第一版必需字段和 room_items 结构。 + */ + private V3EventContractIssue inspectV4NewBookingIssue(JsonNode event) { + if (!event.has("arrival_date") + || !event.has("departure_date") + || !event.has("rate_code") + || !event.path("room_items").isArray() + || !isStringOrNull(event.path("arrival_date")) + || !isStringOrNull(event.path("departure_date")) + || !isStringOrNull(event.path("rate_code"))) { + return new V3EventContractIssue("NEW_BOOKING_CONTRACT_INVALID", "V4 NEW_BOOKING 字段不完整。"); + } + if ("GROUP".equals(textAt(event.path("target_order"), "booking_type")) + && !("STANDARD".equals(textAt(event, "booking_scenario")) + || "PROPOSAL".equals(textAt(event, "booking_scenario")))) { + return new V3EventContractIssue("NEW_BOOKING_SCENARIO_INVALID", "Group NEW_BOOKING booking_scenario 无效。"); + } + if ("FIT".equals(textAt(event.path("target_order"), "booking_type")) + && event.has("guest_name") + && !isStringOrNull(event.path("guest_name"))) { + return new V3EventContractIssue("NEW_BOOKING_GUEST_NAME_INVALID", "Fit NEW_BOOKING guest_name 必须为 string 或 null。"); + } + for (JsonNode roomItem : event.path("room_items")) { + if (!roomItem.isObject() + || !roomItem.has("room_type_code") + || !roomItem.has("room_count") + || !isStringOrNull(roomItem.path("room_type_code")) + || (!roomItem.path("room_count").isNull() + && (!roomItem.path("room_count").canConvertToInt() || roomItem.path("room_count").asInt() <= 0))) { + return new V3EventContractIssue("NEW_BOOKING_ROOM_ITEMS_INVALID", "V4 NEW_BOOKING room_items 结构无效。"); + } + } + return null; + } + + /** + * 校验 V4 Update Booking。Rate Code 在 Update 中属于业务契约错误,不能静默忽略。 + */ + private V3EventContractIssue inspectV4UpdateBookingIssue(JsonNode event) { + if (event.has("rate_code") || event.path("after").has("rate_code")) { + return new V3EventContractIssue("UPDATE_RATE_CODE_NOT_ALLOWED", "V4 UPDATE_BOOKING 不允许修改 rate_code。"); + } + JsonNode after = event.path("after"); + if (!after.isObject()) { + return new V3EventContractIssue("UPDATE_AFTER_CONTRACT_INVALID", "V4 UPDATE_BOOKING after 必须是对象。"); + } + if (after.has("room_items") + && !after.path("room_items").isNull() + && !after.path("room_items").isArray()) { + return new V3EventContractIssue("UPDATE_ROOM_ITEMS_CONTRACT_INVALID", "V4 UPDATE_BOOKING after.room_items 必须为 null 或数组。"); + } + if (after.path("room_items").isArray()) { + for (JsonNode roomItem : after.path("room_items")) { + if (!roomItem.isObject() + || !roomItem.has("room_type_code") + || !roomItem.has("room_count") + || !isStringOrNull(roomItem.path("room_type_code")) + || (!roomItem.path("room_count").isNull() + && (!roomItem.path("room_count").canConvertToInt() || roomItem.path("room_count").asInt() <= 0))) { + return new V3EventContractIssue("UPDATE_ROOM_ITEMS_CONTRACT_INVALID", "V4 UPDATE_BOOKING room_items 结构无效。"); + } + } + } + return null; + } + + /** + * 校验 V4 Trace 事项。EXTRA_BED 和 GENERAL 允许放在同一张 Trace 卡内。 + */ + private V3EventContractIssue inspectV4TraceIssue(JsonNode event) { + JsonNode traceItems = event.path("trace_items"); + if (!traceItems.isArray() || traceItems.isEmpty()) { + return new V3EventContractIssue("TRACE_ITEMS_CONTRACT_INVALID", "V4 TRACE_RESERVATION_NOTES trace_items 不能为空。"); + } + for (JsonNode traceItem : traceItems) { + String itemType = textAt(traceItem, "item_type"); + if ("GENERAL".equals(itemType)) { + if (!traceItem.has("text") + || !traceItem.has("department_code") + || !isStringOrNull(traceItem.path("text")) + || !isStringOrNull(traceItem.path("department_code"))) { + return new V3EventContractIssue("TRACE_GENERAL_ITEM_INVALID", "V4 GENERAL trace item 字段无效。"); + } + continue; + } + if ("EXTRA_BED".equals(itemType)) { + if (!traceItem.has("target_room_type_code") + || !traceItem.has("extra_bed_room_count") + || !traceItem.has("department_code") + || !isStringOrNull(traceItem.path("target_room_type_code")) + || !isStringOrNull(traceItem.path("department_code")) + || (!traceItem.path("extra_bed_room_count").isNull() + && (!traceItem.path("extra_bed_room_count").canConvertToInt() + || traceItem.path("extra_bed_room_count").asInt() <= 0))) { + return new V3EventContractIssue("TRACE_EXTRA_BED_ITEM_INVALID", "V4 EXTRA_BED trace item 字段无效。"); + } + continue; + } + return new V3EventContractIssue("TRACE_ITEM_TYPE_UNSUPPORTED", "V4 trace_items.item_type 只能为 GENERAL 或 EXTRA_BED。"); + } + return null; + } + + /** + * 校验 Rooming List 只适用于 Group,第一版不要求 rows 或附件 ID。 + */ + private V3EventContractIssue inspectV4RoomingListIssue(JsonNode event) { + if (!"GROUP".equals(textAt(event.path("target_order"), "booking_type"))) { + return new V3EventContractIssue("ROOMING_LIST_BOOKING_TYPE_INVALID", "V4 ROOMING_LIST 第一版只支持 Group。"); + } + return null; + } + + /** + * 校验 V4 Payment 附件引用。正常 Payment 必须引用同包已有附件 ID。 + */ + private V3EventContractIssue inspectV4PaymentIssue(JsonNode event, V4PackageContext packageContext) { + JsonNode attachmentIds = event.path("attachment_ids"); + if (!attachmentIds.isArray() || attachmentIds.isEmpty()) { + return new V3EventContractIssue("PAYMENT_ATTACHMENT_IDS_EMPTY", "V4 PAYMENT attachment_ids 不能为空。"); + } + Set seen = new LinkedHashSet<>(); + for (JsonNode attachmentIdNode : attachmentIds) { + String attachmentId = attachmentIdNode.isTextual() ? trimToNull(attachmentIdNode.asText()) : null; + if (attachmentId == null) { + return new V3EventContractIssue("PAYMENT_ATTACHMENT_ID_INVALID", "V4 PAYMENT attachment_ids 只能包含非空字符串。"); + } + if (!seen.add(attachmentId)) { + return new V3EventContractIssue("PAYMENT_ATTACHMENT_ID_DUPLICATE", "V4 PAYMENT attachment_ids 存在重复 ID。"); + } + if (!packageContext.attachmentIds().contains(attachmentId)) { + return new V3EventContractIssue("PAYMENT_ATTACHMENT_ID_NOT_FOUND", "V4 PAYMENT attachment_ids 引用了不存在的包级附件。"); + } + } + return null; + } + + /** + * 校验 manual_review=true 是否能由当前对象中的未解决字段解释,避免完整合法字段被误标复核。 + */ + private V3EventContractIssue inspectV4ManualReviewConsistencyIssue(JsonNode event) { + boolean manualReview = isBooleanTrue(event.path("manual_review")); + boolean unresolved = hasV4EventUnresolvedMarker(event); + if (manualReview && !unresolved) { + return new V3EventContractIssue("MANUAL_REVIEW_WITHOUT_UNRESOLVED_FIELD", "V4 manual_review=true 缺少可识别未解决字段。"); + } + if (!manualReview && unresolved) { + return new V3EventContractIssue("MANUAL_REVIEW_REQUIRED_FOR_UNRESOLVED_FIELD", "V4 未解决字段必须同时 manual_review=true。"); + } + return null; + } + + /** + * 判断 V4 event 是否存在被 Schema 允许的未解决标记。 + */ + private boolean hasV4EventUnresolvedMarker(JsonNode event) { + if (hasV4TargetOrderUnresolvedMarker(event.path("target_order"))) { + return true; + } + String eventType = textAt(event, "event_type"); + if ("NEW_BOOKING".equals(eventType)) { + return event.path("arrival_date").isNull() + || event.path("departure_date").isNull() + || event.path("rate_code").isNull() + || event.path("room_items").isEmpty() + || hasV4RoomItemsUnresolved(event.path("room_items")); + } + if ("UPDATE_BOOKING".equals(eventType)) { + JsonNode after = event.path("after"); + return hasNullChild(after) || after.path("room_items").isNull() || hasV4RoomItemsUnresolved(after.path("room_items")); + } + if ("TRACE_RESERVATION_NOTES".equals(eventType)) { + return hasV4TraceUnresolved(event.path("trace_items")); + } + return false; + } + + /** + * 判断 target_order 是否包含未解决字段。 + */ + private boolean hasV4TargetOrderUnresolvedMarker(JsonNode targetOrder) { + return targetOrder == null + || targetOrder.path("booking_type").isNull() + || targetOrder.path("locator_type").isNull() + || targetOrder.path("locator_value").isNull(); + } + + /** + * 判断 room_items 内是否有待人工补正字段。 + */ + private boolean hasV4RoomItemsUnresolved(JsonNode roomItems) { + if (roomItems == null || !roomItems.isArray()) { + return false; + } + for (JsonNode roomItem : roomItems) { + if (roomItem.path("room_type_code").isNull() || roomItem.path("room_count").isNull()) { + return true; + } + } + return false; + } + + /** + * 判断对象直接子字段是否包含 null,用于 V4 Update 稀疏 after 的未解决值识别。 + */ + private boolean hasNullChild(JsonNode node) { + if (node == null || !node.isObject()) { + return false; + } + var fields = node.fields(); + while (fields.hasNext()) { + if (fields.next().getValue().isNull()) { + return true; + } + } + return false; + } + + /** + * 判断 Trace item 是否存在允许人工复核补正的 null 字段。 + */ + private boolean hasV4TraceUnresolved(JsonNode traceItems) { + if (traceItems == null || !traceItems.isArray()) { + return false; + } + for (JsonNode traceItem : traceItems) { + if (traceItem.path("text").isNull() + || traceItem.path("department_code").isNull() + || traceItem.path("target_room_type_code").isNull() + || traceItem.path("extra_bed_room_count").isNull()) { + return true; + } + } + return false; + } + + /** + * V4 manual_review 仅允许 null 或布尔 true,不接受 false 或对象。 + */ + private boolean isV4ManualReviewNullOrTrue(JsonNode manualReview) { + return manualReview != null && (manualReview.isNull() || isBooleanTrue(manualReview)); + } + + /** + * 按 V4 event_type 和 target_order 派生现有稳定路由。 + */ + private ReservationAiRouteDefinition deriveV4EventRoute(JsonNode event) { + String resultType = isBooleanTrue(event.path("manual_review")) + ? AiResultType.MANUAL_REVIEW.code() + : AiResultType.NORMAL_TASK.code(); + String eventType = textAt(event, "event_type"); + String taskType = deriveV4TaskType(eventType); + String taskSubtype = deriveV4TaskSubtype(event); + if (taskType == null || taskSubtype == null) { + return null; + } + return ReservationAiRouteDefinition.findByTriplet(resultType, taskType, taskSubtype).orElse(null); + } + + /** + * V4 event_type 到当前系统 AI task_type 的最小映射。 + */ + private String deriveV4TaskType(String eventType) { + return switch (nullToEmpty(eventType)) { + case "NEW_BOOKING" -> "New Booking"; + case "UPDATE_BOOKING" -> "Update Booking"; + case "CANCEL_BOOKING" -> "Cancel Booking"; + case "TRACE_RESERVATION_NOTES" -> "Trace / Reservation Notes"; + case "ROOMING_LIST" -> "Rooming List"; + case "PAYMENT" -> "Payment Evidence"; + default -> null; + }; + } + + /** + * V4 event_type 到当前系统 task_subtype 的最小映射。后续 V4 多卡模型可替换这一层。 + */ + private String deriveV4TaskSubtype(JsonNode event) { + String eventType = textAt(event, "event_type"); + String bookingType = textAt(event.path("target_order"), "booking_type"); + return switch (nullToEmpty(eventType)) { + case "NEW_BOOKING" -> switch (nullToEmpty(bookingType)) { + case "GROUP" -> "new_group_block"; + case "FIT" -> "new_fit_reservation"; + default -> null; + }; + case "UPDATE_BOOKING" -> "update_booking_amendment"; + case "CANCEL_BOOKING" -> switch (nullToEmpty(bookingType)) { + case "GROUP" -> "cancel_group_block"; + case "FIT" -> "cancel_fit_reservation"; + default -> null; + }; + case "TRACE_RESERVATION_NOTES" -> v4TraceSubtype(event); + case "ROOMING_LIST" -> "rooming_list"; + case "PAYMENT" -> "payment_evidence"; + default -> null; + }; + } + + /** + * 同一 V4 Trace 卡可同时包含 GENERAL 和 EXTRA_BED;若包含加床,路由到 extra_bed 卡型。 + */ + private String v4TraceSubtype(JsonNode event) { + for (JsonNode traceItem : event.path("trace_items")) { + if ("EXTRA_BED".equals(textAt(traceItem, "item_type"))) { + return "extra_bed"; + } + } + return "general_request"; + } + + /** + * 将 V4 event 归一为当前任务创建链路可消费的 item,并把 V4 原始结构完整保存在 ai_payload_json 中。 + */ + private ObjectNode normalizeV4EventToTaskResultItem( + JsonNode root, + JsonNode event, + ReservationAiRouteDefinition route, + int arrayIndex) { + ObjectNode item = objectMapper.createObjectNode(); + item.put("source_event_index", arrayIndex); + item.put("catalog_code", V4_CATALOG_CODE); + item.put("skill_id", V4_SKILL_ID); + item.put("result_type", route.resultType()); + item.put("task_type", route.taskType()); + item.put("task_subtype", route.taskSubtype()); + item.put("current_or_history", "current"); + item.put("route_code", route.routeCode()); + item.put("system_process_category", route.systemProcessCategory().name()); + item.put("field_contract_version", V4_FIELD_CONTRACT_VERSION); + item.set("case_keys", buildV4CaseKeys(event.path("target_order"))); + item.set("extracted_fields", buildV4ExtractedFields(event)); + item.set("manual_review", event.path("manual_review")); + item.set("attachments", root.path("source_message").path("attachments")); + item.set("v4_source_message", root.path("source_message")); + item.set("v4_order_context", findV4OrderContext(root.path("order_contexts"), textAt(event, "order_ref"))); + item.set("v4_message_event", event); + return item; + } + + /** + * 将 V4 target_order 映射为旧任务链路可识别的 case_keys,同时保留 Booking Code 供后续 V4 投影使用。 + */ + private ObjectNode buildV4CaseKeys(JsonNode targetOrder) { + ObjectNode caseKeys = objectMapper.createObjectNode(); + caseKeys.putNull("group_code"); + caseKeys.putNull("confirmation_number"); + caseKeys.putNull("reservation_number"); + caseKeys.putNull("block_code"); + caseKeys.putNull("booking_code"); + String locatorType = textAt(targetOrder, "locator_type"); + String locatorValue = textAt(targetOrder, "locator_value"); + if ("GROUP_CODE".equals(locatorType) && trimToNull(locatorValue) != null) { + caseKeys.put("group_code", locatorValue); + caseKeys.put("block_code", locatorValue); + } else if ("CONFIRMATION_NUMBER".equals(locatorType) && trimToNull(locatorValue) != null) { + caseKeys.put("confirmation_number", locatorValue); + } else if ("BOOKING_CODE".equals(locatorType) && trimToNull(locatorValue) != null) { + caseKeys.put("booking_code", locatorValue); + } + caseKeys.set("target_order", targetOrder); + return caseKeys; + } + + /** + * 将 V4 业务字段搬入 extracted_fields,供当前任务详情和后续 V4 多卡模型读取。 + */ + private ObjectNode buildV4ExtractedFields(JsonNode event) { + ObjectNode fields = objectMapper.createObjectNode(); + fields.put("order_ref", textAt(event, "order_ref")); + fields.put("v4_event_type", textAt(event, "event_type")); + fields.set("target_order", event.path("target_order")); + String eventType = textAt(event, "event_type"); + if ("NEW_BOOKING".equals(eventType)) { + fields.put("booking_object_type", "GROUP".equals(textAt(event.path("target_order"), "booking_type")) + ? "Group Block" + : "FIT Reservation"); + copyFieldEvenIfNull(event, fields, "arrival_date"); + copyFieldEvenIfNull(event, fields, "departure_date"); + copyFieldEvenIfNull(event, fields, "rate_code"); + copyFieldEvenIfNull(event, fields, "booking_scenario"); + copyFieldEvenIfNull(event, fields, "guest_name"); + copyFieldEvenIfNull(event, fields, "room_items"); + } else if ("UPDATE_BOOKING".equals(eventType)) { + fields.set("after", event.path("after")); + } else if ("CANCEL_BOOKING".equals(eventType)) { + fields.put("cancel_object_type", "GROUP".equals(textAt(event.path("target_order"), "booking_type")) + ? "group_block" + : "fit_reservation"); + } else if ("TRACE_RESERVATION_NOTES".equals(eventType)) { + fields.set("trace_items", event.path("trace_items")); + fields.put("trace_subtype", v4TraceSubtype(event)); + } else if ("ROOMING_LIST".equals(eventType)) { + fields.put("rooming_list_detected", true); + } else if ("PAYMENT".equals(eventType)) { + fields.set("attachment_ids", event.path("attachment_ids")); + } + return fields; + } + + /** + * 查找 V4 event 对应的订单级上下文,未找到时返回空对象;正常情况下前置校验已保证存在。 + */ + private JsonNode findV4OrderContext(JsonNode orderContexts, String orderRef) { + for (JsonNode orderContext : orderContexts) { + if (orderRef != null && orderRef.equals(textAt(orderContext, "order_ref"))) { + return orderContext; + } + } + return objectMapper.createObjectNode(); + } + + /** + * V4 归一化需要保留显式 null,便于后续字段校验知道 Agent 确认了字段未解决。 + */ + private void copyFieldEvenIfNull(JsonNode source, ObjectNode target, String fieldName) { + if (source != null && source.has(fieldName)) { + target.set(fieldName, source.get(fieldName)); + } + } + /** * 校验 V2 兼容 ai_task_results[] 中不再允许当前 producer 继续提交的旧 Parent split 三元组。 */ @@ -1204,6 +2030,13 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta ? ReservationAiSystemProcessCategory.BUSINESS_TASK.name() : route.systemProcessCategory().name(); } + String fieldContractVersion = optionalText( + textAt(item, "field_contract_version"), + "field_contract_version", + LENGTH_64); + if (fieldContractVersion == null) { + fieldContractVersion = FIELD_CONTRACT_VERSION; + } JsonNode caseKeys = item.path("case_keys"); String currentOrHistory = optionalText(textAt(item, "current_or_history"), "current_or_history", LENGTH_32); String groupCode = optionalText(textAt(caseKeys, "group_code"), "group_code", LENGTH_128); @@ -1294,7 +2127,7 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta hotelId, taskCreation.taskId(), mapping.taskCardType().name(), - FIELD_CONTRACT_VERSION, + fieldContractVersion, itemPayloadJson, initialReviewStatus(resultType, mapping), now @@ -1903,6 +2736,34 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta || "S99".equals(routeCode); } + /** + * 判断是否为 V4 包级来源邮件通知。V4 用 order_contexts[] 区分新契约,不要求旧 result_type。 + */ + private boolean isV4SourceMessageNotification(JsonNode root) { + if (!isV4PackageCandidate(root)) { + return false; + } + String routeCode = trimToNull(textAt(root, "route_code")); + return "S10".equals(routeCode) || "S99".equals(routeCode); + } + + /** + * 判断是否为 V4 普通业务根。普通业务 route_code 必须为 null,具体合法性由 V4 validator 处理。 + */ + private boolean isV4BusinessRoot(JsonNode root) { + return isV4PackageCandidate(root) && trimToNull(textAt(root, "route_code")) == null; + } + + /** + * 判断是否为 V4 候选包。完整结构仍交给 V4 validator 报出清晰契约错误。 + */ + private boolean isV4PackageCandidate(JsonNode root) { + return root != null + && root.has("source_message") + && ((root.has("order_contexts") && root.has("message_events")) + || (root.has("route_code") && (root.has("order_contexts") || root.has("message_events")))); + } + /** * 判断是否为 V3 业务根,业务根必须携带 source_message 和 message_events 数组。 */ @@ -1942,6 +2803,73 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta String adapterErrorCode, String adapterErrorMessage, LocalDateTime now) { + return createAdapterContractErrorTransition( + hotelId, + sourceMessageId, + batchId, + event, + arrayIndex, + sourceEventIndex, + route, + adapterErrorCode, + adapterErrorMessage, + now, + V3_CATALOG_CODE, + V3_SKILL_ID); + } + + /** + * 保存 V4 event 的 Adapter 契约错误。V4 event 没有旧 case_keys,入库前派生查询用 key。 + */ + private SuperAgentTaskResultItemResponse createV4AdapterContractErrorTransition( + String hotelId, + Long sourceMessageId, + Long batchId, + JsonNode event, + int arrayIndex, + String adapterErrorCode, + String adapterErrorMessage, + LocalDateTime now) { + ObjectNode eventForTransition = event == null || !event.isObject() + ? objectMapper.createObjectNode() + : event.deepCopy(); + if (event != null && event.path("target_order").isObject()) { + eventForTransition.set("case_keys", buildV4CaseKeys(event.path("target_order"))); + } + if (event != null) { + eventForTransition.set("v4_message_event", event); + } + return createAdapterContractErrorTransition( + hotelId, + sourceMessageId, + batchId, + eventForTransition, + arrayIndex, + arrayIndex, + null, + adapterErrorCode, + adapterErrorMessage, + now, + V4_CATALOG_CODE, + V4_SKILL_ID); + } + + /** + * 保存 Adapter 契约错误 transition 的通用实现。catalog / skill 用于区分 V3 与 V4 入站来源。 + */ + private SuperAgentTaskResultItemResponse createAdapterContractErrorTransition( + String hotelId, + Long sourceMessageId, + Long batchId, + JsonNode event, + int arrayIndex, + int sourceEventIndex, + ReservationAiRouteDefinition route, + String adapterErrorCode, + String adapterErrorMessage, + LocalDateTime now, + String catalogCode, + String skillId) { JsonNode caseKeys = event == null ? null : event.path("case_keys"); JsonNode manualReview = event == null ? null : event.path("manual_review"); String resultType = route == null ? AiResultType.ADAPTER_CONTRACT_ERROR.code() : route.resultType(); @@ -1975,8 +2903,8 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta + "|" + sourceMessageId + "|" + sourceEventIndex + "|" + arrayIndex - + "|" + V3_CATALOG_CODE - + "|" + V3_SKILL_ID + + "|" + catalogCode + + "|" + skillId + "|" + resultType + "|" + aiTaskType + "|" + nullToEmpty(taskSubtype) @@ -1989,8 +2917,8 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta sourceEventIndex, arrayIndex, arrayIndex, - V3_CATALOG_CODE, - V3_SKILL_ID, + catalogCode, + skillId, resultType, aiTaskType, routeCode, @@ -2327,6 +3255,15 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta ) { } + /** + * V4 包级上下文。orderRefs 用于校验 event 归属,attachmentIds 用于校验 Payment 附件引用。 + */ + private record V4PackageContext( + Set orderRefs, + Set attachmentIds + ) { + } + /** * 将空白字符串转换为 null。 */ 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 27b8027..882ddd1 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 @@ -989,6 +989,138 @@ class SuperAgentTaskResultControllerTest { assertThat(transitionCount).isEqualTo(1L); } + @Test + void shouldCreateBusinessTasksFromV4OrderContextsAndMessageEventsInOrder() throws Exception { + SourceMessageCaptureResult source = captureSourceMessage("mail-v4-business-root-001"); + String body = v4BusinessRootBody("mail-v4-business-root-001"); + + MvcResult result = mockMvc.perform(signedPost(body, "nonce-v4-business-root-001")) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.source_message_id").value("mail-v4-business-root-001")) + .andExpect(jsonPath("$.accepted_count").value(2)) + .andExpect(jsonPath("$.items[0].source_event_index").value(1)) + .andExpect(jsonPath("$.items[0].array_index").value(1)) + .andExpect(jsonPath("$.items[0].route_code").value("R02_NEW_GROUP_BLOCK_NORMAL")) + .andExpect(jsonPath("$.items[0].system_task_type").value("NEW_BOOKING")) + .andExpect(jsonPath("$.items[0].task_card_type").value("NEW_BOOKING")) + .andExpect(jsonPath("$.items[0].execution_order").value(1)) + .andExpect(jsonPath("$.items[1].source_event_index").value(2)) + .andExpect(jsonPath("$.items[1].array_index").value(2)) + .andExpect(jsonPath("$.items[1].route_code").value("R10_PAYMENT_EVIDENCE_NORMAL")) + .andExpect(jsonPath("$.items[1].system_task_type").value("UPDATE_BOOKING")) + .andExpect(jsonPath("$.items[1].task_card_type").value("PAYMENT_EVIDENCE")) + .andExpect(jsonPath("$.items[1].execution_order").value(2)) + .andReturn(); + + String firstOrderId = com.jayway.jsonpath.JsonPath.read( + result.getResponse().getContentAsString(), + "$.items[0].order_id"); + String secondOrderId = com.jayway.jsonpath.JsonPath.read( + result.getResponse().getContentAsString(), + "$.items[1].order_id"); + assertThat(secondOrderId).isEqualTo(firstOrderId); + + Long transitionCount = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) + FROM workflow_reservation_ai_transition + WHERE source_message_id = ? + AND catalog_code = 'M002V4' + AND skill_id = 'booking-desk-event-v4' + AND route_code IN ('R02_NEW_GROUP_BLOCK_NORMAL', 'R10_PAYMENT_EVIDENCE_NORMAL') + AND ai_payload_json LIKE '%"v4_message_event"%' + """, Long.class, source.inboxId()); + assertThat(transitionCount).isEqualTo(2L); + } + + @Test + void shouldCreateReadOnlyTaskForV4S10WithoutLegacyResultType() throws Exception { + SourceMessageCaptureResult source = captureSourceMessage("mail-v4-s10-entry-result-001"); + String body = v4S10Body("mail-v4-s10-entry-result-001"); + + mockMvc.perform(signedPost(body, "nonce-v4-s10-entry-result-001")) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.source_message_id").value("mail-v4-s10-entry-result-001")) + .andExpect(jsonPath("$.accepted_count").value(1)) + .andExpect(jsonPath("$.items[0].route_code").value("S10")) + .andExpect(jsonPath("$.items[0].system_process_category").value("SOURCE_MESSAGE_NOTIFICATION")) + .andExpect(jsonPath("$.items[0].system_task_type").value("SOURCE_MESSAGE_ONLY")) + .andExpect(jsonPath("$.items[0].task_status").value("COMPLETED")); + + Long transitionCount = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) + FROM workflow_reservation_ai_transition + WHERE source_message_id = ? + AND result_type = 'source_message_review_notification' + AND ai_task_type = 'S10' + AND route_code = 'S10' + """, Long.class, source.inboxId()); + assertThat(transitionCount).isEqualTo(1L); + } + + @Test + void shouldRejectMalformedV4PackageWithClearAdapterContractError() throws Exception { + String body = v4MalformedMissingOrderContextsBody("mail-v4-malformed-missing-order-contexts-001"); + + mockMvc.perform(signedPost(body, "nonce-v4-malformed-missing-order-contexts-001")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error_code").value("ADAPTER_CONTRACT_ERROR")) + .andExpect(jsonPath("$.message").value("V4 根结构字段不完整。")); + } + + @Test + void shouldPersistAdapterContractErrorWhenV4PaymentReferencesUnknownAttachment() throws Exception { + SourceMessageCaptureResult source = captureSourceMessage("mail-v4-payment-unknown-attachment-001"); + String body = v4PaymentBody("mail-v4-payment-unknown-attachment-001", "[\"att-missing\"]"); + + mockMvc.perform(signedPost(body, "nonce-v4-payment-unknown-attachment-001")) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.accepted_count").value(1)) + .andExpect(jsonPath("$.items[0].adapter_error_code").value("PAYMENT_ATTACHMENT_ID_NOT_FOUND")) + .andExpect(jsonPath("$.items[0].task_id").doesNotExist()); + + Long taskCount = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) + FROM workflow_reservation_task + WHERE source_message_id = ? + """, Long.class, source.inboxId()); + Long adapterErrorCount = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) + FROM workflow_reservation_ai_transition + WHERE source_message_id = ? + AND system_process_category = 'ADAPTER_CONTRACT_ERROR' + AND adapter_error_code = 'PAYMENT_ATTACHMENT_ID_NOT_FOUND' + """, Long.class, source.inboxId()); + assertThat(taskCount).isZero(); + assertThat(adapterErrorCount).isEqualTo(1L); + } + + @Test + void shouldPersistAdapterContractErrorWhenV4UpdateContainsRateCode() throws Exception { + SourceMessageCaptureResult source = captureSourceMessage("mail-v4-update-rate-code-001"); + String body = v4UpdateWithRateCodeBody("mail-v4-update-rate-code-001"); + + mockMvc.perform(signedPost(body, "nonce-v4-update-rate-code-001")) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.accepted_count").value(1)) + .andExpect(jsonPath("$.items[0].adapter_error_code").value("UPDATE_RATE_CODE_NOT_ALLOWED")) + .andExpect(jsonPath("$.items[0].task_id").doesNotExist()); + + Long taskCount = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) + FROM workflow_reservation_task + WHERE source_message_id = ? + """, Long.class, source.inboxId()); + Long adapterErrorCount = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) + FROM workflow_reservation_ai_transition + WHERE source_message_id = ? + AND system_process_category = 'ADAPTER_CONTRACT_ERROR' + AND adapter_error_code = 'UPDATE_RATE_CODE_NOT_ALLOWED' + """, Long.class, source.inboxId()); + assertThat(taskCount).isZero(); + assertThat(adapterErrorCount).isEqualTo(1L); + } + @Test void shouldAcceptV2TaskResultWhenSourceMessageStoredAsOutlookChannelWithoutSourceChannel() throws Exception { String externalMessageId = "mail-v2-task-result-outlook-channel-001"; @@ -3183,6 +3315,204 @@ class SuperAgentTaskResultControllerTest { """.formatted(externalSourceMessageId); } + private String v4BusinessRootBody(String externalSourceMessageId) { + return """ + { + "route_code": null, + "source_message": { + "source_message_id": "%s", + "conversation_id": "thread-v4-001", + "subject": "Group booking and payment", + "sender": "agent@example.test", + "sent_at": "2026-07-18T02:10:00Z", + "body": "Please create group GRP-V4-001 and note payment attached.", + "body_content_type": "text/plain", + "attachments": [ + { + "id": "att-pay-1", + "name": "payment-slip.jpg", + "content_type": "image/jpeg", + "url": "https://oss.example.test/payment-slip.jpg", + "size": 251524 + } + ] + }, + "order_contexts": [ + { + "order_ref": "order-1", + "basic_information": { + "account_code": "QBD_TRAVEL", + "manual_review": null + } + } + ], + "message_events": [ + { + "order_ref": "order-1", + "event_type": "NEW_BOOKING", + "target_order": { + "booking_type": "GROUP", + "locator_type": "GROUP_CODE", + "locator_value": "GRP-V4-001" + }, + "arrival_date": "2026-07-26", + "departure_date": "2026-07-29", + "rate_code": "BAR", + "booking_scenario": "STANDARD", + "room_items": [ + { + "room_type_code": "TWN", + "room_count": 2 + } + ], + "manual_review": null + }, + { + "order_ref": "order-1", + "event_type": "PAYMENT", + "target_order": { + "booking_type": "GROUP", + "locator_type": "GROUP_CODE", + "locator_value": "GRP-V4-001" + }, + "attachment_ids": ["att-pay-1"], + "manual_review": null + } + ] + } + """.formatted(externalSourceMessageId); + } + + private String v4S10Body(String externalSourceMessageId) { + return """ + { + "route_code": "S10", + "source_message": { + "source_message_id": "%s", + "conversation_id": "thread-v4-s10-001", + "subject": "Thanks", + "sender": "guest@example.test", + "sent_at": "2026-07-18T02:10:00Z", + "body": "Noted with thanks.", + "body_content_type": "text/plain", + "attachments": [] + }, + "order_contexts": [], + "message_events": [] + } + """.formatted(externalSourceMessageId); + } + + private String v4MalformedMissingOrderContextsBody(String externalSourceMessageId) { + return """ + { + "route_code": null, + "source_message": { + "source_message_id": "%s", + "conversation_id": "thread-v4-malformed-001", + "subject": "Malformed V4 package", + "sender": "agent@example.test", + "sent_at": "2026-07-18T02:10:00Z", + "body": "Missing order contexts.", + "body_content_type": "text/plain", + "attachments": [] + }, + "message_events": [] + } + """.formatted(externalSourceMessageId); + } + + private String v4PaymentBody(String externalSourceMessageId, String attachmentIdsJson) { + return """ + { + "route_code": null, + "source_message": { + "source_message_id": "%s", + "conversation_id": "thread-v4-payment-001", + "subject": "Payment evidence", + "sender": "agent@example.test", + "sent_at": "2026-07-18T02:10:00Z", + "body": "Payment attached.", + "body_content_type": "text/plain", + "attachments": [ + { + "id": "att-pay-1", + "name": "payment-slip.jpg", + "content_type": "image/jpeg", + "url": "https://oss.example.test/payment-slip.jpg", + "size": 251524 + } + ] + }, + "order_contexts": [ + { + "order_ref": "order-1", + "basic_information": { + "account_code": "QBD_TRAVEL", + "manual_review": null + } + } + ], + "message_events": [ + { + "order_ref": "order-1", + "event_type": "PAYMENT", + "target_order": { + "booking_type": "GROUP", + "locator_type": "GROUP_CODE", + "locator_value": "GRP-V4-PAY-001" + }, + "attachment_ids": %s, + "manual_review": null + } + ] + } + """.formatted(externalSourceMessageId, attachmentIdsJson); + } + + private String v4UpdateWithRateCodeBody(String externalSourceMessageId) { + return """ + { + "route_code": null, + "source_message": { + "source_message_id": "%s", + "conversation_id": "thread-v4-update-rate-001", + "subject": "Update booking", + "sender": "agent@example.test", + "sent_at": "2026-07-18T02:10:00Z", + "body": "Please update stay and rate code.", + "body_content_type": "text/plain", + "attachments": [] + }, + "order_contexts": [ + { + "order_ref": "order-1", + "basic_information": { + "account_code": "QBD_TRAVEL", + "manual_review": null + } + } + ], + "message_events": [ + { + "order_ref": "order-1", + "event_type": "UPDATE_BOOKING", + "target_order": { + "booking_type": "GROUP", + "locator_type": "GROUP_CODE", + "locator_value": "GRP-V4-UPD-001" + }, + "after": { + "arrival_date": "2026-08-01", + "rate_code": "BAR" + }, + "manual_review": null + } + ] + } + """.formatted(externalSourceMessageId); + } + private String[] createReadyTaskWithTwoOperaOperations( String externalMessageId, String nonce,