diff --git a/docs/project/frontend-backend/backend-to-frontend-notes.md b/docs/project/frontend-backend/backend-to-frontend-notes.md index aa9dbda..1100952 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 任务详情、订单详情、任务列表、S000/S999 特殊只读任务、历史 Message Notification、系统管理后台等第一版页面。 +本文记录后端侧提醒前端开发时必须注意的项目规范、业务规则、接口边界和安全要求。当前内容服务于 Reservation 任务详情、订单详情、任务列表、S10/S99 源邮件只读通知卡、旧 S000/S999 兼容展示、历史 Message Notification、系统管理后台等第一版页面。 ## 2. 项目开发注意事项 @@ -18,9 +18,11 @@ ## 3. 字段来源注意事项 -- `docs/import/20260708/任务卡前端展示字段表 3.0.xlsx` 是前端展示 / 编辑白名单。 +- `docs/import/20260711/开发交付_P0冻结基线_2026-07-11/01_Adapter_Frontend/任务卡前端字段变更与路由说明_3.0_to_current.md` 是当前前端 / Adapter 路由说明,覆盖 42 路由、S10/S99、type-known manual review 和 fail-closed 口径。 +- `docs/import/20260711/开发交付_P0冻结基线_2026-07-11/01_Adapter_Frontend/任务卡前端字段变更说明_3.0_to_当前版_2026-07-10.xlsx` 是当前前端展示 / 编辑白名单和三元组路由表。 +- `docs/import/20260708/任务卡前端展示字段表 3.0.xlsx` 是历史前端展示 / 编辑白名单,已被 0711 P0 冻结基线承接。 - `docs/import/20260706/任务卡展示编辑矩阵.xlsx` 是后端校验、最终确认写入、OPERA 映射和展示条件的完整规则来源。 -- 前端不要直接把整个 `ai_task_results[]` 渲染成表单,只展示白名单允许的字段。 +- 前端不要直接把整个旧 `ai_task_results[]` 或 V3 `message_events[]` 渲染成表单,只展示白名单允许的字段。 - 如果 3.0 白名单与旧矩阵冲突,应记录为前后端待确认问题,不由前端单方面放宽必填、枚举或校验规则。 ## 4. 业务规则注意事项 @@ -29,9 +31,11 @@ - 用户可以修改任务字段内容;最终确认后,后端使用 `confirmed_payload_json` 作为 OPERA 模拟输入来源。 - 同一订单下,前置任务未结束时,后续任务只能查看,不能编辑、确认或执行 OPERA 模拟操作。 - 任务状态 `FAILED` 第一版视为结束状态,不阻塞后续任务;但失败的 OPERA 操作不能跳过,必须展示失败原因并允许重试。 -- SuperAgent 新入口结果只会生成 `normal_task`、`manual_review` 或纯文本 `S000/S999`;`informational_message` 仅历史兼容,新页面不要再按新数据入口依赖它。 -- `S000` 表示纯信息类邮件,`S999` 表示无法形成业务素材包。后端会创建 `SOURCE_MESSAGE_ONLY` 只读特殊任务,任务列表可见,订单列表不可见。 -- `SOURCE_MESSAGE_ONLY` 任务不允许编辑、确认、人工转换订单、执行 OPERA 或重试 OPERA;不参与订单任务执行队列,不阻塞其他任务,也不被其他任务阻塞。 +- M002 V3 新入口采用结构化 `S10/S99`:`S10` 表示未匹配当前支持的业务事件,`S99` 表示输入不足或无法形成业务素材包;旧 `S000/S999` 继续按历史数据兼容展示。 +- `S10/S99` 后端会创建只读源邮件通知卡,任务列表可见,订单列表不可见;当前代码中的旧 `SOURCE_MESSAGE_ONLY` 任务仍按同一只读语义展示。 +- 源邮件只读通知卡不允许编辑、确认、人工转换订单、执行 OPERA 或重试 OPERA;不参与订单任务执行队列,不阻塞其他任务,也不被其他任务阻塞。 +- type-known manual review 后续应展示为原业务任务卡的复核模式,不应统一展示成 Fallback。只有业务类型或 subtype 本身未知时才进入 Fallback。 +- 复核场景下允许用户确认订单归属;这不等于开放普通任务任意切换订单。 - 历史 Message Notification 挂临时订单,只读展示,不参与订单任务执行队列,不阻塞其他任务,也不被其他任务阻塞。 - Fallback / manual_review 转为 New / Update / Cancel 时需要展示审计轨迹;登录权限底座已提供,具体业务审计 actor 迁移仍后置。 - 普通任务切换订单接口已确认后置,前端第一版不要把普通任务拖拽或切换订单做成可提交能力。 @@ -43,10 +47,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`;隐藏技术订单不返回,因此 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` | 查询订单列表 | 默认返回全部订单状态;`open_task_count` 排除 `COMPLETED` 和 `FAILED`;隐藏技术订单不返回,因此 S10/S99 和旧 S000/S999 不会在订单列表形成订单。 | +| `GET /api/reservation/tasks` | 查询任务列表 / 工作台 | 用 `can_process` 和 `readonly_reason_code` 控制入口按钮;列表不返回 AI 原始 payload、邮件正文或附件 URL;已返回来源邮件会话摘要字段,并支持 `order_status` 按任务所属订单状态筛选;旧 S000/S999 和新 S10/S99 当前都会以 `task_type=SOURCE_MESSAGE_ONLY` 只读任务返回,任务结果回调响应已包含 `route_code/system_process_category`,列表接口完整透出 V3 路由字段后续补齐。 | | `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 入口结果。 | +| `GET /api/reservation/tasks/{taskId}` | 查询任务详情 | 以返回的可处理状态和只读原因控制按钮,不只看任务状态;`fields[]` 已包含 P0 字段元数据;源邮件只读通知卡字段列表和 OPERA 操作列表为空,旧数据通过 `source_message_only_result` 返回 S000/S999 入口结果;结构化 S10/S99 已可入站并保存,详情接口完整透出 `agent_assessment/notification/manual_review` 后续补齐。 | | `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,不用于普通任务切换订单。 | @@ -76,7 +80,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 字段和 S000/S999 识别。 | 只用于调试页面;请求为 multipart/form-data;必须传 `X-TH-Hotel-Debug-Upload-Key`,但该 key 不能写进前端源码、构建产物、URL、localStorage 或错误上报;SuperAgent 返回 S000/S999 时不是 JSON 解析失败。 | +| `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 或错误上报;SuperAgent 返回旧 S000/S999 或新 S10/S99 入口通知时都不应被前端视为 JSON 解析失败。 | 酒店上下文注意:Reservation 列表、订单详情、任务列表和 Debug EML 上传的 `hotel_id` 第一版都是可选参数。前端默认可以不传;后端会按当前登录用户酒店上下文或平台酒店表唯一 `ACTIVE` 酒店解析。如果前端传了当前选中酒店,后端会校验该酒店是否可访问。 @@ -135,10 +139,10 @@ 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` 可能为空,前端不要因此隐藏整条任务。 -- 当前前端已按 `SOURCE_MESSAGE_ONLY` 展示 S000/S999,只在任务列表和任务详情提供只读查看入口;`INFORMATIONAL_MESSAGE` 仅作为历史 Message Notification 兼容路径保留。 -- 任务列表里 `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`;普通任务该字段为空。 +- 源邮件只读通知卡背后有隐藏技术订单用于满足后端任务外键,但订单列表不会返回该订单;任务列表中该类任务的 `display_order_key`、`temporary_order_no`、`group_code`、`confirmation_number` 可能为空,前端不要因此隐藏整条任务。 +- 当前前端已按 `SOURCE_MESSAGE_ONLY` 展示旧 S000/S999;后端回调已支持结构化 `route_code=S10/S99`、`result_type=source_message_review_notification` 的新入口通知,并继续只在任务列表和任务详情提供只读查看入口;`INFORMATIONAL_MESSAGE` 仅作为历史 Message Notification 兼容路径保留。 +- 任务列表里旧 `task_type=SOURCE_MESSAGE_ONLY`、`task_subtype=S000/S999` 或新 `task_subtype=S10/S99` 的记录只展示邮件来源和 SuperAgent 入口结果,不展示处理按钮。 +- 任务详情里 `source_message_only_result` 仅对 `SOURCE_MESSAGE_ONLY` 返回,包含 `entry_result_code`、`entry_result_meaning`、`entry_result_description`、`entry_result_source_message_id` 和 `raw_answer`;结构化 S10/S99 已在入站 payload 中保存 `route_code`,但详情接口完整透出 `agent_assessment`、`notification` 和入口 `manual_review` 仍待后续 checkpoint;普通业务任务该字段为空。 ### 5.6 前端联调演示数据 seed 接口 @@ -174,12 +178,12 @@ Content-Type: application/json - 已完成 New Booking 任务和两条 OPERA 模拟成功记录。 - OPERA 模拟失败任务,可在任务详情看到失败 attempt 和重试入口。 - Fallback / manual_review 任务。 -- 历史 Message Notification 只读任务;S000/S999 特殊只读任务可通过 SuperAgent 回调或后续专用夹具补充。 +- 历史 Message Notification 只读任务;旧 S000/S999 和新 S10/S99 特殊只读任务可通过 SuperAgent 回调补充,专用前端 fixture 仍可后续增加。 - 同一邮件会话下多封邮件、完整 HTML、附件外链和内联图片外链。 ### 5.7 任务详情字段元数据接入注意 -- `fields[]` 第一版服务于任务详情动态展示,字段来源与白名单规则以 `docs/import/20260708/任务卡前端展示字段表 3.0.xlsx` 为准。 +- `fields[]` 第一版服务于任务详情动态展示,字段来源与白名单规则后续以 `docs/import/20260711/开发交付_P0冻结基线_2026-07-11/01_Adapter_Frontend/任务卡前端字段变更说明_3.0_to_当前版_2026-07-10.xlsx` 和同目录路由说明为准;当前代码中仍有 20260708 白名单兼容口径。 - `result_type`、`task_type`、`task_subtype`、`default_value_source` 已透出给前端,用于和最新前端白名单对齐。 - 后端校验、最终确认写入、OPERA 映射和展示条件仍以 `docs/import/20260706/任务卡展示编辑矩阵.xlsx` 为完整规则来源。 - 前端保存草稿时不要自行按 `write_path` 重组 OPERA 参数;第一版按任务详情返回的字段和值提交即可,真实 OPERA 参数组装后续由后端 adapter / 转换层处理。 @@ -215,7 +219,7 @@ run_label: 可选调试标签 - `X-TH-Hotel-Debug-Upload-Key` 只能由调试人员在受控环境手动提供,不能放入 `VITE_*`、源码、构建产物、URL query、localStorage、错误上报或普通日志。 - 返回的 `html_body_sanitized` 复用邮件会话详情的安全策略,前端展示 HTML 时优先使用;`html_body_with_oss_urls` 只作为调试原始处理结果,不建议直接渲染。 - 返回的 `uploaded_media[]`、`original_eml_oss_url`、`html_body_with_oss_urls`、`html_body_sanitized` 可能包含 OSS URL;前端不要写入普通日志、埋点、错误上报或 URL query。 -- `superagent_parsed_json` 为空时,前端展示 `superagent_raw_answer` 和 `warnings[]`,不要假定 SuperAgent 总能返回 JSON。 +- `superagent_parsed_json` 为空时,前端展示 `superagent_raw_answer` 和 `warnings[]`,不要假定 SuperAgent 总能返回业务 JSON;旧 S000/S999 和新 S10/S99 都属于可解释入口结果,不是普通解析失败。 ### 5.9 系统管理后台接口接入注意 @@ -264,6 +268,7 @@ run_label: 可选调试标签 ## 7. 需要持续提醒的后置事项 - 普通任务切换订单接口继续后置。 +- M002 V3 的结构化 `S10/S99` 入站、42 路由枚举 / 稳定配置、`UNHANDLED_CURRENT_INTENT` 和 `adapter_contract_error` transition 最小落库已完成;type-known manual review 同卡解阻、复核场景订单归属确认、任务列表 / 详情完整透出 V3 路由字段仍需后续后端 checkpoint。前端新页面应预留字段和状态,不要继续只写死旧 `S000/S999`。 - 系统管理后台 V1 已完成;后续若要做用户搜索更多筛选、批量操作、密码策略增强、MFA、登录设备管理,应单独开需求。 - 现有 Reservation / SourceMessage 业务接口的强制登录、强制权限和业务审计 actor 全量迁移仍后置。 - 真实 OPERA / OHIP 接入继续后置。 diff --git a/docs/project/go-live-notes.md b/docs/project/go-live-notes.md index 429b394..dc8bf11 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 过渡层、订单、任务和任务卡;同时支持 `S000/S999,source_message_id` 文本入口结果并创建只读特殊任务。 +- SuperAgent 任务结果接收接口:当前代码接收一个外部 `source_message_id` 下的 AI 任务结果,反查 SourceMessage Inbox 后写入 AI 过渡层、订单、任务和任务卡;支持 V3 结构化 `S10/S99` 和业务根基础解析,同时兼容旧 `S000/S999,source_message_id` 文本入口结果并创建只读特殊任务。 - Reservation 任务详情接口:返回任务字段、队列可处理状态和 OPERA 模拟操作摘要。 - Reservation 任务草稿保存和最终确认接口:按任务卡矩阵做第一版后端校验,确认后生成 `confirmed_payload_json`。 - Reservation OPERA 模拟骨架:已确认任务固定生成两条模拟操作,支持执行、失败重试、attempt 记录和任务审计列表。 @@ -29,6 +29,7 @@ - 业务前端页面展示邮件原文。 - OHIP / OPERA 或其他业务系统真实写操作。 - 普通任务切换订单接口。 +- M002 V3 type-known manual review 同卡解阻、`missing_fields[]` 到任务卡字段白名单映射、typed `infrastructure_input_error` 响应、复核场景订单归属确认、任务列表 / 详情完整透出 V3 路由字段。 - 现有业务接口强制登录和强制权限拦截。 - 业务审计 actor 全量迁移到当前登录用户。 - Debug EML 上传链路不属于生产普通业务页面能力,生产默认关闭;即使已有登录权限,也不要开放给普通用户。 @@ -43,7 +44,7 @@ - 所有 Secret 都通过环境变量、部署平台 Secret 或密钥管理系统注入,不写入仓库、镜像、前端环境变量或普通配置文件。 - 生产默认不保存 AgentBus raw frame 样本。 - AgentBus 实时链路开启前,已经确认 WebSocket URL、Token、Bot Address、外部消息幂等键和断线重连语义。 -- S000/S999 特殊入口结果上线前,必须确认 `platform_hotel` 中存在且只存在一家 `ACTIVE` 酒店,并且已有 SourceMessage Inbox 数据的 `hotel_id` 与该酒店一致。 +- 源邮件只读通知卡上线前,必须确认 `platform_hotel` 中存在且只存在一家 `ACTIVE` 酒店,并且已有 SourceMessage Inbox 数据的 `hotel_id` 与该酒店一致。旧 `S000/S999` 和新结构化 `S10/S99` 都沿用该酒店解析约束。 - 系统管理后台上线前,必须确认至少存在一个 `ACTIVE` 超级管理员账号,且该账号拥有 `SYSTEM_ADMIN_CONSOLE_ACCESS` 和各系统管理权限。 - 单酒店阶段上线前,必须确认 `platform_hotel` 中只有一家 `ACTIVE` 酒店;新增酒店可以存在但应保持 `DISABLED`。 - 管理后台启用后,不要继续把手工改库作为常规运营方式;用户、角色、菜单和酒店变更应通过 `/api/admin/**` 并写入管理审计。 @@ -137,9 +138,10 @@ - SuperAgent 侧也需要配置同一个 secret,并按原始请求体计算 SHA-256。 - 当前第一版只支持一个 HMAC secret,secret 轮换需要协调部署窗口。 - 任务结果通知接口 JSON body 里的 `source_message_id` 是外部来源消息 ID,对应 AgentBus `source.external_message_id`;SuperAgent 默认不传 `hotel_id`,后端用系统酒店 `hotel_id + provider + channel + external_message_id` 反查内部 SourceMessage Inbox。 -- 任务结果通知接口也支持 `text/plain` 的 `S000,source_message_id` 和 `S999,source_message_id`。这类请求不在 body 里带 `hotel_id`,后端同样使用平台酒店表唯一 `ACTIVE` 酒店查询 SourceMessage Inbox。 +- 当前代码的任务结果通知接口也支持 `text/plain` 的 `S000,source_message_id` 和 `S999,source_message_id`。这类请求不在 body 里带 `hotel_id`,后端同样使用平台酒店表唯一 `ACTIVE` 酒店查询 SourceMessage Inbox。 +- M002 V3 已支持结构化 `S10/S99`、V3 业务根基础解析、`UNHANDLED_CURRENT_INTENT` 和 `adapter_contract_error` 最小落库:上线前必须单独验证新 JSON 入站、旧 S000/S999 兼容、隐藏技术订单、任务列表可见、订单列表不可见和只读限制。 - `application/json` 和 `text/plain` 都必须使用原始请求体计算 SHA-256 并参与 HMAC 签名;SuperAgent 侧不能签名格式化后的 JSON 或二次拼接字符串。 -- S000/S999 会创建 `SOURCE_MESSAGE_ONLY` 只读特殊任务和隐藏技术订单,任务列表可见,订单列表不可见,不允许编辑、确认、转换订单或执行 OPERA。 +- 旧 S000/S999 会创建 `SOURCE_MESSAGE_ONLY` 只读特殊任务和隐藏技术订单,任务列表可见,订单列表不可见,不允许编辑、确认、转换订单或执行 OPERA。V3 S10/S99 应保持同等只读和不可执行边界。 - SuperAgent 查询上下文接口中的 `source_message_id`、`source_event_index` 第一版仅兼容接收,不参与查询和校验;不要依赖它们限制查询范围。 ### 3.6 Debug EML / SuperAgent Open API / 阿里云 OSS @@ -173,7 +175,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 Open API 返回的旧 `S000/S999,source_message_id`,并在 `superagent_parsed_json` 中返回结构化入口结果;这不是 JSON 解析失败。结构化 `S10/S99` 可通过任务结果通知接口入站,Debug EML 页面若要直接展示完整 V3 入口结构,前端展示仍需继续补齐。 - Debug EML 第一版只展示 SuperAgent 结果,不创建订单、不创建任务、不调用任务结果通知接口。 - AgentBus 实时收到邮件后自动推 SuperAgent 当前未实现,不能把 Debug EML 链路等同于生产实时自动处理链路。 @@ -191,6 +193,7 @@ - `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` +- `server/src/main/resources/db/migration/V16__add_m002_v3_ai_route_fields.sql` 当前 M004 Debug EML 相关 migration: @@ -224,6 +227,7 @@ - 详细时间设计、页面展示和按酒店本地日期筛选规则见 `docs/project/backend-time-design.md`。 - 执行 V4 前,如果目标库已有 M002 试运行数据,必须先检查 ACTIVE 订单业务号重复和同订单任务队列序号重复。 - 执行 V5 / V6 前,如果目标库已有 M002 试运行数据,必须确认任务草稿、确认 payload 和 OPERA 模拟操作表允许从空数据开始补齐;不要手工伪造已确认 payload 或 attempt 历史。 +- 执行 V16 前,确认 `workflow_reservation_ai_transition.result_type` 和 `workflow_reservation_task.result_type` 扩容到 `VARCHAR(64)` 不会被历史手工约束阻断;V16 会新增 `route_code`、`system_process_category`、`adapter_error_code`、`adapter_error_message` 和对应查询索引。 - 执行 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 89c2ceb..8a76483 100644 --- a/docs/project/integrations/superagent-api-contract.md +++ b/docs/project/integrations/superagent-api-contract.md @@ -4,9 +4,9 @@ | 项目 | 内容 | | --- | --- | -| 文档版本 | 0.5 | -| 日期 | 2026-07-10 | -| 状态 | 已增加 S000/S999 特殊入口结果处理,并完成单酒店阶段 hotel_id 后端解析收口 | +| 文档版本 | 0.7 | +| 日期 | 2026-07-11 | +| 状态 | 当前代码契约已支持 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 或 S000/S999 文本结果。 +9. 鉴权和协议校验通过后再解析业务 JSON、结构化 S10/S99、V3 业务根或 S000/S999 文本结果。 ## 3. SourceMessage ID 口径 @@ -105,6 +105,25 @@ SuperAgent 不应知道或依赖内部 SourceMessage Inbox ID。任务结果通 查询接口 3、4 面向已经入库的邮件会话:缺省酒店由后端解析,`external_conversation_id` 最终仍按 `hotel_id + source_provider + source_channel + external_conversation_id` 查询;`source_message_id` 表示外部来源消息 ID,可作为锚点反查该邮件所属会话。 +## 3.1 M002 V3 迁移提醒 + +2026-07-11 起,项目需求基线已确认采用 `docs/project/requirements/M002-order-task-workflow-v3.md`: + +- 新入口结果将从旧文本 `S000/S999` 迁移为结构化 `S10/S99`。 +- 新业务输出将从旧 `ai_task_results[]` 迁移为 `source_message + message_events[] + case_candidates[] + extraction_warnings[] + unhandled_current_intents[]`。 +- 后端会完整保存 AI 三元组、`route_code` 和系统处理分类;`S10/S99` 仍以只读源邮件通知卡展示,任务列表可见,订单列表不可见。 +- 旧 `S000/S999` 数据继续兼容展示,语义上分别映射到 `S10/S99`。 + +当前后端已完成 M002 V3 CP1-CP2: + +- 已建立 42 条 P0 路由枚举 / 稳定配置。 +- 已支持结构化 `S10/S99` 入站,创建只读 `SOURCE_MESSAGE_ONLY` 任务。 +- 已支持 V3 业务根 `source_message + message_events[]` 的基础解析;可派生到现有任务模型的 event 会创建业务任务,无法派生、显式契约错误或基础 manual_review / parent split 结构不完整的 event 只落 `adapter_contract_error` transition,不创建业务任务。 +- 已支持 `unhandled_current_intents[]` 最小落库:只写 `UNHANDLED_CURRENT_INTENT` transition,不创建业务任务,也不按 adapter 契约错误返回。 +- 已在 `workflow_reservation_ai_transition` 保存 `route_code`、`system_process_category`、`adapter_error_code` 和 `adapter_error_message`。 + +尚未完成:type-known manual review 同卡解阻、`manual_review.missing_fields[]` 到任务卡可编辑字段白名单的完整映射校验、typed `infrastructure_input_error` 响应、复核场景订单归属确认、真实 OPERA / OHIP、普通任务切换订单、任务列表 / 详情接口完整透出所有 V3 路由字段。 + ## 4. 接口 1:查询订单上下文 ### 4.1 请求 @@ -433,9 +452,100 @@ 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 任务结果;JSON 写入业务订单和任务,S000/S999 创建只读特殊任务 | +| 业务动作 | 接收 AI 任务结果;V3 JSON 支持结构化 S10/S99 和业务根,V2 JSON 继续兼容,旧 S000/S999 文本创建只读特殊任务 | -### 8.2 JSON 请求体 +### 8.2 V3 S10/S99 结构化请求体 + +S10 示例: + +```json +{ + "source_message": { + "source_message_id": "mail-20260708-0001", + "subject": null, + "from": null, + "cc": [], + "received_at": null, + "source_channel": "Email" + }, + "route_code": "S10", + "handler_type": "main_agent_outcome", + "result_type": "source_message_review_notification", + "current_or_history": "current", + "agent_assessment": { + "status": "no_booking_action_detected", + "reason_code": "no_booking_action_detected", + "automation_action": "none" + }, + "notification": { + "required": true, + "notification_type": "source_message_review", + "show_source_message": true, + "requires_user_decision": true + }, + "manual_review": null +} +``` + +S99 与 S10 使用相同结构,但 `route_code=S99`,`agent_assessment.status=material_package_unavailable`,且 `manual_review` 必须是完整入口复核对象。 + +### 8.3 V3 业务根请求体 + +```json +{ + "source_message": { + "source_message_id": "mail-20260708-0002", + "subject": "New booking", + "from": null, + "cc": [], + "received_at": null, + "source_channel": "Email" + }, + "message_events": [ + { + "event_type": "New Booking", + "source_event_index": "E1", + "current_or_history": "current", + "case_keys": { + "group_code": null, + "confirmation_number": "CNF-001" + }, + "extracted_fields": { + "booking_object_type": "FIT Reservation" + }, + "manual_review": null + } + ], + "case_candidates": [], + "extraction_warnings": [], + "unhandled_current_intents": [] +} +``` + +V3 字段说明: + +| 字段 | 是否必填 | 中文说明 | +| --- | --- | --- | +| `source_message.source_message_id` | 是 | 外部来源消息 ID,对应 SourceMessage Inbox 的 `external_message_id`;缺失时返回技术错误且不落库 | +| `route_code` | S10/S99 必填 | 只允许 `S10` 或 `S99`,用于区分入口通知结果 | +| `result_type` | S10/S99 必填 | 固定为 `source_message_review_notification` | +| `message_events[]` | 业务根必填 | SuperAgent 最终业务事件列表,本系统逐 event 派生路由 | +| `message_events[].event_type` | 是 | V3 active event 或 `Need Manual Review` | +| `message_events[].source_event_index` | 建议必填 | 可为 `E1` 或数字;后端会归一为数字序号 | +| `message_events[].case_keys` | 否 | 订单关联候选键 | +| `message_events[].extracted_fields` | 否 | 业务字段主体和 subtype 判别字段 | +| `message_events[].manual_review` | 否 | `null` 表示普通任务;对象表示 type-known manual review | +| `unhandled_current_intents[]` | 否 | 第一版只保存 `UNHANDLED_CURRENT_INTENT` transition,不自动创建业务任务 | + +当前已支持的 V3 行为: + +- 42 条 P0 路由进入后端枚举 / 稳定配置。 +- 结构化 `S10/S99` 创建只读 `SOURCE_MESSAGE_ONLY` 任务,任务列表可见,订单列表不可见。 +- 业务 event 能派生到稳定路由时,复用现有订单 / 任务 / 任务卡创建链路。 +- event 判别字段不完整、显式携带 `contract_errors`、根 `missing_fields`、不完整 `manual_review` 或不完整 parent split 候选时,写入 `adapter_contract_error` transition,不创建订单和任务;同一邮件其他 sibling event 继续处理。 +- `unhandled_current_intents[]` 写入 `UNHANDLED_CURRENT_INTENT` transition,不返回 `adapter_error_code`。 + +### 8.4 V2 JSON 兼容请求体 ```json { @@ -481,7 +591,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` | 否 | 订单关联候选键 | @@ -489,9 +599,9 @@ SuperAgent 不应知道或依赖内部 SourceMessage Inbox ID。任务结果通 正式联调时,SuperAgent 不需要传 `hotel_id`。后端通过系统酒店 `hotel_id + source_provider(默认 AGENTBUS) + source_channel(默认 EMAIL) + source_message_id` 查找 `platform_source_message_inbox.external_message_id`。如果没有找到,返回 `SOURCE_MESSAGE_NOT_FOUND`。本地旧夹具允许在缺少 `hotel_id` 时使用内部数字 SourceMessage ID,但该兼容路径不作为 SuperAgent 正式契约。 -`informational_message` 结构化任务仅用于历史兼容。新入口如果是纯信息类邮件或无法形成业务素材包,不要提交空数组,也不要生成 `informational_message`;应使用下面的 S000/S999 文本请求体。 +`informational_message` 结构化任务仅用于历史兼容。新数据如果是纯信息类邮件或无法形成业务素材包,应优先使用 V3 结构化 `S10/S99`;旧联调或兼容场景仍可使用下面的 `S000/S999` 文本请求体。 -### 8.3 S000/S999 文本请求体 +### 8.5 S000/S999 文本请求体 纯信息类邮件: @@ -513,9 +623,9 @@ S999,mail-20260708-0001 | `S999` | 入口阶段无法形成业务素材包,不需要进入业务执行。 | | `mail-20260708-0001` | 外部来源消息 ID,对应 SourceMessage Inbox 的 `external_message_id`。 | -第一版 S000/S999 不在 body 里传 `hotel_id`,后端使用平台酒店表唯一 `ACTIVE` 酒店查询 SourceMessage Inbox。命中后创建 `SOURCE_MESSAGE_ONLY` 只读特殊任务:任务列表可见,订单列表不可见,不允许编辑、确认、转换订单、执行 OPERA 或重试 OPERA,也不参与同订单任务执行顺序阻塞。 +S000/S999 不在 body 里传 `hotel_id`,后端使用平台酒店表唯一 `ACTIVE` 酒店查询 SourceMessage Inbox。命中后创建 `SOURCE_MESSAGE_ONLY` 只读特殊任务:任务列表可见,订单列表不可见,不允许编辑、确认、转换订单、执行 OPERA 或重试 OPERA,也不参与同订单任务执行顺序阻塞。该文本格式仅为兼容路径,新数据优先使用结构化 `S10/S99`。 -### 8.4 成功响应 +### 8.6 成功响应 ```json { @@ -529,6 +639,9 @@ S999,mail-20260708-0001 "source_event_index": 1, "array_index": 1, "ai_transition_id": "1900000000000000250", + "route_code": "R01_NEW_FIT_RESERVATION_NORMAL", + "system_process_category": "BUSINESS_TASK", + "adapter_error_code": null, "order_id": "1900000000000000300", "task_id": "1900000000000000400", "system_task_type": "NEW_BOOKING", @@ -556,6 +669,9 @@ S000/S999 成功响应示例: "source_event_index": 1, "array_index": 1, "ai_transition_id": "1900000000000000550", + "route_code": "S10", + "system_process_category": "SOURCE_MESSAGE_NOTIFICATION", + "adapter_error_code": null, "order_id": "1900000000000000600", "task_id": "1900000000000000700", "system_task_type": "SOURCE_MESSAGE_ONLY", @@ -569,6 +685,26 @@ S000/S999 成功响应示例: } ``` +V3 `adapter_contract_error` 响应中的 `items[]` 不会包含 `order_id` / `task_id`: + +```json +{ + "source_event_index": 2, + "array_index": 1, + "ai_transition_id": "1900000000000000800", + "route_code": null, + "system_process_category": "ADAPTER_CONTRACT_ERROR", + "adapter_error_code": "EVENT_ROUTE_UNSUPPORTED", + "order_id": null, + "task_id": null, + "system_task_type": "ADAPTER_CONTRACT_ERROR", + "task_card_type": "ADAPTER_CONTRACT_ERROR", + "task_status": null, + "order_status": null, + "execution_order": null +} +``` + ## 9. 错误响应 ### 9.1 查询接口错误响应 diff --git a/docs/project/requirements/M002-backend-checkpoint-plan.md b/docs/project/requirements/M002-backend-checkpoint-plan.md index ad27735..01f1b86 100644 --- a/docs/project/requirements/M002-backend-checkpoint-plan.md +++ b/docs/project/requirements/M002-backend-checkpoint-plan.md @@ -4,15 +4,17 @@ | 项目 | 内容 | | --- | --- | -| 文档版本 | 0.2 | -| 日期 | 2026-07-08 | -| 状态 | 后端 checkpoint 计划与阶段实现记录 | +| 文档版本 | 0.3 | +| 日期 | 2026-07-11 | +| 状态 | V2 后端 checkpoint 阶段记录;V3 新开发和当前实现状态以 `M002-order-task-workflow-v3.md` 为准 | | 适用范围 | M002 后端实现拆分、交付物和验收标准 | | 主要读者 | 后端、测试、产品、后续协作 agent | ## 1. 文档定位 -本文把 `M002-order-task-workflow-v2.md`、`M002-superagent-task-result-api-contract.md` 和 `M002-backend-data-model-design.md` 拆成可执行后端 checkpoint。 +本文把 `M002-order-task-workflow-v2.md`、`M002-superagent-task-result-api-contract.md` 和 `M002-backend-data-model-design.md` 拆成可执行后端 checkpoint,用于理解当前已阶段实现的 M002 V2 能力。 + +2026-07-11 后,M002 后续新开发必须先读 `M002-order-task-workflow-v3.md`。V3 已确认采用 0711 P0 冻结基线,新增结构化 `S10/S99`、42 路由、方案 C、type-known manual review 同卡解阻、复核场景订单归属确认和 P1/P2 fail-closed 边界。本文下方 V2 checkpoint 不再覆盖这些新需求。 每个 checkpoint 都应先读项目规范,再按本项目包结构和注释要求实现。不要一次性把完整后端做完,也不要在不确定字段或目录归属时先写再重构。 @@ -27,8 +29,12 @@ - `docs/import/reusable/backend-base-structure-pagination-guidelines.md` - `docs/import/reusable/alibaba-java-coding-guidelines-summary.md` - `docs/project/requirements/M002-order-task-workflow-v2.md` +- `docs/project/requirements/M002-order-task-workflow-v3.md` - `docs/project/requirements/M002-superagent-task-result-api-contract.md` - `docs/project/requirements/M002-backend-data-model-design.md` +- `docs/import/20260711/开发交付_P0冻结基线_2026-07-11/README_交付说明.md` +- `docs/import/20260711/开发交付_P0冻结基线_2026-07-11/01_Adapter_Frontend/任务卡前端字段变更与路由说明_3.0_to_current.md` +- `docs/import/20260711/开发交付_P0冻结基线_2026-07-11/01_Adapter_Frontend/任务卡前端字段变更说明_3.0_to_当前版_2026-07-10.xlsx` - `docs/import/20260706/开发AI先读_工作顺序.md` - `docs/import/20260706/AI输出参数并集字典.xlsx` - `docs/import/20260706/任务卡展示编辑矩阵.xlsx` diff --git a/docs/project/requirements/M002-backend-data-model-design.md b/docs/project/requirements/M002-backend-data-model-design.md index 9755086..e1e9a95 100644 --- a/docs/project/requirements/M002-backend-data-model-design.md +++ b/docs/project/requirements/M002-backend-data-model-design.md @@ -4,9 +4,9 @@ | 项目 | 内容 | | --- | --- | -| 文档版本 | 0.1 | -| 日期 | 2026-07-07 | -| 状态 | 后端数据模型与阶段实现记录 | +| 文档版本 | 0.2 | +| 日期 | 2026-07-11 | +| 状态 | V2 后端数据模型与阶段实现记录;V3 新开发和当前实现状态以 `M002-order-task-workflow-v3.md` 为准 | | 适用范围 | AI 过渡层、订单、任务、任务卡、审计和 OPERA 模拟结果 | | 主要读者 | 后端、数据库、测试、后续协作 agent | @@ -16,6 +16,8 @@ 本文不是完整最终模型。当前后端已经按本模型落地第一阶段 Flyway migration、Entity、Mapper、Repository、Service 和测试;后续真实 OPERA、前端页面和 SuperAgent 查询上下文接口仍需继续补充。 +2026-07-11 后,M002 V3 已确认采用 0711 P0 冻结基线。后续数据模型扩展必须支持结构化 `S10/S99`、`message_events[]`、42 路由、方案 C 中的 AI 原始三元组 / 系统处理分类分离、type-known manual review 同卡解阻和 `adapter_contract_error`,不能只沿用本文的 `ai_task_results[]` 阶段模型。 + ## 2. 设计原则 - 保留 AI 原始 JSON,不覆盖、不重写、不丢字段。 @@ -123,7 +125,7 @@ 表名:`workflow_reservation_ai_transition` -一条 `ai_task_results[]` item 对应一行。 +V2 一条 `ai_task_results[]` item 对应一行。M002 V3 后,`message_events[]`、`S10/S99` 入口通知、`unhandled_current_intents[]` 和 adapter 契约错误也统一以 transition 方式追溯保存。 | 字段 | 类型建议 | 中文说明 | | --- | --- | --- | @@ -136,8 +138,10 @@ | `execution_order` | `INT` | 映射到订单任务队列的初始顺序 | | `catalog_code` | `VARCHAR(32)` | Skill 目录代码 | | `skill_id` | `VARCHAR(128)` | Skill 标识 | -| `result_type` | `VARCHAR(32)` | AI 结果类型 | +| `result_type` | `VARCHAR(64)` | AI 结果类型,例如 `normal_task`、`manual_review`、`source_message_review_notification`、`adapter_contract_error` | | `ai_task_type` | `VARCHAR(64)` | AI 原始任务类型 | +| `route_code` | `VARCHAR(64)` | M002 V3 派生路由码;S10/S99 使用外部 route_code,业务事件使用系统稳定路由码 | +| `system_process_category` | `VARCHAR(64)` | 系统处理分类:`BUSINESS_TASK`、`SOURCE_MESSAGE_NOTIFICATION`、`UNHANDLED_CURRENT_INTENT`、`ADAPTER_CONTRACT_ERROR` | | `system_task_type` | `VARCHAR(64)` | 系统主任务类型 | | `task_card_type` | `VARCHAR(64)` | 任务卡类型 | | `task_subtype` | `VARCHAR(128)` | 业务动作 subtype | @@ -157,6 +161,8 @@ | `informational_message_json` | `LONGTEXT` | 信息提醒 JSON | | `attachments_json` | `LONGTEXT` | 附件 JSON | | `context_used_json` | `LONGTEXT` | 上下文 JSON | +| `adapter_error_code` | `VARCHAR(128)` | Adapter 契约错误代码,仅在当前 event 不建业务任务时保存 | +| `adapter_error_message` | `VARCHAR(512)` | Adapter 契约错误说明,仅保存安全摘要 | | `created_at` / `updated_at` | `DATETIME(6)` | 创建和更新时间 | 索引建议: @@ -164,6 +170,8 @@ - 唯一索引:`hotel_id + item_idempotency_key` - 普通索引:`hotel_id + source_message_id + source_event_index` - 普通索引:`hotel_id + result_type + ai_task_type` +- 普通索引:`hotel_id + route_code` +- 普通索引:`hotel_id + system_process_category` - 普通索引:`hotel_id + group_code` - 普通索引:`hotel_id + confirmation_number` @@ -394,7 +402,7 @@ - 任务阻塞状态:实时计算,不落 `BLOCKED`。 - OPERA 真实接口字段映射:后续真实系统接入后在 adapter 层补充。 - 全量 Excel 字段路径:后端引用 `docs/import/20260706/任务卡展示编辑矩阵.xlsx` 作为完整规则来源,不手抄成数据库。 -- 前端展示 / 编辑白名单:前端引用 `docs/import/20260708/任务卡前端展示字段表 3.0.xlsx`,该白名单不作为后端校验和 OPERA 映射的替代来源。 +- 前端展示 / 编辑白名单:V2 阶段前端引用 `docs/import/20260708/任务卡前端展示字段表 3.0.xlsx`;V3 起以 2026-07-11 P0 冻结基线中的前端 Excel 和路由说明为准。前端白名单不作为后端校验和 OPERA 映射的替代来源。 ## 15. 待确认问题 diff --git a/docs/project/requirements/M002-order-task-workflow-v3.md b/docs/project/requirements/M002-order-task-workflow-v3.md new file mode 100644 index 0000000..5d791d6 --- /dev/null +++ b/docs/project/requirements/M002-order-task-workflow-v3.md @@ -0,0 +1,364 @@ +# M002 Order Task Workflow 订单任务主流程 V3 + +## 文档信息 + +| 项目 | 内容 | +| --- | --- | +| 文档版本 | 0.1 | +| 日期 | 2026-07-11 | +| 状态 | 0711 P0 基线确认版;后端已完成 M002 V3 CP1-CP2 入站解析与路由基线 | +| 适用范围 | SourceMessage 之后的 SuperAgent 输出适配、任务路由、只读通知卡、人工复核同卡解阻、前后端协作边界 | +| 主要读者 | 产品、后端、前端、测试、SuperAgent 对接方、后续协作 agent | + +## 1. 文档定位 + +本文是 `M002-order-task-workflow-v2.md` 的第三版修正。V2 记录了当前后端阶段实现:`ai_task_results[]`、`S000/S999` 文本结果、订单任务基础流转、任务草稿确认、OPERA 模拟骨架、SuperAgent 查询上下文接口和前端 P0 查询接口。 + +V3 记录 2026-07-11 导入的 SuperAgent P0 冻结基线,以及本项目已经确认的产品决策。后续 M002 新开发应优先按本文执行;当前代码中已经存在的 V2 行为,需要按 checkpoint 逐步兼容迁移,不能在未实现前对外宣称已经完成。 + +本文不替代 `docs/project/integrations/superagent-api-contract.md` 的线上联调接口说明。若要给 SuperAgent 联调方使用,必须在对应接口实现完成后同步更新该对外契约。 + +## 2. 权威输入资料 + +V3 以以下资料和决策为输入: + +| 资料 | 用途 | +| --- | --- | +| `docs/import/20260711/开发交付_P0冻结基线_2026-07-11/README_交付说明.md` | 0711 P0 交付边界、可先实现范围、P1/P2 暂缓范围 | +| `docs/import/20260711/开发交付_P0冻结基线_2026-07-11/01_Adapter_Frontend/任务卡前端字段变更与路由说明_3.0_to_current.md` | Adapter / Frontend 的 42 路由、人工复核、Parent split、fail-closed 规则 | +| `docs/import/20260711/开发交付_P0冻结基线_2026-07-11/01_Adapter_Frontend/任务卡前端字段变更说明_3.0_to_当前版_2026-07-10.xlsx` | P0 字段、三元组路由、旧枚举迁移、非法组合和验收用例 | +| `docs/import/20260711/开发交付_P0冻结基线_2026-07-11/02_AI_Runtime/main_agent_prompt.md` | 当前 Main Agent 运行提示词 | +| `docs/import/20260711/开发交付_P0冻结基线_2026-07-11/02_AI_Runtime/booking-desk-event.skill` | 当前 Skill 包 | +| `docs/import/20260711/开发交付_P0冻结基线_2026-07-11/02_AI_Runtime/核心契约_展开阅读/*.md` | 输出契约、事件路由、内容完整性、Main 到 Skill 输入、人工复核规则 | +| `docs/import/20260711/开发交付_P0冻结基线_2026-07-11/03_P0_Acceptance/` | P0 fixtures 和轻量 validator | +| `docs/import/20260711/开发交付_P0冻结基线_2026-07-11/04_Known_Issues_非契约/` | P1/P2 未闭合范围,只用于识别暂缓和 fail-closed,不作为生产规则源 | + +本项目确认的产品决策: + +- M002 V3 正式采用 0711 P0 基线。 +- 旧数据 `S000/S999` 继续在任务列表可见;新数据迁移为 `S10/S99`。 +- `S10/S99` 继续复用隐藏技术订单 + 任务列表只读卡,不进入订单列表和订单执行队列。 +- 缺少 `source_message.source_message_id` 时,目标契约采用 `HTTP 400 + infrastructure_input_error + retryable=true` 的技术错误响应;当前 CP1-CP2 后端暂返回标准错误包装,typed 响应仍在后续 checkpoint。 +- 内部任务模型采用“方案 C”:完整保存 AI 三元组,系统处理分类和前端展示分类单独维护。 +- type-known manual review 使用同一张业务卡复核解阻,不生成第二张 normal task。 +- 第一版保存全部 42 条路由 / 枚举,先支持保存和列表展示。 +- P1/P2 未闭合范围命中时 fail closed,不由本系统发明字段或业务规则。 +- 普通任务切换订单继续后置;P0 仅支持“复核场景下确认订单归属”。 + +## 3. 相对 V2 的核心变化 + +| 主题 | V2 | V3 | +| --- | --- | --- | +| SuperAgent 业务输出 | 顶层 `source_message_id + ai_task_results[]` | 顶层 `source_message + message_events[] + case_candidates[] + extraction_warnings[] + unhandled_current_intents[]` | +| 信息类 / 入口问题 | `text/plain`:`S000,source_message_id` / `S999,source_message_id` | 结构化 JSON:`S10` / `S99`,`result_type=source_message_review_notification` | +| 任务路由 | 以 `result_type + task_type + task_subtype` 粗映射系统主任务 | 按每个 `message_events[i]` 派生 42 条 P0 三元组 | +| 人工复核 | Fallback / manual_review 可转换为业务任务 | type-known review 保留原业务卡;只有类型或 subtype 未知才走 Fallback | +| 复核解阻 | 偏“转换”思路 | 同卡 `review_status + review_resolution.field_overrides[]` 解阻 | +| Message Notification | 历史信息提醒任务 | 新入口统一使用 `S10/S99` 只读源邮件通知卡;历史数据兼容展示 | +| P1/P2 未闭合 | 部分规则可能由系统先做 | 明确 fail closed / adapter_contract_error,不猜测 | + +## 4. SourceMessage Identity 和入口错误 + +### 4.1 SourceMessage ID 口径 + +`source_message.source_message_id` 是 SuperAgent 从上游输入原样带回的外部来源消息 ID,对应 AgentBus 邮件 payload 中的 `source.external_message_id`。它不是本系统内部 `platform_source_message_inbox.id`。 + +后端处理时按以下路径反查: + +```text +系统酒店 + source_provider + source_channel + source_message.source_message_id +→ platform_source_message_inbox.external_message_id +→ platform_source_message_inbox.id +→ workflow / task / transition 表内部 source_message_id 外键 +``` + +V3 第一版继续使用单酒店阶段的系统默认酒店;后续多酒店或权限收口时再扩展显式酒店上下文。 + +### 4.2 缺少 source_message_id + +Gateway 必须在读取正文、附件、OCR、历史、系统上下文或调用 Skill 前校验 `source_message.source_message_id`。 + +为空时,目标契约响应: + +```json +{ + "result_type": "infrastructure_input_error", + "error_code": "missing_source_message_id", + "retryable": true, + "missing_fields": [ + "source_message.source_message_id" + ] +} +``` + +当前 CP1-CP2 实现说明:后端已识别缺失并返回 `HTTP 400`,但响应体仍使用系统标准错误包装,尚未切换为上方 typed `infrastructure_input_error` 结构。 + +处理要求: + +- HTTP 状态码第一版使用 `400`。 +- 不创建 SourceMessage、AI transition、订单、任务、通知卡或审计业务记录。 +- 不把该错误当成 `S10/S99`、Fallback 或人工复核。 +- 调用方可修正输入后重试。 + +## 5. SuperAgent 输出分流 + +V3 接收端按根结构分流: + +```text +1. infrastructure_input_error + → 返回技术错误,不建卡。 + +2. source_message_review_notification + route_code=S10/S99 + → 创建只读源邮件通知卡,任务列表可见,订单列表不可见。 + +3. 业务根 source_message + message_events[] + → 按每个 message_events[i] 派生业务任务或复核任务。 + +4. unhandled_current_intents[] + → 仅保存和展示为源邮件详情 / 任务详情中的未覆盖业务意图块,不自动创建业务任务卡。 +``` + +`candidate_events[]` 是 Main 到 Skill 的内部输入,不属于最终入站结果。本系统不得把它当成最终任务卡或持久化业务事实。 + +## 6. S10 / S99 处理规则 + +### 6.1 新入口结果 + +`S10` 和 `S99` 均为结构化入口通知结果: + +| route_code | result_type | 固定含义 | manual_review | +| --- | --- | --- | --- | +| `S10` | `source_message_review_notification` | 输入可理解,但没有匹配当前支持的 active 业务事件 | `null` | +| `S99` | `source_message_review_notification` | 输入不足,无法形成业务素材包或判断支持范围 | 完整 `main_agent_entry_review` | + +两者都要求用户查看源邮件并自行决定是否回复或处理,不代表系统可以自动忽略邮件。 + +### 6.2 系统落地 + +新数据 `S10/S99` 的系统处理规则: + +- 按外部 `source_message.source_message_id` 反查 SourceMessage Inbox。 +- 创建隐藏技术订单,仅用于满足任务外键或列表聚合需要。 +- 创建只读源邮件通知卡,任务列表可见。 +- 订单列表不可见;订单详情不能作为普通订单页打开。 +- 不参与订单任务执行队列,`queue_participation=false`。 +- 不阻塞任何订单任务,也不被任何订单任务阻塞。 +- 不允许保存草稿、最终确认、复核转换、普通切换订单、执行 OPERA、重试 OPERA。 +- 任务详情展示来源邮件、邮件会话、附件、SuperAgent 原始返回、`route_code` 和入口说明。 + +### 6.3 旧 S000 / S999 兼容 + +旧数据 `S000/S999` 已经在系统中以只读特殊任务展示。V3 不删除旧数据,也不要求历史回写。 + +兼容规则: + +- 旧 `S000` 在前端和查询层按 `S10` 语义展示。 +- 旧 `S999` 在前端和查询层按 `S99` 语义展示。 +- 如果旧任务已经是 `SOURCE_MESSAGE_ONLY` 或等价只读类型,继续在任务列表可见。 +- 新入站不再优先使用 `S000/S999` 文本格式;实现迁移前,对外契约应清楚标记当前代码支持范围。 + +## 7. Adapter 路由模型 + +### 7.1 42 条 P0 路由 + +V3 第一版必须保存并支持以下路由类别: + +- 19 个业务 subtype,每个 subtype 都有 normal 和 type-known manual review 两条路由,共 38 条。 +- `S10` 和 `S99` 两条源邮件通知路由。 +- 类型或 subtype 未知的 Fallback 路由:`manual_review + Fallback + business_event_review`。 +- `unhandled_current_intent + Unhandled Current Intent + requires_business_approval_or_unsupported_task_card` 展示路由。 + +第一版后端要求: + +- 42 条路由全部进入枚举或稳定配置,不能只硬编码已实现的少数几条。 +- 每条入站 event 都按自己的 `message_events[i]` 独立派生,不能在邮件根只生成一个任务。 +- 同一封邮件多个任务按 SuperAgent 返回数组顺序和事件顺序生成执行顺序。 +- `Note`、`Allotment Maintenance`、`update_allotment_control_block` 仅历史兼容,不允许新数据生成。 + +### 7.2 方案 C:AI 三元组和系统处理分类分离 + +V3 内部模型采用方案 C,避免把 SuperAgent 的任务三元组直接等同于本系统执行分类。 + +建议保存三层信息: + +| 层级 | 字段示例 | 中文说明 | +| --- | --- | --- | +| AI 原始路由 | `ai_result_type`、`ai_task_type`、`ai_task_subtype`、`route_code` | 完整保存 SuperAgent 输出,不因系统处理而丢失 | +| 系统处理分类 | `system_process_category`、`system_task_type`、`queue_participation`、`readonly` | 决定是否进入订单队列、是否可编辑、是否可确认、是否可执行 | +| 前端展示分类 | `card_display_type`、`card_title_code`、`route_display_code` | 决定任务列表和详情如何展示 | + +这样可以同时支持: + +- `S10/S99` 有 AI 三元组,但不是业务执行任务。 +- `unhandled_current_intent` 可展示,但不自动建业务任务卡。 +- 新增业务卡可以先保存和列表展示,后续再逐步接校验和 OPERA adapter。 +- type-known manual review 保留原业务 subtype,不被强行改成 Fallback。 + +## 8. 业务任务和订单挂靠 + +### 8.1 业务任务 + +`normal_task` 业务事件可以按既有订单/任务模型生成业务任务;用户确认前不得执行 OPERA。 + +第一版应至少保留以下业务标识: + +- `source_event_index` +- `array_index` +- `event_type` +- `ai_result_type` +- `ai_task_type` +- `ai_task_subtype` +- `route_code` +- `case_keys` +- `manual_review` +- `ai_payload_json` +- `review_status` +- `review_resolution` + +### 8.2 订单归属 + +订单归属第一版规则: + +- `New Booking` 无可靠业务号时创建临时订单。 +- 有 `group_code`、`confirmation_number` 等可定位字段时,优先挂靠或创建相应订单。 +- 同一个 `hotel_id + GROUP_CODE` 只能有一个 `ACTIVE` 订单。 +- 同一个 `hotel_id + CONFIRMATION_NUMBER` 只能有一个 `ACTIVE` 订单。 +- `S10/S99` 使用隐藏技术订单,不进入订单列表。 + +P0 新增明确:复核场景下需要支持用户确认订单归属。它不是普通任务切换订单: + +| 能力 | V3 范围 | +| --- | --- | +| 复核过程中确认 / 选择订单归属 | P0 需要支持 | +| type-known manual review 解决字段同时确认订单归属 | P0 需要支持 | +| Fallback 被用户判定为 New / Update / Cancel 并确定订单归属 | P0 需要支持 | +| 已创建普通任务任意切换到其他订单 | 继续后置,不在 P0 | + +## 9. Type-known manual review 同卡解阻 + +### 9.1 路由原则 + +只要 SuperAgent 已能确定业务 `event_type` 和 subtype,就必须保留原业务类型和 subtype。字段、目标对象、房型、Rate Code、证据或上下文不安全时,使用同一业务卡的 manual-review mode。 + +只有业务类型或 subtype 本身无法确定时,才使用: + +```text +manual_review + Fallback + business_event_review +``` + +### 9.2 状态模型 + +Agent payload 不可变。本系统在同一张卡上维护复核状态: + +```json +{ + "review_status": "pending", + "review_resolution": null +} +``` + +用户解决后: + +```json +{ + "review_status": "resolved", + "review_resolution": { + "field_overrides": [ + { + "field_pointer": "/extracted_fields/room_items/0/pms_room_type_code", + "value": "SU1" + } + ], + "resolved_by": "", + "resolved_at": "2026-07-11T00:00:00Z" + } +} +``` + +要求: + +- 不改写 `ai_payload_json`。 +- 不创建第二张 linked normal task。 +- `missing_fields[]` 必须是 RFC 6901 JSON Pointer。 +- Pointer 必须能映射到该业务卡已知可编辑字段,否则为 `adapter_contract_error`。 +- 订单归属确认可作为复核解阻的一部分保存,但不得打开普通任务随意切换订单能力。 +- 全部缺失字段、订单归属、目录值和依赖校验通过后,才进入 Preflight / READY。 + +## 10. P1/P2 未闭合范围处理 + +0711 导入包已经明确 P1/P2 未闭合。后端、前端、Adapter 都不得自行发明规则。 + +命中以下情况时,第一版应 fail closed: + +- 42 路由与 runtime 输出不一致或无法唯一匹配。 +- `manual_review` 九字段不完整。 +- `missing_fields[]` 不是 RFC 6901 pointer,或无法映射到可编辑字段。 +- Parent split 关系字段不完整或无法一一对应。 +- `Note`、`Allotment Maintenance`、`update_allotment_control_block` 新数据出现。 +- Fix Charge、Preflight/lock、Fallback 非字段解阻、Voucher 文件对象缺失、Manual RateCode 边界等 P1/P2 未闭合场景。 + +处理建议: + +- 单个 event 契约错误时,该 event 0 卡并记录 `adapter_contract_error`。 +- 同一邮件的 sibling events 继续独立处理。 +- 契约错误不是人工复核,不能用 Fallback 吞掉。 +- 前端应展示“契约问题 / 暂不支持”的稳定 code,不把它当成可编辑业务卡。 + +## 11. 前端影响 + +前端需要按 V3 调整以下行为: + +- 任务列表支持展示旧 `S000/S999` 和新 `S10/S99`,但新文案以 `S10/S99` 为主。 +- `S10/S99` 只读卡只出现在任务列表和任务详情,不出现在订单列表。 +- 任务列表不应仅按旧 `SOURCE_MESSAGE_ONLY` 判断;应兼容后端后续返回的 `route_code=S10/S99`、`result_type=source_message_review_notification`。 +- type-known manual review 不再统一展示成 Fallback,应展示原业务卡名称和 subtype,并显示复核状态。 +- 复核解阻页需要能提交 `field_overrides[]`,并在复核场景下确认订单归属。 +- `unhandled_current_intents[]` 只作为展示块,不提供执行按钮。 +- P1/P2 fail-closed 返回时,前端展示稳定错误和源邮件入口,不让用户误以为可以确认执行。 + +## 12. 后端实施 checkpoint 建议 + +V3 建议拆成以下 checkpoint,避免一次性重构过大: + +| Checkpoint | 目标 | 说明 | +| --- | --- | --- | +| M002-V3-CP1 | 文档和枚举基线 | 已完成:建立 42 路由枚举 / 稳定配置,作为入站路由唯一代码源 | +| M002-V3-CP2 | 入站解析兼容 | 已完成:正式回调支持结构化 S10/S99 和 V3 业务根,保留旧 S000/S999 兼容 | +| M002-V3-CP3 | 路由持久化 | 部分完成:已保存 AI 原始三元组、route_code、system_process_category、unhandled_current_intents 和 adapter_contract_error;message_events / unhandled_current_intents 的前端完整展示仍后置 | +| M002-V3-CP4 | 列表 / 详情展示 | 任务列表和详情支持 S10/S99、42 路由只读展示、type-known review 展示 | +| M002-V3-CP5 | 同卡复核解阻 | 支持 review_status、field_overrides、复核场景订单归属确认和 READY 流转 | +| M002-V3-CP6 | P0 fixtures 回归 | 引入 0711 P0 fixtures / validator 作为后端适配测试参考,补充项目级测试 | + +## 13. 明确不做 + +V3 P0 不做以下事项: + +- 不做真实 OPERA / OHIP 写入。 +- 不做普通任务任意切换订单。 +- 不由前端直接调用 SuperAgent、AgentBus、OPERA、OHIP 或数据库。 +- 不把 `candidate_events[]` 当成最终任务。 +- 不自动根据 `unhandled_current_intents[]` 创建业务任务卡。 +- 不用 P1/P2 Known Issues 自行发明字段或 schema。 +- 不移除历史 `S000/S999` 数据展示。 + +## 14. 当前代码现状提醒 + +截至 M002 V3 CP1-CP2 落地后,当前后端已经实现: + +- `S000/S999` 文本结果兼容处理。 +- 结构化 `S10/S99` 入站处理,复用 `SOURCE_MESSAGE_ONLY` 只读特殊任务。 +- 42 条 P0 路由枚举 / 稳定配置。 +- V3 业务根 `source_message + message_events[]` 基础解析;能派生到稳定路由的 event 创建业务任务,无法派生的 event、显式 `contract_errors`、根 `missing_fields`、不完整 `manual_review` 和不完整 parent split 候选只落 `adapter_contract_error` transition。 +- `unhandled_current_intents[]` 只落 `UNHANDLED_CURRENT_INTENT` transition,不创建订单和任务,也不伪装成 adapter 契约错误。 +- AI transition 最小保存 `route_code`、`system_process_category`、`adapter_error_code`、`adapter_error_message`。 +- 订单 / 任务列表、任务详情、草稿保存、最终确认、OPERA 模拟骨架和审计列表。 +- SuperAgent 查询上下文接口 1、2,以及邮件会话相关查询。 + +仍需后续 checkpoint 实现: + +- type-known review 同卡解阻、`review_status` 和 `review_resolution.field_overrides[]`。 +- 复核场景订单归属确认。 +- `manual_review.missing_fields[]` 到任务卡可编辑字段白名单的完整映射校验。 +- `source_message.source_message_id` 缺失时按 V3 typed `infrastructure_input_error` 结构响应。 +- 任务列表 / 详情完整透出 V3 `route_code`、入口通知结构、unhandled intent 展示块和 adapter contract error 展示。 +- 真实 OPERA / OHIP、普通任务任意切换订单、P0 fixtures / validator 全量回归。 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 261739a..54cfd7b 100644 --- a/docs/project/requirements/M002-superagent-task-result-api-contract.md +++ b/docs/project/requirements/M002-superagent-task-result-api-contract.md @@ -8,18 +8,20 @@ | 项目 | 内容 | | --- | --- | -| 文档版本 | 0.3 | -| 日期 | 2026-07-10 | -| 状态 | 后端接口契约草稿 | +| 文档版本 | 0.5 | +| 日期 | 2026-07-11 | +| 状态 | V2 兼容 + M002 V3 CP1-CP2 入站解析基线;完整 V3 展示和同卡复核仍看 `M002-order-task-workflow-v3.md` 后续 checkpoint | | 适用范围 | SuperAgent / Main Agent 调用本系统提交 AI 任务结果 | | 主要读者 | 后端、SuperAgent 对接方、测试、后续协作 agent | ## 1. 文档定位 -本文定义 SuperAgent / Main Agent 向本系统提交 `ai_task_results[]` 结构化结果,以及提交 S000/S999 特殊入口结果的后端接口契约。 +本文定义 SuperAgent / Main Agent 向本系统提交 V3 `source_message + message_events[]` 业务根、结构化 S10/S99 入口通知、V2 `ai_task_results[]` 兼容结果,以及旧 S000/S999 特殊入口结果的后端接口契约。 本文承接 `M002-order-task-workflow-v2.md`,只定义本系统入站接口、鉴权、幂等、请求响应和技术校验边界,不定义 SuperAgent 内部 prompt、Skill 实现、OPERA 真实接口或前端展示细节。 +2026-07-11 后,M002 后续开发基线已迁移到 `M002-order-task-workflow-v3.md`。当前后端已完成 CP1-CP2:结构化 `S10/S99` 入站、V3 业务根基础解析、42 路由枚举 / 稳定配置、`UNHANDLED_CURRENT_INTENT` 和 route 相关字段最小落库。旧 `S000/S999` 和 `ai_task_results[]` 仍作为兼容路径保留。对外联调以 `docs/project/integrations/superagent-api-contract.md` 为准。完整 type-known manual review 解阻、`missing_fields[]` 到任务卡字段白名单映射和 typed `infrastructure_input_error` 响应仍在后续 checkpoint。 + ## 2. 接口概览 | 项目 | 内容 | @@ -29,7 +31,7 @@ | Content-Type | `application/json` 或 `text/plain` | | 响应格式 | `application/json` | | 一次请求范围 | 只能包含一个 `source_message_id` | -| 业务动作 | 接收 AI 结果或入口结果、写入 AI 过渡层、生成订单 / 任务 / 任务卡 | +| 业务动作 | 接收 AI 结果或入口结果、写入 AI 过渡层,按可支持路由生成订单 / 任务 / 任务卡 | | 鉴权方式 | HMAC-SHA256 签名 | 中文说明: @@ -37,8 +39,8 @@ - 该接口是服务到服务的入站接口,不给前端直接调用。 - SuperAgent 不直连数据库,只能通过本接口提交任务结果或入口处理结果。 - 本接口只做技术校验和系统接收,不替代用户确认和 OPERA 模拟操作。 -- `application/json` 用于 `normal_task` / `manual_review` 结构化任务。 -- `text/plain` 用于 `S000,source_message_id` / `S999,source_message_id` 特殊入口结果。 +- `application/json` 用于 V3 结构化 `S10/S99`、V3 业务根或 V2 `normal_task` / `manual_review` 兼容结构化任务。 +- `text/plain` 用于旧 `S000,source_message_id` / `S999,source_message_id` 特殊入口结果兼容。 ### 2.1 SourceMessage ID 口径 @@ -175,7 +177,7 @@ JSON 请求体沿用 AI 导入文档定义的聚合结构。 | `ai_task_results[]` | 是 | AI 拆分出的任务结果列表,必须保留数组顺序 | | `extraction_warnings[]` | 否 | 抽取警告;不直接等同于业务任务 | -第一版要求 `ai_task_results[]` 至少包含一条记录。没有业务动作的纯信息类邮件不要提交空数组,也不要新生成 `informational_message`;应改用 `S000,source_message_id` 文本结果。 +V2 第一版要求 `ai_task_results[]` 至少包含一条记录。没有业务动作的纯信息类邮件不要提交空数组,也不要新生成 `informational_message`;新数据优先使用 V3 结构化 `S10/S99`,旧联调或兼容场景仍可使用 `S000,source_message_id` 文本结果。 ### 4.3 `ai_task_results[]` 字段 @@ -184,7 +186,7 @@ JSON 请求体沿用 AI 导入文档定义的聚合结构。 | `source_event_index` | 是 | AI current 事件序号,建议从 1 开始 | | `catalog_code` | 是 | Skill 目录代码,例如 S01、S02 | | `skill_id` | 是 | Skill 标识 | -| `result_type` | 是 | 新入口只接受 `normal_task`、`manual_review`;`informational_message` 仅历史兼容 | +| `result_type` | 是 | V2 当前代码契约只接受 `normal_task`、`manual_review`;`informational_message` 仅历史兼容 | | `task_type` | 是 | AI 原始任务类型 | | `task_subtype` | 否 | 业务动作 subtype;有则用于任务卡路由 | | `current_or_history` | 否 | 当前或历史标识 | 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 index 049b0ed..a989a3b 100644 --- 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 @@ -7,6 +7,10 @@ import java.util.Optional; * SuperAgent 入口阶段非结构化结果码。该结果只说明来源消息处理结论,不代表业务任务。 */ public enum SourceMessageOnlyResultCode { + /** 输入可理解但没有匹配当前支持的业务事件。 */ + S10("S10", "NO_BOOKING_ACTION_DETECTED", "未匹配到当前支持的业务事件"), + /** 输入不足,无法形成可判断的业务素材包。 */ + S99("S99", "MATERIAL_PACKAGE_UNAVAILABLE", "无法形成业务素材包"), /** 纯信息类邮件,不需要形成业务素材包。 */ S000("S000", "PURE_INFORMATION", "纯信息类邮件"), /** 入口问题导致无法形成业务素材包。 */ diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/ReservationAiTransitionDraft.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/ReservationAiTransitionDraft.java index a317398..b2e8736 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/ReservationAiTransitionDraft.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/ReservationAiTransitionDraft.java @@ -3,7 +3,12 @@ package cn.nianxx.thhotel.workflows.reservation.common.dto; import java.time.LocalDateTime; /** - * AI 过渡记录入库草稿。保留 AI 原始 item JSON 和查询所需冗余字段。 + * AI 过渡记录入库草稿。保留 AI 原始 item JSON、AI 原始三元组、V3 路由码和查询所需冗余字段。 + * + * @param routeCode M002 V3 稳定路由码,S10/S99 使用外部 route_code + * @param systemProcessCategory 系统处理分类,用于区分业务任务、来源邮件通知、未处理意图和契约错误 + * @param adapterErrorCode Adapter 契约错误代码,仅在不建业务任务时保存 + * @param adapterErrorMessage Adapter 契约错误说明,仅保存安全摘要 */ public record ReservationAiTransitionDraft( String hotelId, @@ -16,6 +21,8 @@ public record ReservationAiTransitionDraft( String skillId, String resultType, String aiTaskType, + String routeCode, + String systemProcessCategory, String systemTaskType, String taskCardType, String taskSubtype, @@ -35,6 +42,8 @@ public record ReservationAiTransitionDraft( String informationalMessageJson, String attachmentsJson, String contextUsedJson, + String adapterErrorCode, + String adapterErrorMessage, LocalDateTime now ) { } 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 e795446..b0cb9d3 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 @@ -6,7 +6,10 @@ package cn.nianxx.thhotel.workflows.reservation.common.enums; public enum AiResultType { NORMAL_TASK("normal_task"), MANUAL_REVIEW("manual_review"), - INFORMATIONAL_MESSAGE("informational_message"); + INFORMATIONAL_MESSAGE("informational_message"), + SOURCE_MESSAGE_REVIEW_NOTIFICATION("source_message_review_notification"), + UNHANDLED_CURRENT_INTENT("unhandled_current_intent"), + ADAPTER_CONTRACT_ERROR("adapter_contract_error"); private final String code; diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/enums/ReservationAiRouteDefinition.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/enums/ReservationAiRouteDefinition.java new file mode 100644 index 0000000..e71e3e7 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/enums/ReservationAiRouteDefinition.java @@ -0,0 +1,449 @@ +package cn.nianxx.thhotel.workflows.reservation.common.enums; + +import java.util.Arrays; +import java.util.Map; +import java.util.Optional; +import java.util.function.Function; +import java.util.stream.Collectors; + +/** + * M002 V3 SuperAgent P0 路由定义。每条记录对应一条稳定三元组或入口通知路由。 + */ +public enum ReservationAiRouteDefinition { + NEW_FIT_RESERVATION_NORMAL( + "R01_NEW_FIT_RESERVATION_NORMAL", + "normal_task", + "New Booking", + "new_fit_reservation", + ReservationAiSystemProcessCategory.BUSINESS_TASK, + ReservationSystemTaskType.NEW_BOOKING, + ReservationTaskCardType.NEW_BOOKING), + NEW_FIT_RESERVATION_REVIEW( + "R01_NEW_FIT_RESERVATION_REVIEW", + "manual_review", + "New Booking", + "new_fit_reservation", + ReservationAiSystemProcessCategory.BUSINESS_TASK, + ReservationSystemTaskType.NEW_BOOKING, + ReservationTaskCardType.NEW_BOOKING), + NEW_GROUP_BLOCK_NORMAL( + "R02_NEW_GROUP_BLOCK_NORMAL", + "normal_task", + "New Booking", + "new_group_block", + ReservationAiSystemProcessCategory.BUSINESS_TASK, + ReservationSystemTaskType.NEW_BOOKING, + ReservationTaskCardType.NEW_BOOKING), + NEW_GROUP_BLOCK_REVIEW( + "R02_NEW_GROUP_BLOCK_REVIEW", + "manual_review", + "New Booking", + "new_group_block", + ReservationAiSystemProcessCategory.BUSINESS_TASK, + ReservationSystemTaskType.NEW_BOOKING, + ReservationTaskCardType.NEW_BOOKING), + NEW_ALLOTMENT_CONTROL_BLOCK_NORMAL( + "R03_NEW_ALLOTMENT_CONTROL_BLOCK_NORMAL", + "normal_task", + "New Booking", + "new_allotment_control_block", + ReservationAiSystemProcessCategory.BUSINESS_TASK, + ReservationSystemTaskType.NEW_BOOKING, + ReservationTaskCardType.NEW_BOOKING), + NEW_ALLOTMENT_CONTROL_BLOCK_REVIEW( + "R03_NEW_ALLOTMENT_CONTROL_BLOCK_REVIEW", + "manual_review", + "New Booking", + "new_allotment_control_block", + ReservationAiSystemProcessCategory.BUSINESS_TASK, + ReservationSystemTaskType.NEW_BOOKING, + ReservationTaskCardType.NEW_BOOKING), + UPDATE_BOOKING_AMENDMENT_NORMAL( + "R04_UPDATE_BOOKING_AMENDMENT_NORMAL", + "normal_task", + "Update Booking", + "update_booking_amendment", + ReservationAiSystemProcessCategory.BUSINESS_TASK, + ReservationSystemTaskType.UPDATE_BOOKING, + ReservationTaskCardType.UPDATE_BOOKING), + UPDATE_BOOKING_AMENDMENT_REVIEW( + "R04_UPDATE_BOOKING_AMENDMENT_REVIEW", + "manual_review", + "Update Booking", + "update_booking_amendment", + ReservationAiSystemProcessCategory.BUSINESS_TASK, + ReservationSystemTaskType.UPDATE_BOOKING, + ReservationTaskCardType.UPDATE_BOOKING), + CANCEL_FIT_RESERVATION_NORMAL( + "R05_CANCEL_FIT_RESERVATION_NORMAL", + "normal_task", + "Cancel Booking", + "cancel_fit_reservation", + ReservationAiSystemProcessCategory.BUSINESS_TASK, + ReservationSystemTaskType.CANCEL_BOOKING, + ReservationTaskCardType.CANCEL_BOOKING), + CANCEL_FIT_RESERVATION_REVIEW( + "R05_CANCEL_FIT_RESERVATION_REVIEW", + "manual_review", + "Cancel Booking", + "cancel_fit_reservation", + ReservationAiSystemProcessCategory.BUSINESS_TASK, + ReservationSystemTaskType.CANCEL_BOOKING, + ReservationTaskCardType.CANCEL_BOOKING), + CANCEL_GROUP_BLOCK_NORMAL( + "R06_CANCEL_GROUP_BLOCK_NORMAL", + "normal_task", + "Cancel Booking", + "cancel_group_block", + ReservationAiSystemProcessCategory.BUSINESS_TASK, + ReservationSystemTaskType.CANCEL_BOOKING, + ReservationTaskCardType.CANCEL_BOOKING), + CANCEL_GROUP_BLOCK_REVIEW( + "R06_CANCEL_GROUP_BLOCK_REVIEW", + "manual_review", + "Cancel Booking", + "cancel_group_block", + ReservationAiSystemProcessCategory.BUSINESS_TASK, + ReservationSystemTaskType.CANCEL_BOOKING, + ReservationTaskCardType.CANCEL_BOOKING), + LINKED_PARENT_RELEASE_AFTER_CHILD_SPLIT_NORMAL( + "R07_LINKED_PARENT_RELEASE_AFTER_CHILD_SPLIT_NORMAL", + "normal_task", + "Cancel Booking", + "linked_parent_release_after_child_split", + ReservationAiSystemProcessCategory.BUSINESS_TASK, + ReservationSystemTaskType.CANCEL_BOOKING, + ReservationTaskCardType.CANCEL_BOOKING), + LINKED_PARENT_RELEASE_AFTER_CHILD_SPLIT_REVIEW( + "R07_LINKED_PARENT_RELEASE_AFTER_CHILD_SPLIT_REVIEW", + "manual_review", + "Cancel Booking", + "linked_parent_release_after_child_split", + ReservationAiSystemProcessCategory.BUSINESS_TASK, + ReservationSystemTaskType.CANCEL_BOOKING, + ReservationTaskCardType.CANCEL_BOOKING), + CANCEL_ALLOTMENT_CONTROL_BLOCK_NORMAL( + "R08_CANCEL_ALLOTMENT_CONTROL_BLOCK_NORMAL", + "normal_task", + "Cancel Allotment", + "cancel_allotment_control_block", + ReservationAiSystemProcessCategory.BUSINESS_TASK, + ReservationSystemTaskType.CANCEL_BOOKING, + ReservationTaskCardType.CANCEL_ALLOTMENT), + CANCEL_ALLOTMENT_CONTROL_BLOCK_REVIEW( + "R08_CANCEL_ALLOTMENT_CONTROL_BLOCK_REVIEW", + "manual_review", + "Cancel Allotment", + "cancel_allotment_control_block", + ReservationAiSystemProcessCategory.BUSINESS_TASK, + ReservationSystemTaskType.CANCEL_BOOKING, + ReservationTaskCardType.CANCEL_ALLOTMENT), + LIAN_TAI_CREDIT_VOUCHER_NORMAL( + "R09_LIAN_TAI_CREDIT_VOUCHER_NORMAL", + "normal_task", + "Voucher Received", + "lian_tai_credit_voucher", + ReservationAiSystemProcessCategory.BUSINESS_TASK, + ReservationSystemTaskType.UPDATE_BOOKING, + ReservationTaskCardType.VOUCHER_RECEIVED), + LIAN_TAI_CREDIT_VOUCHER_REVIEW( + "R09_LIAN_TAI_CREDIT_VOUCHER_REVIEW", + "manual_review", + "Voucher Received", + "lian_tai_credit_voucher", + ReservationAiSystemProcessCategory.BUSINESS_TASK, + ReservationSystemTaskType.UPDATE_BOOKING, + ReservationTaskCardType.VOUCHER_RECEIVED), + PAYMENT_EVIDENCE_NORMAL( + "R10_PAYMENT_EVIDENCE_NORMAL", + "normal_task", + "Payment Evidence", + "payment_evidence", + ReservationAiSystemProcessCategory.BUSINESS_TASK, + ReservationSystemTaskType.UPDATE_BOOKING, + ReservationTaskCardType.PAYMENT_EVIDENCE), + PAYMENT_EVIDENCE_REVIEW( + "R10_PAYMENT_EVIDENCE_REVIEW", + "manual_review", + "Payment Evidence", + "payment_evidence", + ReservationAiSystemProcessCategory.BUSINESS_TASK, + ReservationSystemTaskType.UPDATE_BOOKING, + ReservationTaskCardType.PAYMENT_EVIDENCE), + ROOMING_LIST_NORMAL( + "R11_ROOMING_LIST_NORMAL", + "normal_task", + "Rooming List", + "rooming_list", + ReservationAiSystemProcessCategory.BUSINESS_TASK, + ReservationSystemTaskType.UPDATE_BOOKING, + ReservationTaskCardType.ROOMING_LIST), + ROOMING_LIST_REVIEW( + "R11_ROOMING_LIST_REVIEW", + "manual_review", + "Rooming List", + "rooming_list", + ReservationAiSystemProcessCategory.BUSINESS_TASK, + ReservationSystemTaskType.UPDATE_BOOKING, + ReservationTaskCardType.ROOMING_LIST), + AMEND_GROUP_CODE_NORMAL( + "R12_AMEND_GROUP_CODE_NORMAL", + "normal_task", + "Amend Group Code", + "amend_group_code", + ReservationAiSystemProcessCategory.BUSINESS_TASK, + ReservationSystemTaskType.UPDATE_BOOKING, + ReservationTaskCardType.AMEND_GROUP_CODE), + AMEND_GROUP_CODE_REVIEW( + "R12_AMEND_GROUP_CODE_REVIEW", + "manual_review", + "Amend Group Code", + "amend_group_code", + ReservationAiSystemProcessCategory.BUSINESS_TASK, + ReservationSystemTaskType.UPDATE_BOOKING, + ReservationTaskCardType.AMEND_GROUP_CODE), + EXTRA_BED_NORMAL( + "R13_EXTRA_BED_NORMAL", + "normal_task", + "Trace / Reservation Notes", + "extra_bed", + ReservationAiSystemProcessCategory.BUSINESS_TASK, + ReservationSystemTaskType.UPDATE_BOOKING, + ReservationTaskCardType.TRACE_RESERVATION_NOTES), + EXTRA_BED_REVIEW( + "R13_EXTRA_BED_REVIEW", + "manual_review", + "Trace / Reservation Notes", + "extra_bed", + ReservationAiSystemProcessCategory.BUSINESS_TASK, + ReservationSystemTaskType.UPDATE_BOOKING, + ReservationTaskCardType.TRACE_RESERVATION_NOTES), + GENERAL_REQUEST_NORMAL( + "R14_GENERAL_REQUEST_NORMAL", + "normal_task", + "Trace / Reservation Notes", + "general_request", + ReservationAiSystemProcessCategory.BUSINESS_TASK, + ReservationSystemTaskType.UPDATE_BOOKING, + ReservationTaskCardType.TRACE_RESERVATION_NOTES), + GENERAL_REQUEST_REVIEW( + "R14_GENERAL_REQUEST_REVIEW", + "manual_review", + "Trace / Reservation Notes", + "general_request", + ReservationAiSystemProcessCategory.BUSINESS_TASK, + ReservationSystemTaskType.UPDATE_BOOKING, + ReservationTaskCardType.TRACE_RESERVATION_NOTES), + MAINTAIN_TA_RECORDER_NORMAL( + "R15_MAINTAIN_TA_RECORDER_NORMAL", + "normal_task", + "TA Recorder", + "maintain_ta_recorder", + ReservationAiSystemProcessCategory.BUSINESS_TASK, + ReservationSystemTaskType.UPDATE_BOOKING, + ReservationTaskCardType.TA_RECORDER), + MAINTAIN_TA_RECORDER_REVIEW( + "R15_MAINTAIN_TA_RECORDER_REVIEW", + "manual_review", + "TA Recorder", + "maintain_ta_recorder", + ReservationAiSystemProcessCategory.BUSINESS_TASK, + ReservationSystemTaskType.UPDATE_BOOKING, + ReservationTaskCardType.TA_RECORDER), + INVOICE_GENERATION_NORMAL( + "R16_INVOICE_GENERATION_NORMAL", + "normal_task", + "Invoice Generation", + "invoice_generation", + ReservationAiSystemProcessCategory.BUSINESS_TASK, + ReservationSystemTaskType.UPDATE_BOOKING, + ReservationTaskCardType.INVOICE_GENERATION), + INVOICE_GENERATION_REVIEW( + "R16_INVOICE_GENERATION_REVIEW", + "manual_review", + "Invoice Generation", + "invoice_generation", + ReservationAiSystemProcessCategory.BUSINESS_TASK, + ReservationSystemTaskType.UPDATE_BOOKING, + ReservationTaskCardType.INVOICE_GENERATION), + INVOICE_RECEIVED_NORMAL( + "R17_INVOICE_RECEIVED_NORMAL", + "normal_task", + "Invoice Received", + "invoice_received", + ReservationAiSystemProcessCategory.BUSINESS_TASK, + ReservationSystemTaskType.UPDATE_BOOKING, + ReservationTaskCardType.INVOICE_RECEIVED), + INVOICE_RECEIVED_REVIEW( + "R17_INVOICE_RECEIVED_REVIEW", + "manual_review", + "Invoice Received", + "invoice_received", + ReservationAiSystemProcessCategory.BUSINESS_TASK, + ReservationSystemTaskType.UPDATE_BOOKING, + ReservationTaskCardType.INVOICE_RECEIVED), + PAYMENT_NOTICE_NORMAL( + "R18_PAYMENT_NOTICE_NORMAL", + "normal_task", + "Payment Notice", + "payment_notice", + ReservationAiSystemProcessCategory.BUSINESS_TASK, + ReservationSystemTaskType.UPDATE_BOOKING, + ReservationTaskCardType.PAYMENT_NOTICE), + PAYMENT_NOTICE_REVIEW( + "R18_PAYMENT_NOTICE_REVIEW", + "manual_review", + "Payment Notice", + "payment_notice", + ReservationAiSystemProcessCategory.BUSINESS_TASK, + ReservationSystemTaskType.UPDATE_BOOKING, + ReservationTaskCardType.PAYMENT_NOTICE), + MANUAL_RATE_CODE_NORMAL( + "R19_MANUAL_RATE_CODE_NORMAL", + "normal_task", + "Manual RateCode", + "manual_rate_code", + ReservationAiSystemProcessCategory.BUSINESS_TASK, + ReservationSystemTaskType.UPDATE_BOOKING, + ReservationTaskCardType.MANUAL_RATE_CODE), + MANUAL_RATE_CODE_REVIEW( + "R19_MANUAL_RATE_CODE_REVIEW", + "manual_review", + "Manual RateCode", + "manual_rate_code", + ReservationAiSystemProcessCategory.BUSINESS_TASK, + ReservationSystemTaskType.UPDATE_BOOKING, + ReservationTaskCardType.MANUAL_RATE_CODE), + SOURCE_MESSAGE_S10( + "S10", + "source_message_review_notification", + "Message Notification", + "S10", + ReservationAiSystemProcessCategory.SOURCE_MESSAGE_NOTIFICATION, + ReservationSystemTaskType.SOURCE_MESSAGE_ONLY, + ReservationTaskCardType.SOURCE_MESSAGE_ONLY), + SOURCE_MESSAGE_S99( + "S99", + "source_message_review_notification", + "Message Notification", + "S99", + ReservationAiSystemProcessCategory.SOURCE_MESSAGE_NOTIFICATION, + ReservationSystemTaskType.SOURCE_MESSAGE_ONLY, + ReservationTaskCardType.SOURCE_MESSAGE_ONLY), + FALLBACK_BUSINESS_EVENT_REVIEW( + "R41_FALLBACK_BUSINESS_EVENT_REVIEW", + "manual_review", + "Fallback", + "business_event_review", + ReservationAiSystemProcessCategory.BUSINESS_TASK, + ReservationSystemTaskType.MANUAL_REVIEW, + ReservationTaskCardType.FALLBACK_REVIEW), + UNHANDLED_CURRENT_INTENT( + "R42_UNHANDLED_CURRENT_INTENT", + "unhandled_current_intent", + "Unhandled Current Intent", + "requires_business_approval_or_unsupported_task_card", + ReservationAiSystemProcessCategory.UNHANDLED_CURRENT_INTENT, + ReservationSystemTaskType.SOURCE_MESSAGE_ONLY, + ReservationTaskCardType.SOURCE_MESSAGE_ONLY); + + private static final Map BY_TRIPLET = Arrays.stream(values()) + .collect(Collectors.toUnmodifiableMap( + route -> routeKey(route.resultType, route.taskType, route.taskSubtype), + Function.identity())); + + private static final Map BY_ROUTE_CODE = Arrays.stream(values()) + .collect(Collectors.toUnmodifiableMap(ReservationAiRouteDefinition::routeCode, Function.identity())); + + private final String routeCode; + private final String resultType; + private final String taskType; + private final String taskSubtype; + private final ReservationAiSystemProcessCategory systemProcessCategory; + private final ReservationSystemTaskType systemTaskType; + private final ReservationTaskCardType taskCardType; + + ReservationAiRouteDefinition( + String routeCode, + String resultType, + String taskType, + String taskSubtype, + ReservationAiSystemProcessCategory systemProcessCategory, + ReservationSystemTaskType systemTaskType, + ReservationTaskCardType taskCardType) { + this.routeCode = routeCode; + this.resultType = resultType; + this.taskType = taskType; + this.taskSubtype = taskSubtype; + this.systemProcessCategory = systemProcessCategory; + this.systemTaskType = systemTaskType; + this.taskCardType = taskCardType; + } + + public String routeCode() { + return routeCode; + } + + public String resultType() { + return resultType; + } + + public String taskType() { + return taskType; + } + + public String taskSubtype() { + return taskSubtype; + } + + public ReservationAiSystemProcessCategory systemProcessCategory() { + return systemProcessCategory; + } + + public ReservationSystemTaskType systemTaskType() { + return systemTaskType; + } + + public ReservationTaskCardType taskCardType() { + return taskCardType; + } + + /** + * 按 AI 三元组查找路由定义,大小写敏感,避免外部值被误归类。 + */ + public static Optional findByTriplet( + String resultType, + String taskType, + String taskSubtype) { + return Optional.ofNullable(BY_TRIPLET.get(routeKey(resultType, taskType, taskSubtype))); + } + + /** + * 按 route_code 查找路由定义,主要用于 S10/S99 和内部生成的稳定路由码。 + */ + public static Optional findByRouteCode(String routeCode) { + return Optional.ofNullable(BY_ROUTE_CODE.get(routeCode)); + } + + /** + * 判断当前路由是否为只读来源邮件通知。 + */ + public boolean sourceMessageNotification() { + return systemProcessCategory == ReservationAiSystemProcessCategory.SOURCE_MESSAGE_NOTIFICATION; + } + + /** + * 判断当前路由是否会进入业务任务模型。 + */ + public boolean businessTask() { + return systemProcessCategory == ReservationAiSystemProcessCategory.BUSINESS_TASK; + } + + private static String routeKey(String resultType, String taskType, String taskSubtype) { + return nullToEmpty(resultType) + "|" + nullToEmpty(taskType) + "|" + nullToEmpty(taskSubtype); + } + + private static String nullToEmpty(String value) { + return value == null ? "" : value; + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/enums/ReservationAiSystemProcessCategory.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/enums/ReservationAiSystemProcessCategory.java new file mode 100644 index 0000000..8a17fee --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/enums/ReservationAiSystemProcessCategory.java @@ -0,0 +1,15 @@ +package cn.nianxx.thhotel.workflows.reservation.common.enums; + +/** + * AI 入站结果在本系统内的处理分类。该分类独立于 SuperAgent 原始三元组,用于判断是否建任务、是否只读或是否仅落契约错误。 + */ +public enum ReservationAiSystemProcessCategory { + /** 可进入订单任务模型的业务任务或业务复核。 */ + BUSINESS_TASK, + /** 只展示来源邮件的通知类结果,例如 S10/S99。 */ + SOURCE_MESSAGE_NOTIFICATION, + /** 当前业务意义清楚但没有任务卡承接的未处理意图。 */ + UNHANDLED_CURRENT_INTENT, + /** Adapter 契约或路由判别失败,当前 event 不建业务任务。 */ + ADAPTER_CONTRACT_ERROR +} 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 044b6d1..2d9daaa 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 @@ -9,5 +9,6 @@ public enum ReservationSystemTaskType { CANCEL_BOOKING, MANUAL_REVIEW, INFORMATIONAL_MESSAGE, - SOURCE_MESSAGE_ONLY + SOURCE_MESSAGE_ONLY, + ADAPTER_CONTRACT_ERROR } 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 8b38129..ee90004 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 @@ -7,12 +7,19 @@ public enum ReservationTaskCardType { NEW_BOOKING, UPDATE_BOOKING, CANCEL_BOOKING, + CANCEL_ALLOTMENT, VOUCHER_RECEIVED, + PAYMENT_EVIDENCE, ROOMING_LIST, AMEND_GROUP_CODE, TRACE_RESERVATION_NOTES, TA_RECORDER, + INVOICE_GENERATION, + INVOICE_RECEIVED, + PAYMENT_NOTICE, + MANUAL_RATE_CODE, MESSAGE_NOTIFICATION, FALLBACK_REVIEW, + ADAPTER_CONTRACT_ERROR, SOURCE_MESSAGE_ONLY } diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/SuperAgentTaskResultItemResponse.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/SuperAgentTaskResultItemResponse.java index 6f173d7..c4c1b6b 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/SuperAgentTaskResultItemResponse.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/SuperAgentTaskResultItemResponse.java @@ -4,6 +4,20 @@ import com.fasterxml.jackson.annotation.JsonProperty; /** * SuperAgent 任务结果入站单个 item 响应。ID 以字符串返回,避免前端 Long 精度丢失。 + * + * @param sourceEventIndex AI 原始事件序号 + * @param arrayIndex 当前批次中的处理顺序 + * @param aiTransitionId AI 过渡记录 ID + * @param routeCode M002 V3 稳定路由码,S10/S99 直接使用外部路由码 + * @param systemProcessCategory 系统处理分类,用于区分业务任务、来源邮件通知和契约错误 + * @param adapterErrorCode Adapter 契约错误代码,正常创建任务时为空 + * @param orderId 关联订单 ID;仅落契约错误时为空 + * @param taskId 关联任务 ID;仅落契约错误时为空 + * @param systemTaskType 系统主任务类型 + * @param taskCardType 任务卡类型 + * @param taskStatus 任务状态;仅落契约错误时为空 + * @param orderStatus 订单状态;仅落契约错误时为空 + * @param executionOrder 同订单任务执行顺序;仅落契约错误时为空 */ public record SuperAgentTaskResultItemResponse( @JsonProperty("source_event_index") @@ -12,6 +26,12 @@ public record SuperAgentTaskResultItemResponse( Integer arrayIndex, @JsonProperty("ai_transition_id") String aiTransitionId, + @JsonProperty("route_code") + String routeCode, + @JsonProperty("system_process_category") + String systemProcessCategory, + @JsonProperty("adapter_error_code") + String adapterErrorCode, @JsonProperty("order_id") String orderId, @JsonProperty("task_id") diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/domain/ReservationAiTransitionEntity.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/domain/ReservationAiTransitionEntity.java index 796ca69..f20bc26 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/domain/ReservationAiTransitionEntity.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/domain/ReservationAiTransitionEntity.java @@ -34,6 +34,10 @@ public class ReservationAiTransitionEntity { private String resultType; /** AI 原始任务类型。 */ private String aiTaskType; + /** M002 V3 派生路由码,S10/S99 使用外部 route_code,业务事件使用系统稳定路由码。 */ + private String routeCode; + /** 系统处理分类,区分业务任务、来源邮件通知、未处理意图和契约错误。 */ + private String systemProcessCategory; /** 系统主任务类型。 */ private String systemTaskType; /** 任务卡类型。 */ @@ -72,6 +76,10 @@ public class ReservationAiTransitionEntity { private String attachmentsJson; /** AI 使用上下文 JSON。 */ private String contextUsedJson; + /** Adapter 契约错误代码,仅在当前 event 不建业务任务时保存。 */ + private String adapterErrorCode; + /** Adapter 契约错误说明,仅保存安全摘要,不写入客户敏感原文。 */ + private String adapterErrorMessage; /** 记录创建 UTC 时间。 */ private LocalDateTime createdAt; /** 记录更新 UTC 时间。 */ @@ -99,6 +107,10 @@ public class ReservationAiTransitionEntity { public void setResultType(String resultType) { this.resultType = resultType; } public String getAiTaskType() { return aiTaskType; } public void setAiTaskType(String aiTaskType) { this.aiTaskType = aiTaskType; } + public String getRouteCode() { return routeCode; } + public void setRouteCode(String routeCode) { this.routeCode = routeCode; } + public String getSystemProcessCategory() { return systemProcessCategory; } + public void setSystemProcessCategory(String systemProcessCategory) { this.systemProcessCategory = systemProcessCategory; } public String getSystemTaskType() { return systemTaskType; } public void setSystemTaskType(String systemTaskType) { this.systemTaskType = systemTaskType; } public String getTaskCardType() { return taskCardType; } @@ -137,6 +149,10 @@ public class ReservationAiTransitionEntity { public void setAttachmentsJson(String attachmentsJson) { this.attachmentsJson = attachmentsJson; } public String getContextUsedJson() { return contextUsedJson; } public void setContextUsedJson(String contextUsedJson) { this.contextUsedJson = contextUsedJson; } + public String getAdapterErrorCode() { return adapterErrorCode; } + public void setAdapterErrorCode(String adapterErrorCode) { this.adapterErrorCode = adapterErrorCode; } + public String getAdapterErrorMessage() { return adapterErrorMessage; } + public void setAdapterErrorMessage(String adapterErrorMessage) { this.adapterErrorMessage = adapterErrorMessage; } public LocalDateTime getCreatedAt() { return createdAt; } public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } public LocalDateTime getUpdatedAt() { return updatedAt; } 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 bb35051..dcd5bd0 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 @@ -136,6 +136,8 @@ public class MybatisReservationAiWorkflowRepository implements ReservationAiWork entity.setSkillId(draft.skillId()); entity.setResultType(draft.resultType()); entity.setAiTaskType(draft.aiTaskType()); + entity.setRouteCode(draft.routeCode()); + entity.setSystemProcessCategory(draft.systemProcessCategory()); entity.setSystemTaskType(draft.systemTaskType()); entity.setTaskCardType(draft.taskCardType()); entity.setTaskSubtype(draft.taskSubtype()); @@ -155,6 +157,8 @@ public class MybatisReservationAiWorkflowRepository implements ReservationAiWork entity.setInformationalMessageJson(draft.informationalMessageJson()); entity.setAttachmentsJson(draft.attachmentsJson()); entity.setContextUsedJson(draft.contextUsedJson()); + entity.setAdapterErrorCode(draft.adapterErrorCode()); + entity.setAdapterErrorMessage(draft.adapterErrorMessage()); entity.setCreatedAt(draft.now()); entity.setUpdatedAt(draft.now()); transitionMapper.insert(entity); 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 1c37580..2c093de 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 @@ -24,13 +24,20 @@ public class JsonReservationTaskCardFieldDefinitionProvider implements Reservati Map.entry(ReservationTaskCardType.NEW_BOOKING.name(), "New Booking 卡"), Map.entry(ReservationTaskCardType.UPDATE_BOOKING.name(), "Update Booking 卡"), Map.entry(ReservationTaskCardType.CANCEL_BOOKING.name(), "Cancel Booking 卡"), + Map.entry(ReservationTaskCardType.CANCEL_ALLOTMENT.name(), "Cancel Allotment 卡"), Map.entry(ReservationTaskCardType.VOUCHER_RECEIVED.name(), "Voucher Received 卡"), + Map.entry(ReservationTaskCardType.PAYMENT_EVIDENCE.name(), "Payment Evidence 卡"), Map.entry(ReservationTaskCardType.ROOMING_LIST.name(), "Rooming List 卡"), Map.entry(ReservationTaskCardType.AMEND_GROUP_CODE.name(), "Amend Group Code 卡"), Map.entry(ReservationTaskCardType.TRACE_RESERVATION_NOTES.name(), "Trace / Reservation Notes 卡"), Map.entry(ReservationTaskCardType.TA_RECORDER.name(), "TA Recorder 卡"), + Map.entry(ReservationTaskCardType.INVOICE_GENERATION.name(), "Invoice Generation 卡"), + Map.entry(ReservationTaskCardType.INVOICE_RECEIVED.name(), "Invoice Received 卡"), + Map.entry(ReservationTaskCardType.PAYMENT_NOTICE.name(), "Payment Notice 卡"), + Map.entry(ReservationTaskCardType.MANUAL_RATE_CODE.name(), "Manual RateCode 卡"), Map.entry(ReservationTaskCardType.MESSAGE_NOTIFICATION.name(), "Message Notification 信息提醒卡"), Map.entry(ReservationTaskCardType.FALLBACK_REVIEW.name(), "Fallback 人工复核卡"), + Map.entry(ReservationTaskCardType.ADAPTER_CONTRACT_ERROR.name(), "Adapter 契约错误卡"), Map.entry(ReservationTaskCardType.SOURCE_MESSAGE_ONLY.name(), "Source Message Only 只读卡") ); 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 4947085..f6dc3db 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 @@ -11,6 +11,8 @@ import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationOrderSnapsh import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationTaskCardDraft; import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationTaskDraft; import cn.nianxx.thhotel.workflows.reservation.common.enums.AiResultType; +import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationAiRouteDefinition; +import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationAiSystemProcessCategory; 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; @@ -51,12 +53,16 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta 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 V3_CATALOG_CODE = "M002V3"; + private static final String V3_SKILL_ID = "booking-desk-event"; + 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"; private static final int LENGTH_32 = 32; private static final int LENGTH_64 = 64; private static final int LENGTH_128 = 128; private static final int LENGTH_256 = 256; + private static final int LENGTH_512 = 512; private static final int MAX_QUEUE_ORDER_RETRY = 5; private final ObjectMapper objectMapper; @@ -85,7 +91,7 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta } /** - * 接收已通过 HMAC 鉴权的 SuperAgent 任务结果。S000/S999 文本结果使用默认酒店反查来源邮件。 + * 接收已通过 HMAC 鉴权的 SuperAgent 任务结果。先处理旧文本结果,再分流 V3 JSON 和 V2 JSON。 */ @Override @Transactional @@ -101,6 +107,15 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta requestBody); } JsonNode root = parseJson(rawBody); + if (isInfrastructureInputError(root)) { + throw error(HttpStatus.BAD_REQUEST, "INFRASTRUCTURE_INPUT_ERROR", "SuperAgent 返回基础设施输入错误。"); + } + if (isV3SourceMessageNotification(root)) { + return acceptV3SourceMessageNotification(root, clientId, requestId, defaultHotelId, requestBody); + } + if (isV3BusinessRoot(root)) { + return acceptV3BusinessRoot(root, clientId, requestId, defaultHotelId, requestBody); + } ResolvedSourceMessage resolvedSourceMessage = resolveSourceMessage(root, defaultHotelId); SourceMessageInboxSnapshot sourceMessage = resolvedSourceMessage.snapshot(); Long sourceMessageId = sourceMessage.id(); @@ -161,6 +176,367 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta ); } + /** + * 接收 V3 结构化 S10/S99 来源邮件通知,复用只读特殊任务,不进入订单列表和执行队列。 + */ + private SuperAgentTaskResultResponse acceptV3SourceMessageNotification( + JsonNode root, + String clientId, + String requestId, + String defaultHotelId, + String rawBody) { + String routeCode = requireText(textAt(root, "route_code"), "route_code", LENGTH_64); + ReservationAiRouteDefinition route = ReservationAiRouteDefinition.findByRouteCode(routeCode) + .filter(ReservationAiRouteDefinition::sourceMessageNotification) + .orElseThrow(() -> error(HttpStatus.BAD_REQUEST, "ADAPTER_CONTRACT_ERROR", "S10/S99 route_code 无效。")); + if (!AiResultType.SOURCE_MESSAGE_REVIEW_NOTIFICATION.code().equals(textAt(root, "result_type"))) { + throw error(HttpStatus.BAD_REQUEST, "ADAPTER_CONTRACT_ERROR", "S10/S99 result_type 无效。"); + } + validateV3SourceMessageNotificationShape(root, route); + ResolvedSourceMessage resolvedSourceMessage = resolveV3SourceMessage(root, defaultHotelId); + SourceMessageOnlyEntryResult entryResult = new SourceMessageOnlyEntryResult( + SourceMessageOnlyResultCode.fromCode(route.taskSubtype()) + .orElseThrow(() -> error(HttpStatus.BAD_REQUEST, "ADAPTER_CONTRACT_ERROR", "S10/S99 结果码无效。")), + resolvedSourceMessage.responseSourceMessageId(), + AiResultType.SOURCE_MESSAGE_REVIEW_NOTIFICATION.code(), + route.routeCode() + ); + return acceptSourceMessageOnlyEntryResult( + entryResult, + clientId, + requestId, + defaultHotelId, + rawBody, + resolvedSourceMessage.snapshot()); + } + + /** + * 接收 V3 业务根,按 message_events[] 逐项派生三元组;不完整或暂不支持的 event 只落契约错误。 + */ + private SuperAgentTaskResultResponse acceptV3BusinessRoot( + JsonNode root, + String clientId, + String requestId, + String defaultHotelId, + String rawBody) { + ResolvedSourceMessage resolvedSourceMessage = resolveV3SourceMessage(root, defaultHotelId); + SourceMessageInboxSnapshot sourceMessage = resolvedSourceMessage.snapshot(); + String hotelId = sourceMessage.hotelId(); + Long sourceMessageId = sourceMessage.id(); + JsonNode messageEvents = root.path("message_events"); + JsonNode unhandledIntents = root.path("unhandled_current_intents"); + int itemCount = messageEvents.size() + unhandledIntents.size(); + 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, + itemCount, + nodeJson(root.get("extraction_warnings")) + ); + 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<>(itemCount); + for (int index = 0; index < messageEvents.size(); index++) { + JsonNode event = messageEvents.get(index); + int arrayIndex = index + 1; + V3EventContractIssue contractIssue = inspectV3EventContractIssue(event); + if (contractIssue != null) { + responseItems.add(createAdapterContractErrorTransition( + hotelId, + sourceMessageId, + batchId, + event, + arrayIndex, + sourceEventIndex(event, arrayIndex), + null, + contractIssue.code(), + contractIssue.message(), + now)); + continue; + } + ReservationAiRouteDefinition route = deriveV3EventRoute(event); + if (route == null) { + responseItems.add(createAdapterContractErrorTransition( + hotelId, + sourceMessageId, + batchId, + event, + arrayIndex, + sourceEventIndex(event, arrayIndex), + null, + "EVENT_ROUTE_UNSUPPORTED", + "message_events[" + index + "] 无法派生稳定路由。", + now)); + continue; + } + ObjectNode item = normalizeV3EventToTaskResultItem(event, route, arrayIndex); + responseItems.add(createItem(hotelId, sourceMessageId, batchId, item, arrayIndex, now)); + } + for (int index = 0; index < unhandledIntents.size(); index++) { + int arrayIndex = messageEvents.size() + index + 1; + responseItems.add(createAdapterContractErrorTransition( + hotelId, + sourceMessageId, + batchId, + unhandledIntents.get(index), + arrayIndex, + arrayIndex, + ReservationAiRouteDefinition.UNHANDLED_CURRENT_INTENT, + null, + null, + now)); + } + + return new SuperAgentTaskResultResponse( + safeRequestId, + resolvedSourceMessage.responseSourceMessageId(), + batchId.toString(), + false, + responseItems.size(), + responseItems, + List.of() + ); + } + + /** + * 校验结构化 S10/S99 的最小形态,避免把联合值或空复核误收为正常通知。 + */ + private void validateV3SourceMessageNotificationShape(JsonNode root, ReservationAiRouteDefinition route) { + JsonNode manualReview = root.get("manual_review"); + if (route == ReservationAiRouteDefinition.SOURCE_MESSAGE_S10 + && manualReview != null + && !manualReview.isNull()) { + throw error(HttpStatus.BAD_REQUEST, "ADAPTER_CONTRACT_ERROR", "S10 manual_review 必须为空。"); + } + if (route == ReservationAiRouteDefinition.SOURCE_MESSAGE_S99 + && (manualReview == null || manualReview.isNull() || !manualReview.isObject())) { + throw error(HttpStatus.BAD_REQUEST, "ADAPTER_CONTRACT_ERROR", "S99 manual_review 必须为入口复核对象。"); + } + } + + /** + * 将 V3 业务 event 转为旧任务结果 item 形态,复用现有订单、任务和任务卡创建逻辑。 + */ + private ObjectNode normalizeV3EventToTaskResultItem( + JsonNode event, + ReservationAiRouteDefinition route, + int arrayIndex) { + ObjectNode item = objectMapper.createObjectNode(); + item.put("source_event_index", sourceEventIndex(event, arrayIndex)); + item.put("catalog_code", V3_CATALOG_CODE); + item.put("skill_id", V3_SKILL_ID); + item.put("result_type", route.resultType()); + item.put("task_type", route.taskType()); + item.put("task_subtype", route.taskSubtype()); + copyIfPresent(event, item, "current_or_history"); + copyIfPresent(event, item, "case_keys"); + copyIfPresent(event, item, "extracted_fields"); + copyIfPresent(event, item, "manual_review"); + copyIfPresent(event, item, "attachments"); + copyIfPresent(event, item, "file_references"); + copyIfPresent(event, item, "context_used"); + copyIfPresent(event, item, "parent_source_event_index"); + copyIfPresent(event, item, "linked_task_group_id"); + copyIfPresent(event, item, "blocked_until_parent_completed"); + item.set("v3_message_event", event); + item.put("route_code", route.routeCode()); + item.put("system_process_category", route.systemProcessCategory().name()); + return item; + } + + /** + * 对 V3 event 做最小路由派生,未覆盖或判别字段不足时返回 null,由调用方落契约错误。 + */ + private ReservationAiRouteDefinition deriveV3EventRoute(JsonNode event) { + String eventType = trimToNull(textAt(event, "event_type")); + String resultType = isNullOrMissing(event.get("manual_review")) + ? AiResultType.NORMAL_TASK.code() + : AiResultType.MANUAL_REVIEW.code(); + String taskType = deriveTaskType(eventType); + String taskSubtype = deriveTaskSubtype(event, eventType); + if (taskType == null || taskSubtype == null) { + return null; + } + return ReservationAiRouteDefinition.findByTriplet(resultType, taskType, taskSubtype).orElse(null); + } + + /** + * 按 V3 event_type 派生系统使用的 AI task_type。 + */ + private String deriveTaskType(String eventType) { + if ("Update Booking / Amendment".equals(eventType)) { + return "Update Booking"; + } + if ("AMEND GROUP CODE".equals(eventType)) { + return "Amend Group Code"; + } + if ("Trace".equals(eventType)) { + return "Trace / Reservation Notes"; + } + if ("TA RECORDER".equals(eventType)) { + return "TA Recorder"; + } + if ("Need Manual Review".equals(eventType)) { + return "Fallback"; + } + return eventType; + } + + /** + * 按 V3 event_type 和判别字段派生 task_subtype;字段不足时返回 null 并 fail closed。 + */ + private String deriveTaskSubtype(JsonNode event, String eventType) { + JsonNode extractedFields = event.path("extracted_fields"); + return switch (nullToEmpty(eventType)) { + case "New Booking" -> switch (nullToEmpty(firstText(extractedFields, event, "booking_object_type"))) { + case "FIT Reservation" -> "new_fit_reservation"; + case "Group Block" -> "new_group_block"; + case "Allotment / Control Block" -> "new_allotment_control_block"; + default -> null; + }; + case "Update Booking / Amendment" -> "update_booking_amendment"; + case "Cancel Booking" -> cancelBookingSubtype(event, extractedFields); + case "Cancel Allotment" -> "cancel_allotment_control_block"; + case "Voucher Received" -> "lian_tai_credit_voucher"; + case "Payment Evidence" -> "payment_evidence"; + case "Rooming List" -> "rooming_list"; + case "AMEND GROUP CODE" -> "amend_group_code"; + case "Trace" -> switch (nullToEmpty(firstText(extractedFields, event, "trace_subtype"))) { + case "extra_bed" -> "extra_bed"; + case "general_request" -> "general_request"; + default -> null; + }; + case "TA RECORDER" -> "maintain_ta_recorder"; + case "Invoice Generation" -> "invoice_generation"; + case "Invoice Received" -> "invoice_received"; + case "Payment Notice" -> "payment_notice"; + case "Manual RateCode" -> "manual_rate_code"; + case "Need Manual Review" -> "business_event_review"; + default -> null; + }; + } + + /** + * Cancel Booking 需要先识别 parent split 关系,再识别普通取消对象。 + */ + private String cancelBookingSubtype(JsonNode event, JsonNode extractedFields) { + String relationshipType = firstText(extractedFields, event, "relationship_type"); + if ("linked_parent_release_after_child_split".equals(relationshipType)) { + return "linked_parent_release_after_child_split"; + } + return switch (nullToEmpty(firstText(extractedFields, event, "cancel_object_type"))) { + case "fit_reservation" -> "cancel_fit_reservation"; + case "group_block" -> "cancel_group_block"; + default -> null; + }; + } + + /** + * 识别 V3 event 中已经明确暴露的契约问题。命中后该 event 不建业务卡,只保存 transition。 + */ + private V3EventContractIssue inspectV3EventContractIssue(JsonNode event) { + if (nonEmptyArray(event, "contract_errors")) { + return new V3EventContractIssue("EVENT_CONTRACT_ERROR", "message_event 携带 contract_errors,第一版不建业务任务。"); + } + if (nonEmptyArray(event, "missing_fields")) { + return new V3EventContractIssue("EVENT_CONTRACT_INCOMPLETE", "message_event 根节点携带 missing_fields,第一版不建业务任务。"); + } + JsonNode manualReview = event == null ? null : event.get("manual_review"); + if (!isNullOrMissing(manualReview)) { + V3EventContractIssue manualReviewIssue = inspectV3ManualReviewContractIssue(manualReview); + if (manualReviewIssue != null) { + return manualReviewIssue; + } + } + if (isLinkedParentReleaseCandidate(event) && !validLinkedParentReleaseCandidate(event)) { + return new V3EventContractIssue( + "LINKED_PARENT_RELEASE_CONTRACT_INCOMPLETE", + "linked_parent_release_after_child_split 关系字段不完整,第一版不建业务任务。"); + } + return null; + } + + /** + * 校验 V3 type-known manual_review 的九字段基础结构,完整字段解阻逻辑后续 checkpoint 再做。 + */ + private V3EventContractIssue inspectV3ManualReviewContractIssue(JsonNode manualReview) { + if (!manualReview.isObject()) { + return new V3EventContractIssue("MANUAL_REVIEW_CONTRACT_INVALID", "manual_review 必须是对象。"); + } + String reviewRecordType = trimToNull(textAt(manualReview, "review_record_type")); + String reasonCode = trimToNull(textAt(manualReview, "reason_code")); + String visibleReason = trimToNull(textAt(manualReview, "visible_reason")); + if (!"business_event_review".equals(reviewRecordType) || reasonCode == null || visibleReason == null) { + return new V3EventContractIssue("MANUAL_REVIEW_CONTRACT_INCOMPLETE", "manual_review 九字段不完整。"); + } + if (!manualReview.path("known_fields").isObject() + || !manualReview.path("missing_fields").isArray() + || !manualReview.path("blocking_points").isArray() + || !manualReview.path("conflicting_points").isArray() + || !manualReview.path("suggested_human_actions").isArray() + || !manualReview.path("evidence_to_check").isArray()) { + return new V3EventContractIssue("MANUAL_REVIEW_CONTRACT_INCOMPLETE", "manual_review 九字段不完整。"); + } + for (JsonNode pointer : manualReview.path("missing_fields")) { + String pointerValue = pointer.isTextual() ? trimToNull(pointer.asText()) : null; + if (pointerValue == null || !pointerValue.startsWith("/")) { + return new V3EventContractIssue( + "MANUAL_REVIEW_MISSING_FIELD_POINTER_INVALID", + "manual_review.missing_fields 必须使用 RFC 6901 JSON Pointer。"); + } + } + return null; + } + + /** + * 判断是否为 parent release / cancel 派生事件候选。 + */ + private boolean isLinkedParentReleaseCandidate(JsonNode event) { + JsonNode extractedFields = event == null ? null : event.path("extracted_fields"); + return "linked_parent_release_after_child_split".equals(firstText(extractedFields, event, "relationship_type")); + } + + /** + * 校验 parent split 候选的最小可追溯关系字段,避免只凭 relationship_type 建业务任务。 + */ + private boolean validLinkedParentReleaseCandidate(JsonNode event) { + JsonNode extractedFields = event == null ? null : event.path("extracted_fields"); + JsonNode caseKeys = event == null ? null : event.path("case_keys"); + return "Cancel Booking".equals(trimToNull(textAt(event, "event_type"))) + && "group_block".equals(firstText(extractedFields, event, "cancel_object_type")) + && trimToNull(textAt(caseKeys, "group_code")) != null + && nonEmptyArray(extractedFields, "child_group_codes") + && nonEmptyArray(event, "related_source_event_indices"); + } + /** * 接收 S000/S999 文本入口结果,创建只读来源消息任务,并隐藏其技术订单。 */ @@ -170,14 +546,31 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta String requestId, String defaultHotelId, String rawBody) { - String hotelId = requireText(defaultHotelId, "default_hotel_id", LENGTH_64); - SourceMessageInboxSnapshot sourceMessage = sourceMessageInboxRepository + return acceptSourceMessageOnlyEntryResult(entryResult, clientId, requestId, defaultHotelId, rawBody, null); + } + + /** + * 接收来源邮件只读结果。V3 已解析 SourceMessage 时直接复用,旧文本结果按默认酒店反查。 + */ + private SuperAgentTaskResultResponse acceptSourceMessageOnlyEntryResult( + SourceMessageOnlyEntryResult entryResult, + String clientId, + String requestId, + String defaultHotelId, + String rawBody, + SourceMessageInboxSnapshot resolvedSourceMessage) { + String hotelId = resolvedSourceMessage == null + ? requireText(defaultHotelId, "default_hotel_id", LENGTH_64) + : requireText(resolvedSourceMessage.hotelId(), "source_message.hotel_id", LENGTH_64); + SourceMessageInboxSnapshot sourceMessage = resolvedSourceMessage == null + ? sourceMessageInboxRepository .findByIdempotencyKey( hotelId, DEFAULT_SOURCE_PROVIDER, DEFAULT_SOURCE_CHANNEL, entryResult.externalSourceMessageId()) - .orElseThrow(() -> error(HttpStatus.NOT_FOUND, "SOURCE_MESSAGE_NOT_FOUND", "SourceMessage 不存在。")); + .orElseThrow(() -> error(HttpStatus.NOT_FOUND, "SOURCE_MESSAGE_NOT_FOUND", "SourceMessage 不存在。")) + : resolvedSourceMessage; String requestPayloadSha256 = sha256(rawBody == null ? "" : rawBody); String batchIdempotencyKey = sha256(BATCH_KEY_PREFIX + "|" + sourceMessage.id() + "|" + requestPayloadSha256); ReservationAiBatchSnapshot existingBatch = workflowRepository @@ -246,6 +639,8 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta 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("result_type", entryResult.resultTypeCode()); + itemPayload.put("route_code", entryResult.routeCode()); itemPayload.put("raw_answer", rawBody); String itemPayloadJson = nodeJson(itemPayload); String itemPayloadSha256 = sha256(itemPayloadJson); @@ -256,7 +651,7 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta + "|" + SOURCE_MESSAGE_ONLY_SKILL_ID + "|" - + SOURCE_MESSAGE_ONLY_RESULT_TYPE + + entryResult.resultTypeCode() + "|" + entryResult.resultCode().code() + "|" @@ -272,8 +667,10 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta 1, SOURCE_MESSAGE_ONLY_CATALOG_CODE, SOURCE_MESSAGE_ONLY_SKILL_ID, - SOURCE_MESSAGE_ONLY_RESULT_TYPE, + entryResult.resultTypeCode(), entryResult.resultCode().code(), + entryResult.routeCode(), + ReservationAiSystemProcessCategory.SOURCE_MESSAGE_NOTIFICATION.name(), ReservationSystemTaskType.SOURCE_MESSAGE_ONLY.name(), ReservationTaskCardType.SOURCE_MESSAGE_ONLY.name(), entryResult.resultCode().code(), @@ -293,6 +690,8 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta null, null, null, + null, + null, now )); ReservationOrderSnapshot order = createHiddenSourceMessageOnlyOrder(hotelId, sourceMessageId, now); @@ -303,7 +702,7 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta hotelId, sourceMessageId, transitionId, - SOURCE_MESSAGE_ONLY_RESULT_TYPE, + entryResult.resultTypeCode(), entryResult.resultCode().code(), mapping, entryResult.resultCode().code(), @@ -328,6 +727,9 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta 1, 1, transitionId.toString(), + entryResult.routeCode(), + ReservationAiSystemProcessCategory.SOURCE_MESSAGE_NOTIFICATION.name(), + null, order.id().toString(), taskCreation.taskId().toString(), ReservationSystemTaskType.SOURCE_MESSAGE_ONLY.name(), @@ -384,10 +786,26 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta Integer sourceEventIndex = requirePositiveInt(item, "source_event_index"); String catalogCode = requireText(textAt(item, "catalog_code"), "catalog_code", LENGTH_32); String skillId = requireText(textAt(item, "skill_id"), "skill_id", LENGTH_128); - String resultType = requireText(textAt(item, "result_type"), "result_type", LENGTH_32); + String resultType = requireText(textAt(item, "result_type"), "result_type", LENGTH_64); String aiTaskType = requireText(textAt(item, "task_type"), "task_type", LENGTH_64); String taskSubtype = optionalText(textAt(item, "task_subtype"), "task_subtype", LENGTH_128); - TaskTypeMapping mapping = mapTaskType(resultType, aiTaskType); + TaskTypeMapping mapping = mapTaskType(resultType, aiTaskType, taskSubtype); + ReservationAiRouteDefinition route = ReservationAiRouteDefinition + .findByTriplet(resultType, aiTaskType, taskSubtype) + .orElse(null); + String routeCode = optionalText(textAt(item, "route_code"), "route_code", LENGTH_64); + if (routeCode == null && route != null) { + routeCode = route.routeCode(); + } + String systemProcessCategory = optionalText( + textAt(item, "system_process_category"), + "system_process_category", + LENGTH_64); + if (systemProcessCategory == null) { + systemProcessCategory = route == null + ? ReservationAiSystemProcessCategory.BUSINESS_TASK.name() + : route.systemProcessCategory().name(); + } 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); @@ -418,6 +836,8 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta skillId, resultType, aiTaskType, + routeCode, + systemProcessCategory, mapping.systemTaskType().name(), mapping.taskCardType().name(), taskSubtype, @@ -437,6 +857,8 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta nodeJson(item.get("informational_message")), nodeJson(item.get("attachments")), nodeJson(item.get("context_used")), + null, + null, now )); @@ -483,6 +905,9 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta sourceEventIndex, arrayIndex, transitionId.toString(), + routeCode, + systemProcessCategory, + null, order.id().toString(), taskCreation.taskId().toString(), mapping.systemTaskType().name(), @@ -663,9 +1088,17 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta /** * 映射 AI 原始任务类型到系统主任务类型和任务卡类型。 */ - private TaskTypeMapping mapTaskType(String resultType, String aiTaskType) { + private TaskTypeMapping mapTaskType(String resultType, String aiTaskType, String taskSubtype) { + ReservationAiRouteDefinition route = ReservationAiRouteDefinition + .findByTriplet(resultType, aiTaskType, taskSubtype) + .orElse(null); + if (route != null && route.businessTask()) { + return new TaskTypeMapping(route.systemTaskType(), route.taskCardType()); + } if (AiResultType.MANUAL_REVIEW.code().equals(resultType) || "Fallback".equals(aiTaskType)) { - return new TaskTypeMapping(ReservationSystemTaskType.MANUAL_REVIEW, ReservationTaskCardType.FALLBACK_REVIEW); + return new TaskTypeMapping( + ReservationSystemTaskType.MANUAL_REVIEW, + ReservationTaskCardType.FALLBACK_REVIEW); } if (AiResultType.INFORMATIONAL_MESSAGE.code().equals(resultType) || "Message Notification".equals(aiTaskType)) { return new TaskTypeMapping( @@ -682,12 +1115,18 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta case "Cancel Booking" -> new TaskTypeMapping( ReservationSystemTaskType.CANCEL_BOOKING, ReservationTaskCardType.CANCEL_BOOKING); + case "Cancel Allotment" -> new TaskTypeMapping( + ReservationSystemTaskType.CANCEL_BOOKING, + ReservationTaskCardType.CANCEL_ALLOTMENT); case "Update Booking" -> new TaskTypeMapping( ReservationSystemTaskType.UPDATE_BOOKING, ReservationTaskCardType.UPDATE_BOOKING); case "Voucher Received" -> new TaskTypeMapping( ReservationSystemTaskType.UPDATE_BOOKING, ReservationTaskCardType.VOUCHER_RECEIVED); + case "Payment Evidence" -> new TaskTypeMapping( + ReservationSystemTaskType.UPDATE_BOOKING, + ReservationTaskCardType.PAYMENT_EVIDENCE); case "Rooming List" -> new TaskTypeMapping( ReservationSystemTaskType.UPDATE_BOOKING, ReservationTaskCardType.ROOMING_LIST); @@ -700,12 +1139,24 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta case "TA Recorder" -> new TaskTypeMapping( ReservationSystemTaskType.UPDATE_BOOKING, ReservationTaskCardType.TA_RECORDER); + case "Invoice Generation" -> new TaskTypeMapping( + ReservationSystemTaskType.UPDATE_BOOKING, + ReservationTaskCardType.INVOICE_GENERATION); + case "Invoice Received" -> new TaskTypeMapping( + ReservationSystemTaskType.UPDATE_BOOKING, + ReservationTaskCardType.INVOICE_RECEIVED); + case "Payment Notice" -> new TaskTypeMapping( + ReservationSystemTaskType.UPDATE_BOOKING, + ReservationTaskCardType.PAYMENT_NOTICE); + case "Manual RateCode" -> new TaskTypeMapping( + ReservationSystemTaskType.UPDATE_BOOKING, + ReservationTaskCardType.MANUAL_RATE_CODE); default -> throw error(HttpStatus.BAD_REQUEST, "TASK_RESULT_UNSUPPORTED_TYPE", "task_type 不可识别。"); }; } /** - * 解析 SuperAgent 入口阶段的纯文本结果。只支持 S000/S999,不把其他文本误判为业务结果。 + * 解析 SuperAgent 入口阶段的纯文本结果。兼容旧 S000/S999,也允许调试期文本 S10/S99。 */ private SourceMessageOnlyEntryResult parseSourceMessageOnlyEntryResult(String rawBody) { String trimmedBody = trimToNull(rawBody); @@ -719,14 +1170,18 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta return null; } if (separatorIndex < 0) { - throw error(HttpStatus.BAD_REQUEST, "SOURCE_MESSAGE_REQUIRED", "S000/S999 结果缺少 source_message_id。"); + throw error(HttpStatus.BAD_REQUEST, "SOURCE_MESSAGE_REQUIRED", "入口结果缺少 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。"); + throw error(HttpStatus.BAD_REQUEST, "SOURCE_MESSAGE_REQUIRED", "入口结果缺少 source_message_id。"); } validateLength(externalSourceMessageId, "source_message_id", LENGTH_256); - return new SourceMessageOnlyEntryResult(resultCode, externalSourceMessageId); + return new SourceMessageOnlyEntryResult( + resultCode, + externalSourceMessageId, + SOURCE_MESSAGE_ONLY_RESULT_TYPE, + resultCode.code()); } /** @@ -920,6 +1375,233 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta } } + /** + * 判断是否为 V3 基础设施输入错误。该结果不建 SourceMessage、批次、任务或通知卡。 + */ + private boolean isInfrastructureInputError(JsonNode root) { + return "infrastructure_input_error".equals(textAt(root, "result_type")); + } + + /** + * 判断是否为 V3 结构化 S10/S99 来源邮件通知根。 + */ + private boolean isV3SourceMessageNotification(JsonNode root) { + String routeCode = trimToNull(textAt(root, "route_code")); + return AiResultType.SOURCE_MESSAGE_REVIEW_NOTIFICATION.code().equals(textAt(root, "result_type")) + || "S10".equals(routeCode) + || "S99".equals(routeCode); + } + + /** + * 判断是否为 V3 业务根,业务根必须携带 source_message 和 message_events 数组。 + */ + private boolean isV3BusinessRoot(JsonNode root) { + return root != null + && root.has("source_message") + && root.path("message_events").isArray(); + } + + /** + * 解析 V3 根对象中的 SourceMessage 外部 ID,并按系统默认酒店反查 Inbox 内部记录。 + */ + private ResolvedSourceMessage resolveV3SourceMessage(JsonNode root, String defaultHotelId) { + JsonNode sourceMessageNode = root.path("source_message"); + String externalSourceMessageId = trimToNull(textAt(sourceMessageNode, "source_message_id")); + if (externalSourceMessageId == null) { + throw error(HttpStatus.BAD_REQUEST, "INFRASTRUCTURE_INPUT_ERROR", "source_message.source_message_id 缺失。"); + } + validateLength(externalSourceMessageId, "source_message.source_message_id", LENGTH_256); + String hotelId = requireText(defaultHotelId, "default_hotel_id", LENGTH_64); + SourceMessageInboxSnapshot sourceMessage = sourceMessageInboxRepository + .findByIdempotencyKey( + hotelId, + DEFAULT_SOURCE_PROVIDER, + DEFAULT_SOURCE_CHANNEL, + externalSourceMessageId) + .orElseThrow(() -> error(HttpStatus.NOT_FOUND, "SOURCE_MESSAGE_NOT_FOUND", "SourceMessage 不存在。")); + return new ResolvedSourceMessage(sourceMessage, externalSourceMessageId); + } + + /** + * 保存 V3 event 的 Adapter 契约错误或未处理意图,只落 AI transition,不创建订单和业务任务。 + */ + private SuperAgentTaskResultItemResponse createAdapterContractErrorTransition( + String hotelId, + Long sourceMessageId, + Long batchId, + JsonNode event, + int arrayIndex, + int sourceEventIndex, + ReservationAiRouteDefinition route, + String adapterErrorCode, + String adapterErrorMessage, + LocalDateTime now) { + 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(); + String aiTaskType = route == null + ? optionalTransitionSummary(textAt(event, "event_type"), LENGTH_64) + : route.taskType(); + if (aiTaskType == null) { + aiTaskType = ADAPTER_CONTRACT_ERROR_CODE; + } + String taskSubtype = route == null ? null : route.taskSubtype(); + String routeCode = route == null ? null : route.routeCode(); + String systemProcessCategory = route == null + ? ReservationAiSystemProcessCategory.ADAPTER_CONTRACT_ERROR.name() + : route.systemProcessCategory().name(); + String systemTaskType = route == null + ? ReservationSystemTaskType.ADAPTER_CONTRACT_ERROR.name() + : route.systemTaskType().name(); + String taskCardType = route == null + ? ReservationTaskCardType.ADAPTER_CONTRACT_ERROR.name() + : route.taskCardType().name(); + String itemPayloadJson = nodeJson(event); + if (itemPayloadJson == null) { + itemPayloadJson = "{}"; + } + String itemPayloadSha256 = sha256(itemPayloadJson); + String safeAdapterErrorCode = route == null + ? requireText(adapterErrorCode, "adapter_error_code", LENGTH_128) + : optionalTransitionSummary(adapterErrorCode, LENGTH_128); + String safeAdapterErrorMessage = optionalTransitionSummary(adapterErrorMessage, LENGTH_512); + String itemIdempotencyKey = sha256(ITEM_KEY_PREFIX + + "|" + sourceMessageId + + "|" + sourceEventIndex + + "|" + arrayIndex + + "|" + V3_CATALOG_CODE + + "|" + V3_SKILL_ID + + "|" + resultType + + "|" + aiTaskType + + "|" + nullToEmpty(taskSubtype) + + "|" + safeAdapterErrorCode + + "|" + itemPayloadSha256); + Long transitionId = workflowRepository.insertTransition(new ReservationAiTransitionDraft( + hotelId, + batchId, + sourceMessageId, + sourceEventIndex, + arrayIndex, + arrayIndex, + V3_CATALOG_CODE, + V3_SKILL_ID, + resultType, + aiTaskType, + routeCode, + systemProcessCategory, + systemTaskType, + taskCardType, + taskSubtype, + optionalTransitionSummary(textAt(event, "current_or_history"), LENGTH_32), + optionalTransitionSummary(textAt(caseKeys, "group_code"), LENGTH_128), + optionalTransitionSummary(textAt(caseKeys, "confirmation_number"), LENGTH_128), + itemPayloadSha256, + itemIdempotencyKey, + optionalTransitionSummary(textAt(manualReview, "reason_code"), LENGTH_128), + integerAt(event, "parent_source_event_index"), + optionalTransitionSummary(textAt(event, "linked_task_group_id"), LENGTH_128), + booleanAt(event, "blocked_until_parent_completed"), + itemPayloadJson, + nodeJson(event == null ? null : event.get("case_keys")), + nodeJson(event == null ? null : event.get("extracted_fields")), + nodeJson(event == null ? null : event.get("manual_review")), + null, + nodeJson(event == null ? null : event.get("attachments")), + nodeJson(event == null ? null : event.get("context_used")), + safeAdapterErrorCode, + safeAdapterErrorMessage, + now + )); + return new SuperAgentTaskResultItemResponse( + sourceEventIndex, + arrayIndex, + transitionId.toString(), + routeCode, + systemProcessCategory, + safeAdapterErrorCode, + null, + null, + systemTaskType, + taskCardType, + null, + null, + null); + } + + /** + * 将 V3 source_event_index 归一为数字;E1、"1" 和整数 1 都会转为 1。 + */ + private int sourceEventIndex(JsonNode event, int fallback) { + JsonNode indexNode = event == null ? null : event.get("source_event_index"); + if (indexNode == null || indexNode.isNull()) { + return fallback; + } + if (indexNode.isInt() || indexNode.isLong()) { + int value = indexNode.asInt(); + return value > 0 ? value : fallback; + } + String rawValue = trimToNull(indexNode.asText()); + if (rawValue == null) { + return fallback; + } + String numeric = rawValue.startsWith("E") || rawValue.startsWith("e") + ? rawValue.substring(1) + : rawValue; + try { + int value = Integer.parseInt(numeric); + return value > 0 ? value : fallback; + } catch (NumberFormatException exception) { + return fallback; + } + } + + /** + * 复制 V3 event 字段到归一化 item,缺失字段不写入。 + */ + private void copyIfPresent(JsonNode source, ObjectNode target, String fieldName) { + if (source == null || target == null || fieldName == null) { + return; + } + JsonNode value = source.get(fieldName); + if (value != null && !value.isNull()) { + target.set(fieldName, value); + } + } + + /** + * 判断 JSON 节点是否为空、缺失或 null。 + */ + private boolean isNullOrMissing(JsonNode node) { + return node == null || node.isMissingNode() || node.isNull(); + } + + /** + * 优先从主对象读取字段,读取不到再从备用对象读取。 + */ + private String firstText(JsonNode primary, JsonNode fallback, String fieldName) { + String value = trimToNull(textAt(primary, fieldName)); + return value == null ? trimToNull(textAt(fallback, fieldName)) : value; + } + + /** + * Adapter 契约错误路径只保存安全摘要,外部长字段不能反向导致整批回滚。 + */ + private String optionalTransitionSummary(String value, int maxLength) { + String trimmed = trimToNull(value); + if (trimmed == null) { + return null; + } + return trimmed.length() > maxLength ? trimmed.substring(0, maxLength) : trimmed; + } + + /** + * 判断数组字段是否存在且非空。 + */ + private boolean nonEmptyArray(JsonNode node, String fieldName) { + JsonNode value = node == null ? null : node.path(fieldName); + return value != null && value.isArray() && value.size() > 0; + } + /** * 判断文本是否有效。 */ @@ -936,6 +1618,15 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta ) { } + /** + * V3 单个 event 的契约问题摘要。只保存稳定 code 和安全说明,不保存客户原文。 + */ + private record V3EventContractIssue( + String code, + String message + ) { + } + /** * 将空白字符串转换为 null。 */ @@ -980,11 +1671,13 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta } /** - * SuperAgent S000/S999 文本结果解析值。 + * SuperAgent 来源邮件只读结果解析值。旧文本和 V3 结构化通知共用该载体。 */ private record SourceMessageOnlyEntryResult( SourceMessageOnlyResultCode resultCode, - String externalSourceMessageId + String externalSourceMessageId, + String resultTypeCode, + String routeCode ) { } diff --git a/server/src/main/resources/db/migration/V16__add_m002_v3_ai_route_fields.sql b/server/src/main/resources/db/migration/V16__add_m002_v3_ai_route_fields.sql new file mode 100644 index 0000000..2d470c3 --- /dev/null +++ b/server/src/main/resources/db/migration/V16__add_m002_v3_ai_route_fields.sql @@ -0,0 +1,24 @@ +-- M002 V3 入站路由基线:保存 AI 原始三元组之外的派生 route_code、系统处理分类和 Adapter 契约错误。 +ALTER TABLE workflow_reservation_ai_transition + MODIFY COLUMN result_type VARCHAR(64) NOT NULL COMMENT 'AI 结果类型,例如 normal_task、manual_review、source_message_review_notification'; + +ALTER TABLE workflow_reservation_task + MODIFY COLUMN result_type VARCHAR(64) NOT NULL COMMENT 'AI 结果类型,例如 normal_task、manual_review、source_message_review_notification'; + +ALTER TABLE workflow_reservation_ai_transition + ADD COLUMN route_code VARCHAR(64) NULL COMMENT 'M002 V3 派生路由码,S10/S99 使用外部 route_code,业务事件使用系统稳定路由码'; + +ALTER TABLE workflow_reservation_ai_transition + ADD COLUMN system_process_category VARCHAR(64) NULL COMMENT '系统处理分类:BUSINESS_TASK、SOURCE_MESSAGE_NOTIFICATION、UNHANDLED_CURRENT_INTENT、ADAPTER_CONTRACT_ERROR'; + +ALTER TABLE workflow_reservation_ai_transition + ADD COLUMN adapter_error_code VARCHAR(128) NULL COMMENT 'Adapter 契约错误代码,仅在当前 event 不建业务任务时保存'; + +ALTER TABLE workflow_reservation_ai_transition + ADD COLUMN adapter_error_message VARCHAR(512) NULL COMMENT 'Adapter 契约错误说明,仅保存安全摘要,不写入客户敏感原文'; + +CREATE INDEX idx_reservation_ai_transition_route + ON workflow_reservation_ai_transition (hotel_id, route_code); + +CREATE INDEX idx_reservation_ai_transition_process_category + ON workflow_reservation_ai_transition (hotel_id, system_process_category); diff --git a/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/common/enums/ReservationAiRouteDefinitionTest.java b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/common/enums/ReservationAiRouteDefinitionTest.java new file mode 100644 index 0000000..905cad1 --- /dev/null +++ b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/common/enums/ReservationAiRouteDefinitionTest.java @@ -0,0 +1,29 @@ +package cn.nianxx.thhotel.workflows.reservation.common.enums; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; + +class ReservationAiRouteDefinitionTest { + + @Test + void shouldKeepFortyTwoM002V3P0RouteDefinitions() { + assertThat(ReservationAiRouteDefinition.values()).hasSize(42); + } + + @Test + void shouldResolveSourceMessageAndBusinessRoutesByStableKeys() { + assertThat(ReservationAiRouteDefinition.findByRouteCode("S10")) + .contains(ReservationAiRouteDefinition.SOURCE_MESSAGE_S10); + assertThat(ReservationAiRouteDefinition.findByTriplet( + "normal_task", + "New Booking", + "new_fit_reservation")) + .contains(ReservationAiRouteDefinition.NEW_FIT_RESERVATION_NORMAL); + assertThat(ReservationAiRouteDefinition.findByTriplet( + "manual_review", + "Payment Evidence", + "payment_evidence")) + .contains(ReservationAiRouteDefinition.PAYMENT_EVIDENCE_REVIEW); + } +} 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 dbaa2f0..85b7c6d 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 @@ -616,6 +616,434 @@ class SuperAgentTaskResultControllerTest { .andExpect(jsonPath("$.error_code").value("SOURCE_MESSAGE_NOT_FOUND")); } + @Test + void shouldCreateReadOnlySourceMessageOnlyTaskForStructuredS10Result() throws Exception { + SourceMessageCaptureResult source = captureSourceMessage("mail-v3-s10-entry-result-001"); + String body = """ + { + "source_message": { + "source_message_id": "mail-v3-s10-entry-result-001", + "subject": null, + "from": null, + "cc": [], + "received_at": null, + "source_channel": "Email" + }, + "route_code": "S10", + "handler_type": "main_agent_outcome", + "result_type": "source_message_review_notification", + "current_or_history": "current", + "agent_assessment": { + "status": "no_booking_action_detected", + "reason_code": "no_booking_action_detected", + "automation_action": "none" + }, + "notification": { + "required": true, + "notification_type": "source_message_review", + "show_source_message": true, + "requires_user_decision": true + }, + "manual_review": null + } + """; + + MvcResult result = mockMvc.perform(signedPost(body, "nonce-v3-s10-entry-result-001")) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.source_message_id").value("mail-v3-s10-entry-result-001")) + .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")) + .andReturn(); + String taskId = com.jayway.jsonpath.JsonPath.read(result.getResponse().getContentAsString(), "$.items[0].task_id"); + + Long transitionCount = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) + FROM workflow_reservation_ai_transition + WHERE source_message_id = ? + AND result_type = 'source_message_review_notification' + AND route_code = 'S10' + AND system_process_category = 'SOURCE_MESSAGE_NOTIFICATION' + """, Long.class, source.inboxId()); + Long taskCount = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) + FROM workflow_reservation_task + WHERE id = ? + AND source_message_id = ? + AND result_type = 'source_message_review_notification' + AND task_subtype = 'S10' + AND queue_participation = 0 + """, Long.class, Long.valueOf(taskId), source.inboxId()); + assertThat(transitionCount).isEqualTo(1L); + assertThat(taskCount).isEqualTo(1L); + } + + @Test + void shouldCreateReadOnlySourceMessageOnlyTaskForStructuredS99Result() throws Exception { + SourceMessageCaptureResult source = captureSourceMessage("mail-v3-s99-entry-result-001"); + String body = """ + { + "source_message": { + "source_message_id": "mail-v3-s99-entry-result-001", + "subject": null, + "from": null, + "cc": [], + "received_at": null, + "source_channel": "Email" + }, + "route_code": "S99", + "handler_type": "main_agent_outcome", + "result_type": "source_message_review_notification", + "current_or_history": "current", + "agent_assessment": { + "status": "insufficient_business_material", + "reason_code": "cannot_form_business_material_package", + "automation_action": "none" + }, + "notification": { + "required": true, + "notification_type": "source_message_review", + "show_source_message": true, + "requires_user_decision": true + }, + "manual_review": { + "reason_code": "cannot_form_business_material_package", + "review_notes": "需要人工查看原邮件。" + } + } + """; + + MvcResult result = mockMvc.perform(signedPost(body, "nonce-v3-s99-entry-result-001")) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.source_message_id").value("mail-v3-s99-entry-result-001")) + .andExpect(jsonPath("$.items[0].route_code").value("S99")) + .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")) + .andReturn(); + String taskId = com.jayway.jsonpath.JsonPath.read(result.getResponse().getContentAsString(), "$.items[0].task_id"); + + Long transitionCount = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) + FROM workflow_reservation_ai_transition + WHERE source_message_id = ? + AND result_type = 'source_message_review_notification' + AND route_code = 'S99' + AND system_process_category = 'SOURCE_MESSAGE_NOTIFICATION' + """, Long.class, source.inboxId()); + Long taskCount = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) + FROM workflow_reservation_task + WHERE id = ? + AND source_message_id = ? + AND result_type = 'source_message_review_notification' + AND task_subtype = 'S99' + AND queue_participation = 0 + """, Long.class, Long.valueOf(taskId), source.inboxId()); + assertThat(transitionCount).isEqualTo(1L); + assertThat(taskCount).isEqualTo(1L); + } + + @Test + void shouldCreateBusinessTaskFromV3BusinessRootMessageEvent() throws Exception { + SourceMessageCaptureResult source = captureSourceMessage("mail-v3-business-root-001"); + String body = """ + { + "source_message": { + "source_message_id": "mail-v3-business-root-001", + "subject": "New booking", + "from": null, + "cc": [], + "received_at": null, + "source_channel": "Email" + }, + "message_events": [ + { + "event_type": "New Booking", + "source_event_index": "E1", + "current_or_history": "current", + "case_keys": { + "group_code": null, + "confirmation_number": "CNF-V3-NEW-001" + }, + "extracted_fields": { + "booking_object_type": "FIT Reservation", + "arrival_date": "2026-09-01" + }, + "manual_review": null + } + ], + "case_candidates": [], + "extraction_warnings": [], + "unhandled_current_intents": [] + } + """; + + mockMvc.perform(signedPost(body, "nonce-v3-business-root-001")) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.accepted_count").value(1)) + .andExpect(jsonPath("$.items[0].source_event_index").value(1)) + .andExpect(jsonPath("$.items[0].route_code").value("R01_NEW_FIT_RESERVATION_NORMAL")) + .andExpect(jsonPath("$.items[0].system_process_category").value("BUSINESS_TASK")) + .andExpect(jsonPath("$.items[0].system_task_type").value("NEW_BOOKING")) + .andExpect(jsonPath("$.items[0].task_card_type").value("NEW_BOOKING")) + .andExpect(jsonPath("$.items[0].task_status").value("PENDING_CONFIRM")) + .andExpect(jsonPath("$.items[0].order_status").value("ACTIVE")); + + Long transitionCount = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) + FROM workflow_reservation_ai_transition + WHERE source_message_id = ? + AND source_event_index = 1 + AND result_type = 'normal_task' + AND ai_task_type = 'New Booking' + AND task_subtype = 'new_fit_reservation' + AND route_code = 'R01_NEW_FIT_RESERVATION_NORMAL' + AND system_process_category = 'BUSINESS_TASK' + """, Long.class, source.inboxId()); + assertThat(transitionCount).isEqualTo(1L); + } + + @Test + void shouldPersistAdapterContractErrorWhenV3EventRouteCannotBeDerived() throws Exception { + SourceMessageCaptureResult source = captureSourceMessage("mail-v3-adapter-error-001"); + String body = """ + { + "source_message": { + "source_message_id": "mail-v3-adapter-error-001", + "subject": "Trace without subtype", + "from": null, + "cc": [], + "received_at": null, + "source_channel": "Email" + }, + "message_events": [ + { + "event_type": "Trace", + "source_event_index": "E2", + "current_or_history": "current", + "case_keys": { + "group_code": "GRP-V3-ERR-001", + "confirmation_number": null + }, + "extracted_fields": {}, + "manual_review": null + } + ], + "case_candidates": [], + "extraction_warnings": [], + "unhandled_current_intents": [] + } + """; + + mockMvc.perform(signedPost(body, "nonce-v3-adapter-error-001")) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.accepted_count").value(1)) + .andExpect(jsonPath("$.items[0].source_event_index").value(2)) + .andExpect(jsonPath("$.items[0].system_process_category").value("ADAPTER_CONTRACT_ERROR")) + .andExpect(jsonPath("$.items[0].adapter_error_code").value("EVENT_ROUTE_UNSUPPORTED")) + .andExpect(jsonPath("$.items[0].system_task_type").value("ADAPTER_CONTRACT_ERROR")); + + Long transitionCount = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) + FROM workflow_reservation_ai_transition + WHERE source_message_id = ? + AND result_type = 'adapter_contract_error' + AND system_process_category = 'ADAPTER_CONTRACT_ERROR' + AND adapter_error_code = 'EVENT_ROUTE_UNSUPPORTED' + """, Long.class, source.inboxId()); + Long taskCount = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) + FROM workflow_reservation_task + WHERE source_message_id = ? + """, Long.class, source.inboxId()); + assertThat(transitionCount).isEqualTo(1L); + assertThat(taskCount).isZero(); + } + + @Test + void shouldPersistAdapterContractErrorForExplicitV3ContractErrorsAndContinueSiblingEvents() throws Exception { + SourceMessageCaptureResult source = captureSourceMessage("mail-v3-contract-error-sibling-001"); + String overlongEventType = "Unsupported Event " + "X".repeat(120); + String body = """ + { + "source_message": { + "source_message_id": "mail-v3-contract-error-sibling-001", + "subject": "Mixed events", + "from": null, + "cc": [], + "received_at": null, + "source_channel": "Email" + }, + "message_events": [ + { + "event_type": "%s", + "source_event_index": "E1", + "current_or_history": "current", + "case_keys": { + "group_code": "%s", + "confirmation_number": null + }, + "extracted_fields": {}, + "contract_errors": [ + { + "path": "/extracted_fields", + "code": "unsupported_contract" + } + ], + "manual_review": null + }, + { + "event_type": "New Booking", + "source_event_index": "E2", + "current_or_history": "current", + "case_keys": { + "group_code": null, + "confirmation_number": "CNF-V3-SIBLING-001" + }, + "extracted_fields": { + "booking_object_type": "FIT Reservation" + }, + "manual_review": null + } + ], + "case_candidates": [], + "extraction_warnings": [], + "unhandled_current_intents": [] + } + """.formatted(overlongEventType, "GRP-" + "Y".repeat(180)); + + mockMvc.perform(signedPost(body, "nonce-v3-contract-error-sibling-001")) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.accepted_count").value(2)) + .andExpect(jsonPath("$.items[0].system_process_category").value("ADAPTER_CONTRACT_ERROR")) + .andExpect(jsonPath("$.items[0].adapter_error_code").value("EVENT_CONTRACT_ERROR")) + .andExpect(jsonPath("$.items[1].route_code").value("R01_NEW_FIT_RESERVATION_NORMAL")) + .andExpect(jsonPath("$.items[1].system_task_type").value("NEW_BOOKING")); + + Long transitionCount = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) + FROM workflow_reservation_ai_transition + WHERE source_message_id = ? + """, Long.class, source.inboxId()); + Long taskCount = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) + FROM workflow_reservation_task + WHERE source_message_id = ? + """, Long.class, source.inboxId()); + assertThat(transitionCount).isEqualTo(2L); + assertThat(taskCount).isEqualTo(1L); + } + + @Test + void shouldPersistAdapterContractErrorForIncompleteV3ManualReview() throws Exception { + SourceMessageCaptureResult source = captureSourceMessage("mail-v3-manual-review-incomplete-001"); + String body = """ + { + "source_message": { + "source_message_id": "mail-v3-manual-review-incomplete-001", + "subject": "Manual review incomplete", + "from": null, + "cc": [], + "received_at": null, + "source_channel": "Email" + }, + "message_events": [ + { + "event_type": "New Booking", + "source_event_index": "E1", + "current_or_history": "current", + "case_keys": { + "group_code": null, + "confirmation_number": "CNF-V3-MR-001" + }, + "extracted_fields": { + "booking_object_type": "FIT Reservation" + }, + "manual_review": { + "reason_code": "missing_room_type" + } + } + ], + "case_candidates": [], + "extraction_warnings": [], + "unhandled_current_intents": [] + } + """; + + mockMvc.perform(signedPost(body, "nonce-v3-manual-review-incomplete-001")) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.accepted_count").value(1)) + .andExpect(jsonPath("$.items[0].system_process_category").value("ADAPTER_CONTRACT_ERROR")) + .andExpect(jsonPath("$.items[0].adapter_error_code").value("MANUAL_REVIEW_CONTRACT_INCOMPLETE")); + + Long transitionCount = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) + FROM workflow_reservation_ai_transition + WHERE source_message_id = ? + AND adapter_error_code = 'MANUAL_REVIEW_CONTRACT_INCOMPLETE' + """, Long.class, source.inboxId()); + Long taskCount = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) + FROM workflow_reservation_task + WHERE source_message_id = ? + """, Long.class, source.inboxId()); + assertThat(transitionCount).isEqualTo(1L); + assertThat(taskCount).isZero(); + } + + @Test + void shouldPersistUnhandledCurrentIntentWithDedicatedProcessCategory() throws Exception { + SourceMessageCaptureResult source = captureSourceMessage("mail-v3-unhandled-current-intent-001"); + String body = """ + { + "source_message": { + "source_message_id": "mail-v3-unhandled-current-intent-001", + "subject": "Unhandled current intent", + "from": null, + "cc": [], + "received_at": null, + "source_channel": "Email" + }, + "message_events": [], + "case_candidates": [], + "extraction_warnings": [], + "unhandled_current_intents": [ + { + "intent_type": "unsupported_active_request", + "reason_code": "no_task_card_available" + } + ] + } + """; + + mockMvc.perform(signedPost(body, "nonce-v3-unhandled-current-intent-001")) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.accepted_count").value(1)) + .andExpect(jsonPath("$.items[0].route_code").value("R42_UNHANDLED_CURRENT_INTENT")) + .andExpect(jsonPath("$.items[0].system_process_category").value("UNHANDLED_CURRENT_INTENT")) + .andExpect(jsonPath("$.items[0].adapter_error_code").doesNotExist()) + .andExpect(jsonPath("$.items[0].system_task_type").value("SOURCE_MESSAGE_ONLY")) + .andExpect(jsonPath("$.items[0].task_id").doesNotExist()); + + Long transitionCount = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) + FROM workflow_reservation_ai_transition + WHERE source_message_id = ? + AND route_code = 'R42_UNHANDLED_CURRENT_INTENT' + AND system_process_category = 'UNHANDLED_CURRENT_INTENT' + AND adapter_error_code IS NULL + """, Long.class, source.inboxId()); + Long taskCount = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) + FROM workflow_reservation_task + WHERE source_message_id = ? + """, Long.class, source.inboxId()); + assertThat(transitionCount).isEqualTo(1L); + assertThat(taskCount).isZero(); + } + @Test void shouldRejectUnsupportedContentTypeForTaskResultCallback() throws Exception { String body = "S000,source-message-unsupported-content-type-001";