diff --git a/docs/project/README.md b/docs/project/README.md index 486af6e..5b1394e 100644 --- a/docs/project/README.md +++ b/docs/project/README.md @@ -10,4 +10,9 @@ - `frontend-development-guidelines.md`:当前项目前端专属规范。 - `go-live-notes.md`:当前项目上线注意事项,记录上线前检查、环境变量、安全、AgentBus、验证和回滚。 - `requirements/M001-source-message-inbox-prd.md`:M001 邮件来源入口 PRD,记录 AgentBus 邮件 JSON 入库、历史查询、原文读取和媒体引用边界。 +- `requirements/M002-order-task-workflow-v1.md`:M002 订单任务主流程 V1,记录 SourceMessage 之后的 SuperAgent 抽取、订单挂靠、任务处理和 OPERA/OHIP 模拟操作边界。 +- `requirements/M002-order-task-workflow-v2.md`:M002 订单任务主流程 V2,记录 AI 过渡层、任务卡矩阵、系统主任务类型、临时订单、订单号候选和 OPERA 模拟回填边界。 +- `requirements/M002-superagent-task-result-api-contract.md`:M002 SuperAgent 任务结果入站接口契约,记录 HMAC 鉴权、请求响应、幂等和错误码。 +- `requirements/M002-backend-data-model-design.md`:M002 后端数据模型设计,记录 AI 过渡层、订单、任务、任务卡、审计和 OPERA 模拟结果表。 +- `requirements/M002-backend-checkpoint-plan.md`:M002 后端 checkpoint 计划,记录后端实现拆分、交付物和验收标准。 - `integrations/superagent-agentbus-project-integration-guide.md`:当前项目 SuperAgent 与 AgentBus 验证记录和项目级接入细节。 diff --git a/docs/project/go-live-notes.md b/docs/project/go-live-notes.md index cd32597..ba4ab29 100644 --- a/docs/project/go-live-notes.md +++ b/docs/project/go-live-notes.md @@ -82,6 +82,11 @@ - `server/src/main/resources/db/migration/V1__create_source_message_inbox.sql` - `server/src/main/resources/db/migration/V2__create_source_message_original_access_audit.sql` +当前 M002 订单任务相关 migration: + +- `server/src/main/resources/db/migration/V3__create_reservation_ai_task_workflow.sql` +- `server/src/main/resources/db/migration/V4__harden_reservation_task_queue_and_manual_conversion.sql` + 上线前确认: - 目标数据库为空库或 Flyway history 与当前代码一致。 @@ -89,6 +94,31 @@ - migration 在 UAT 或测试库已经跑过。 - 表和字段中文注释能正常创建。 - 数据库时间按 UTC 写入,接口层负责返回 ISO 8601。 +- 执行 V4 前,如果目标库已有 M002 试运行数据,必须先检查 ACTIVE 订单业务号重复和同订单任务队列序号重复。 + +V4 前置检查 SQL: + +```sql +SELECT hotel_id, order_key_type, order_business_key, COUNT(*) AS duplicate_count +FROM workflow_reservation_order +WHERE order_status = 'ACTIVE' + AND order_key_type IN ('GROUP_CODE', 'CONFIRMATION_NUMBER') + AND order_business_key IS NOT NULL +GROUP BY hotel_id, order_key_type, order_business_key +HAVING COUNT(*) > 1; + +SELECT hotel_id, order_id, queue_participation, execution_order, COUNT(*) AS duplicate_count +FROM workflow_reservation_task +GROUP BY hotel_id, order_id, queue_participation, execution_order +HAVING COUNT(*) > 1; +``` + +处理要求: + +- 如果任一 SQL 返回记录,不要继续执行 V4。 +- ACTIVE 订单业务号重复时,先由业务确认保留哪一条 ACTIVE,其他订单应转为 `LOGIC_DELETED`、`ENDED` 或完成任务迁移后再上线。 +- 同订单任务队列序号重复时,先按来源顺序和审计证据重新分配 `execution_order`,确认前置任务关系正确后再上线。 +- 不要为了让唯一索引创建成功而随意删除订单、任务或 AI 原始记录。 禁止事项: diff --git a/docs/project/requirements/M002-backend-checkpoint-plan.md b/docs/project/requirements/M002-backend-checkpoint-plan.md new file mode 100644 index 0000000..d687868 --- /dev/null +++ b/docs/project/requirements/M002-backend-checkpoint-plan.md @@ -0,0 +1,316 @@ +# M002 Backend Checkpoint Plan 后端开发 Checkpoint 计划 + +## 文档信息 + +| 项目 | 内容 | +| --- | --- | +| 文档版本 | 0.1 | +| 日期 | 2026-07-07 | +| 状态 | 后端开发计划草稿 | +| 适用范围 | M002 后端实现拆分、交付物和验收标准 | +| 主要读者 | 后端、测试、产品、后续协作 agent | + +## 1. 文档定位 + +本文把 `M002-order-task-workflow-v2.md`、`M002-superagent-task-result-api-contract.md` 和 `M002-backend-data-model-design.md` 拆成可执行后端 checkpoint。 + +每个 checkpoint 都应先读项目规范,再按本项目包结构和注释要求实现。不要一次性把完整后端做完,也不要在不确定字段或目录归属时先写再重构。 + +## 2. 开发前必读 + +后端开发前必须先读: + +- `AGENTS.md` +- `README.md` +- `docs/project/backend-development-guidelines.md` +- `docs/import/reusable/backend-development-guidelines.md` +- `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-superagent-task-result-api-contract.md` +- `docs/project/requirements/M002-backend-data-model-design.md` +- `docs/import/20260706/开发AI先读_工作顺序.md` +- `docs/import/20260706/AI输出参数并集字典.xlsx` +- `docs/import/20260706/任务卡展示编辑矩阵.xlsx` + +## 3. 代码组织要求 + +后端实现必须继续遵守当前项目包结构习惯: + +- `control` 放 Controller 具体实现。 +- `service` 放 Service 接口。 +- `service.impl` 放 Service 实现类。 +- `domain` 放 Entity。 +- `mapper` 放 MyBatis Mapper。 +- `repository` 放 Repository 接口和具体实现。 +- `common.dto`、`common.request`、`common.result` 放通用数据载体。 +- `common.enums` 放枚举。 +- 外部系统适配类放在对应 `integrations...adapter` 包。 + +Controller、Service、Service 实现类的方法必须有中文注释。Entity 字段必须有中文注释。Mapper 继承 MyBatis-Plus 的自带方法不强制写注释,自定义方法按复杂度补充中文注释。 + +## 4. Checkpoint 1:接口契约与安全骨架 + +名称:`m002-cp01-superagent-intake-security` + +目标: + +- 建立 SuperAgent 任务结果入站接口骨架。 +- 完成 HMAC 鉴权、请求体大小限制、timestamp 和 nonce 防重放。 +- 暂不创建完整订单任务,只返回受控错误或最小接收结果。 + +建议范围: + +- 新增配置项:`SUPERAGENT_TASK_RESULT_HMAC_SECRET` 等。 +- 新增 HMAC 校验组件。 +- 新增 nonce 去重存储,第一版可用数据库或可控缓存实现。 +- 新增请求 / 响应 DTO。 +- 新增统一错误响应。 + +验收标准: + +- 缺失 Header 返回 `401`。 +- timestamp 超时返回 `401`。 +- 签名错误返回 `401`。 +- nonce 重放返回 `409`。 +- 请求体过大返回 `413`。 +- 响应和日志不暴露 secret、签名原文或完整请求体。 + +不做: + +- 不做业务字段合法性判断。 +- 不执行 OPERA 模拟。 +- 不开发前端页面。 + +## 5. Checkpoint 2:AI 过渡层持久化 + +名称:`m002-cp02-ai-transition-persistence` + +目标: + +- 建立 AI 接收批次表和 AI 过渡表。 +- 保存 `source_message_id + ai_task_results[] + extraction_warnings[]`。 +- 实现系统生成幂等键,保证重复提交不重复创建记录。 + +建议范围: + +- Flyway migration。 +- Entity、Mapper、Repository。 +- 批次幂等键生成。 +- item 幂等键生成。 +- `source_message_id` 存在性校验。 +- 保存 AI 原始 JSON 和常用物理列。 + +验收标准: + +- 同一请求重复提交返回幂等重放,不重复插入。 +- 同一 `source_message_id` 不同请求体第一版返回 `409`。 +- 每个 item 保存 `array_index`、`source_event_index` 和 `execution_order`。 +- 保存 `ai_payload_json`,用户后续修改不得覆盖该字段。 + +不做: + +- 不创建 OPERA 操作。 +- 不实现任务详情编辑。 + +## 6. Checkpoint 3:订单、任务和任务卡最小模型 + +名称:`m002-cp03-order-task-card-model` + +目标: + +- 建立订单、任务和任务卡表。 +- 将 AI 过渡记录映射成系统订单、任务和任务卡。 +- 完成主任务类型和任务卡类型映射。 + +建议范围: + +- 订单状态枚举:`TEMPORARY`、`ACTIVE`、`ENDED`、`LOGIC_DELETED`。 +- 任务状态枚举:`PENDING_CONFIRM`、`READY`、`EXECUTING`、`FAILED`、`COMPLETED`。 +- 系统主任务类型枚举。 +- 任务卡类型枚举。 +- Message Notification 创建临时订单和只读任务卡。 +- Fallback / manual_review 创建临时订单和人工复核任务卡。 + +验收标准: + +- New Booking 无业务号时创建临时订单。 +- Update / Cancel 可匹配订单时挂已有订单,不可匹配时挂临时订单。 +- Message Notification 挂临时订单,但 `queue_participation=false`。 +- 任务保存 AI 原始 `task_type`、系统主任务类型、任务卡类型和 subtype。 +- 任务初始状态符合数据模型设计。 + +不做: + +- 不实现用户编辑确认。 +- 不实现 OPERA 模拟。 + +## 7. Checkpoint 4:订单挂靠与队列可处理状态 + +名称:`m002-cp04-order-queue-availability` + +目标: + +- 实现订单挂靠规则和同订单任务执行顺序。 +- 实现实时计算任务是否可处理。 +- 不落库 `BLOCKED` 状态。 + +建议范围: + +- 按 `order_id + execution_order` 查询任务队列。 +- 前置参与队列任务未完成时,后续任务只读。 +- 前置参与队列任务为 `FAILED` 或 `COMPLETED` 时视为结束,不阻塞后续任务。 +- `queue_participation=false` 的任务不阻塞队列。 +- 数据库通过 `hotel_id + order_id + queue_participation + execution_order` 唯一约束兜底,服务层遇到并发队列序号冲突时重新取号重试。 +- 联动任务识别 `parent_source_event_index`、`linked_task_group_id`、`blocked_until_parent_completed`。 +- 第一版可先提供任务详情接口,返回实时可处理状态和只读原因,前端页面后续再定。 + +验收标准: + +- 同一次 AI 返回多个任务时保留数组顺序。 +- 前置任务未完成时后续任务不能编辑、确认、执行或重试。 +- 前置任务为 `FAILED` 时后续任务不再被阻塞。 +- Message Notification 不阻塞其他任务,也不被其他任务阻塞。 +- 任务切换订单后按目标订单队列重新计算可处理状态。 + +不做: + +- 不允许用户强制完成前置任务。 +- 不允许跳过失败 OPERA 操作。 + +## 8. Checkpoint 5:任务详情、编辑和确认 payload + +名称:`m002-cp05-task-detail-confirmed-payload` + +目标: + +- 提供任务详情读取接口。 +- 支持用户编辑任务卡字段。 +- 用户确认后生成 `confirmed_payload_json`。 + +建议范围: + +- 任务详情 Response 包含 AI 原始摘要、任务卡字段、可处理状态和阻塞原因。 +- 第一版任务卡字段矩阵写在代码 Provider 中,避免 Controller / Service 直接硬编码。 +- 保存 `draft_payload_json`。 +- 确认时生成 `confirmed_payload_json`。 +- 审计用户字段修改和确认。 + +验收标准: + +- 不可处理任务详情只能查看,不能编辑或确认。 +- 可处理任务可以保存草稿。 +- 用户确认后任务进入 `READY`。 +- OPERA 参数不得从 `ai_payload_json` 读取。 +- 用户修改不覆盖 AI 原始 JSON。 + +不做: + +- 不接真实 OPERA。 +- 不把 Excel 全量字段导入数据库。 + +## 9. Checkpoint 6:人工复核转换和订单迁移 + +名称:`m002-cp06-manual-review-conversion` + +目标: + +- 实现 Fallback / manual_review 的人工处理。 +- 支持转为 New / Update / Cancel。 +- 支持任务从临时订单迁移到已有订单。 + +建议范围: + +- 转换类型接口。 +- 订单切换接口。 +- 临时订单逻辑删除。 +- 转换原因字段第一版允许为空,但必须记录审计。 +- 转为 `UPDATE_BOOKING` 或 `CANCEL_BOOKING` 时目标订单 ID 必填。 +- 审计转换、迁移和删除。 + +验收标准: + +- 转为 `NEW_BOOKING` 时临时订单可继续保留;如果 AI 原始 `case_keys` 已有 Group Code 或 Confirmation No.,第一版可先以 `AI_CANDIDATE` 激活原临时订单。 +- 转为 `UPDATE_BOOKING` 或 `CANCEL_BOOKING` 时必须选择目标订单。 +- 原临时订单无有效任务后进入 `LOGIC_DELETED`。 +- 所有转换都记录前后快照;原因有则记录,没有则为空。 + +不做: + +- 不允许绕过前置任务直接完成。 +- 不做 AI 业务合法性二次判断。 + +## 10. Checkpoint 7:OPERA 模拟操作与重试 + +名称:`m002-cp07-opera-simulation-results` + +目标: + +- 建立 OPERA 模拟逻辑操作和 attempt 记录。 +- 支持执行、失败、重试和结果展示。 +- 支持 New Booking 在必要时回填订单业务号。 + +建议范围: + +- 从 `confirmed_payload_json` 生成模拟操作。 +- 每个任务可生成多条操作。 +- 每次执行或重试新增 attempt。 +- 失败任务进入 `FAILED`。 +- 全部必要操作成功后任务进入 `COMPLETED`。 +- 从 `business_key_candidates_json` 回填订单业务号。 +- 第一版先在订单表设计 `business_key_source`、`business_key_backfilled_at` 等字段,OPERA 模拟执行模块后续再写入。 + +验收标准: + +- 未确认任务不能执行 OPERA 模拟。 +- 不可处理任务不能执行 OPERA 模拟。 +- 失败操作不能跳过。 +- 用户不能强制完成任务。 +- 重试不覆盖历史 attempt。 +- New Booking 无业务号时,模拟成功后可回填 Confirmation Number 或 Group Code 含义字段。 + +不做: + +- 不接真实 OPERA 接口。 +- 不写死真实 OPERA 返回字段路径。 + +## 11. Checkpoint 8:审计、查询和收口 + +名称:`m002-cp08-audit-query-hardening` + +目标: + +- 补齐审计查询、列表筛选、安全收口和回归测试。 + +建议范围: + +- 订单任务审计查询。 +- 任务列表基础筛选。 +- 订单详情下任务时间线。 +- 安全日志检查。 +- 关键接口集成测试。 + +验收标准: + +- 能追溯 AI 接收、任务创建、订单挂靠、用户修改、确认、迁移、OPERA 模拟和重试。 +- 列表不返回完整 AI payload、邮件正文、附件 URL 或 Secret。 +- 所有关键路径有测试覆盖。 +- `./mvnw test` 通过,或明确说明环境问题。 + +## 12. 建议开发节奏 + +建议一次只做一个 checkpoint。每个 checkpoint 完成后: + +- 先自查是否符合项目包结构和中文注释规范。 +- 运行相关测试。 +- 做 code review。 +- 再进入下一个 checkpoint。 + +如果实现过程中遇到以下情况,必须先暂停确认: + +- 订单状态需要新增第五种。 +- 任务状态需要新增新状态。 +- OPERA 模拟结果字段需要写死真实路径。 +- Excel 字段矩阵需要从代码配置改成数据库配置。 +- 某个类不知道应该放在哪个包。 +- 需要调整已确认的 SuperAgent 接口契约。 diff --git a/docs/project/requirements/M002-backend-data-model-design.md b/docs/project/requirements/M002-backend-data-model-design.md new file mode 100644 index 0000000..b37ac97 --- /dev/null +++ b/docs/project/requirements/M002-backend-data-model-design.md @@ -0,0 +1,400 @@ +# M002 Backend Data Model Design 后端数据模型设计 + +## 文档信息 + +| 项目 | 内容 | +| --- | --- | +| 文档版本 | 0.1 | +| 日期 | 2026-07-07 | +| 状态 | 后端数据模型草稿 | +| 适用范围 | AI 过渡层、订单、任务、任务卡、审计和 OPERA 模拟结果 | +| 主要读者 | 后端、数据库、测试、后续协作 agent | + +## 1. 文档定位 + +本文定义 M002 第一阶段后端数据模型草案,用于支撑 SuperAgent 任务结果入站、订单挂靠、任务卡确认、队列顺序、审计和 OPERA 模拟结果。 + +本文不是最终建表 SQL。后续写代码前,应按本项目后端规范补充 Flyway migration、Entity 中文注释、Mapper、Repository、Service 和测试。 + +## 2. 设计原则 + +- 保留 AI 原始 JSON,不覆盖、不重写、不丢字段。 +- 用户确认后的数据写入 `confirmed_payload_json`,OPERA 模拟只读取确认后的 payload。 +- 高频查询、幂等、排序和状态机字段冗余为物理列。 +- 任务卡字段第一版先写在代码里,但必须通过低耦合 Provider 封装,后续可迁移为数据库配置。 +- `BLOCKED` 不作为任务持久状态,而是根据同订单前置任务是否完成实时计算。 +- 用户不允许强制完成任务。 +- 用户不能跳过失败的 OPERA 模拟操作。 +- 同订单队列可处理状态实时计算时,`FAILED` 和 `COMPLETED` 都视为前置任务已结束,不阻塞后续任务。 +- OPERA 当前是模拟结构,后续真实系统接入时通过 adapter 映射,不污染核心任务模型。 + +## 3. 枚举 + +### 3.1 订单状态 + +| 状态 | 中文说明 | +| --- | --- | +| `TEMPORARY` | 临时订单,用于 New Booking 未取得业务号、Fallback、Message Notification 或暂无法匹配订单的任务容器 | +| `ACTIVE` | 有效订单,可承载可处理业务任务 | +| `ENDED` | 已结束订单,例如已完成、已取消或业务生命周期结束;仍可查看历史 | +| `LOGIC_DELETED` | 逻辑删除订单,主要用于临时订单迁移后废弃,不再承载新任务 | + +### 3.2 任务状态 + +| 状态 | 中文说明 | +| --- | --- | +| `PENDING_CONFIRM` | 待用户确认订单归属和任务字段 | +| `READY` | 已确认,等待执行 OPERA 模拟 | +| `EXECUTING` | 正在执行 OPERA 模拟 | +| `FAILED` | OPERA 模拟失败,必须重试或修正后再执行,不能跳过 | +| `COMPLETED` | 任务已完成 | + +说明: + +- `Message Notification` 不参与执行队列,可在创建后直接进入 `COMPLETED` 或保持只读完成态。 +- `manual_review` / `Fallback` 默认进入 `PENDING_CONFIRM`,等待人工转换或处理。 +- 阻塞态通过查询同订单前置任务实时计算,不落 `BLOCKED` 状态。 + +### 3.3 系统主任务类型 + +| 类型 | 中文说明 | +| --- | --- | +| `NEW_BOOKING` | 新建订单任务 | +| `UPDATE_BOOKING` | 更新订单任务,下面包含多种任务卡 | +| `CANCEL_BOOKING` | 取消订单任务 | +| `MANUAL_REVIEW` | 人工复核 / Fallback 任务 | +| `INFORMATIONAL_MESSAGE` | 只读信息提醒任务,不参与执行队列 | + +### 3.4 任务卡类型 + +第一版建议包含: + +- `NEW_BOOKING` +- `UPDATE_BOOKING` +- `CANCEL_BOOKING` +- `VOUCHER_RECEIVED` +- `ROOMING_LIST` +- `AMEND_GROUP_CODE` +- `TRACE_RESERVATION_NOTES` +- `TA_RECORDER` +- `MESSAGE_NOTIFICATION` +- `FALLBACK_REVIEW` + +## 4. 表设计总览 + +| 表名 | 中文说明 | +| --- | --- | +| `workflow_reservation_ai_batch` | AI 任务结果接收批次表 | +| `workflow_reservation_ai_transition` | AI 任务结果过渡表,一条 AI item 一行 | +| `workflow_reservation_order` | 订单表 | +| `workflow_reservation_task` | 任务表 | +| `workflow_reservation_task_card` | 任务卡确认数据表 | +| `workflow_reservation_audit_log` | 订单任务审计表 | +| `workflow_reservation_opera_operation` | OPERA 模拟逻辑操作表 | +| `workflow_reservation_opera_operation_attempt` | OPERA 模拟操作尝试记录表 | + +表名前缀使用 `workflow_reservation`,表示当前属于 reservation 工作流,不放入平台中立 `platform` 模块。 + +## 5. AI 接收批次表 + +表名:`workflow_reservation_ai_batch` + +| 字段 | 类型建议 | 中文说明 | +| --- | --- | --- | +| `id` | `BIGINT` | 批次 ID | +| `hotel_id` | `VARCHAR(64)` | 酒店或业务上下文 | +| `source_message_id` | `BIGINT` | 关联 SourceMessage | +| `request_payload_sha256` | `CHAR(64)` | 原始请求体 SHA-256 | +| `batch_idempotency_key` | `CHAR(64)` | 系统生成的批次幂等键 | +| `client_id` | `VARCHAR(128)` | SuperAgent 调用方 ID | +| `request_id` | `VARCHAR(128)` | 调用方请求 ID,可为空 | +| `received_at` | `DATETIME(6)` | 接收时间 | +| `item_count` | `INT` | AI item 数量 | +| `extraction_warnings_json` | `LONGTEXT` | 抽取警告 JSON | +| `created_at` / `updated_at` | `DATETIME(6)` | 创建和更新时间 | + +索引建议: + +- 唯一索引:`hotel_id + batch_idempotency_key` +- 普通索引:`hotel_id + source_message_id` +- 普通索引:`hotel_id + received_at` + +## 6. AI 过渡表 + +表名:`workflow_reservation_ai_transition` + +一条 `ai_task_results[]` item 对应一行。 + +| 字段 | 类型建议 | 中文说明 | +| --- | --- | --- | +| `id` | `BIGINT` | AI 过渡记录 ID | +| `hotel_id` | `VARCHAR(64)` | 酒店或业务上下文 | +| `batch_id` | `BIGINT` | 接收批次 ID | +| `source_message_id` | `BIGINT` | 来源消息 ID | +| `source_event_index` | `INT` | AI current 事件序号 | +| `array_index` | `INT` | AI 返回列表中的顺序,建议从 1 开始 | +| `execution_order` | `INT` | 映射到订单任务队列的初始顺序 | +| `catalog_code` | `VARCHAR(32)` | Skill 目录代码 | +| `skill_id` | `VARCHAR(128)` | Skill 标识 | +| `result_type` | `VARCHAR(32)` | AI 结果类型 | +| `ai_task_type` | `VARCHAR(64)` | AI 原始任务类型 | +| `system_task_type` | `VARCHAR(64)` | 系统主任务类型 | +| `task_card_type` | `VARCHAR(64)` | 任务卡类型 | +| `task_subtype` | `VARCHAR(128)` | 业务动作 subtype | +| `current_or_history` | `VARCHAR(32)` | 当前或历史标识 | +| `group_code` | `VARCHAR(128)` | Group Code 候选 | +| `confirmation_number` | `VARCHAR(128)` | Confirmation Number 候选 | +| `item_payload_sha256` | `CHAR(64)` | 单个 item JSON 哈希 | +| `item_idempotency_key` | `CHAR(64)` | 系统生成 item 幂等键 | +| `manual_reason_code` | `VARCHAR(128)` | 人工复核原因码 | +| `parent_source_event_index` | `INT` | 父任务事件序号 | +| `linked_task_group_id` | `VARCHAR(128)` | 联动任务组 ID | +| `blocked_until_parent_completed` | `TINYINT(1)` | 是否等待父任务完成 | +| `ai_payload_json` | `LONGTEXT` | AI 原始 item JSON | +| `case_keys_json` | `LONGTEXT` | 订单候选键 JSON | +| `extracted_fields_json` | `LONGTEXT` | AI 业务字段 JSON | +| `manual_review_json` | `LONGTEXT` | 人工复核 JSON | +| `informational_message_json` | `LONGTEXT` | 信息提醒 JSON | +| `attachments_json` | `LONGTEXT` | 附件 JSON | +| `context_used_json` | `LONGTEXT` | 上下文 JSON | +| `created_at` / `updated_at` | `DATETIME(6)` | 创建和更新时间 | + +索引建议: + +- 唯一索引:`hotel_id + item_idempotency_key` +- 普通索引:`hotel_id + source_message_id + source_event_index` +- 普通索引:`hotel_id + result_type + ai_task_type` +- 普通索引:`hotel_id + group_code` +- 普通索引:`hotel_id + confirmation_number` + +## 7. 订单表 + +表名:`workflow_reservation_order` + +| 字段 | 类型建议 | 中文说明 | +| --- | --- | --- | +| `id` | `BIGINT` | 系统内部订单 ID | +| `hotel_id` | `VARCHAR(64)` | 酒店或业务上下文 | +| `order_key_type` | `VARCHAR(32)` | 业务号类型:`GROUP_CODE`、`CONFIRMATION_NUMBER`、`TEMPORARY` | +| `order_business_key` | `VARCHAR(128)` | 真实业务号 | +| `active_business_key` | `VARCHAR(128)` | ACTIVE 订单唯一约束辅助键,ACTIVE 业务订单时等于真实业务号,其他状态为空 | +| `temporary_order_code` | `VARCHAR(128)` | 临时订单展示编号 | +| `order_status` | `VARCHAR(32)` | 订单状态 | +| `business_key_source` | `VARCHAR(64)` | 业务号来源,例如 `AI_CANDIDATE`、`USER_CONFIRMED`、`OPERA_SIMULATION_RESULT` | +| `business_key_backfilled_at` | `DATETIME(6)` | New Booking 成功后回填真实业务号的 UTC 时间 | +| `display_name` | `VARCHAR(256)` | 前端展示名称 | +| `source_message_id` | `BIGINT` | 首次创建该订单的来源消息 | +| `created_from_task_id` | `BIGINT` | 首次创建该订单的任务 ID,可为空 | +| `ended_at` | `DATETIME(6)` | 订单进入 ENDED 的时间 | +| `logic_deleted_at` | `DATETIME(6)` | 逻辑删除时间 | +| `logic_deleted_reason` | `VARCHAR(512)` | 逻辑删除原因 | +| `version` | `BIGINT` | 乐观锁版本 | +| `created_at` / `updated_at` | `DATETIME(6)` | 创建和更新时间 | + +索引建议: + +- 普通索引:`hotel_id + order_status + updated_at` +- 普通索引:`hotel_id + order_key_type + order_business_key` +- 唯一索引:`hotel_id + order_key_type + active_business_key`,用于保证同一 `hotel_id + GROUP_CODE` / `hotel_id + CONFIRMATION_NUMBER` 只能有一个 ACTIVE 订单 +- 唯一索引:`hotel_id + temporary_order_code` + +说明: + +- MySQL 第一版不依赖部分唯一索引;通过 `active_business_key` 在 ACTIVE 业务订单时写入真实业务号、非 ACTIVE 或临时订单为空,配合唯一索引保证同一业务号只能有一个 ACTIVE 订单。 +- `LOGIC_DELETED` 订单不得再挂新任务。 + +## 8. 任务表 + +表名:`workflow_reservation_task` + +| 字段 | 类型建议 | 中文说明 | +| --- | --- | --- | +| `id` | `BIGINT` | 系统任务 ID | +| `hotel_id` | `VARCHAR(64)` | 酒店或业务上下文 | +| `order_id` | `BIGINT` | 当前挂靠订单 ID | +| `source_message_id` | `BIGINT` | 来源消息 ID | +| `ai_transition_id` | `BIGINT` | AI 过渡记录 ID | +| `result_type` | `VARCHAR(32)` | AI 结果类型 | +| `ai_task_type` | `VARCHAR(64)` | AI 原始任务类型 | +| `system_task_type` | `VARCHAR(64)` | 系统主任务类型 | +| `task_card_type` | `VARCHAR(64)` | 任务卡类型 | +| `task_subtype` | `VARCHAR(128)` | 业务动作 subtype | +| `task_status` | `VARCHAR(32)` | 任务状态 | +| `queue_participation` | `TINYINT(1)` | 是否参与订单执行队列 | +| `execution_order` | `INT` | 同订单执行顺序 | +| `parent_task_id` | `BIGINT` | 父任务 ID,可为空 | +| `parent_source_event_index` | `INT` | 父任务 source event index | +| `linked_task_group_id` | `VARCHAR(128)` | 联动任务组 ID | +| `blocked_until_parent_completed` | `TINYINT(1)` | 是否等待父任务完成 | +| `last_failure_reason` | `VARCHAR(512)` | 最近失败原因摘要 | +| `confirmed_at` | `DATETIME(6)` | 用户确认时间 | +| `completed_at` | `DATETIME(6)` | 完成时间 | +| `version` | `BIGINT` | 乐观锁版本 | +| `created_at` / `updated_at` | `DATETIME(6)` | 创建和更新时间 | + +索引建议: + +- 普通索引:`hotel_id + order_id + queue_participation + execution_order` +- 唯一索引:`hotel_id + order_id + queue_participation + execution_order`,用于防止并发创建任务时出现相同队列序号 +- 普通索引:`hotel_id + task_status + updated_at` +- 普通索引:`hotel_id + source_message_id` +- 普通索引:`hotel_id + ai_transition_id` + +队列规则: + +- `queue_participation=false` 的任务不阻塞队列,适用于 `Message Notification`。 +- 是否可编辑、可确认、可执行由服务层实时计算。 +- 当前任务前面存在未完成且参与队列的任务时,本任务只能查看。 + +## 9. 任务卡确认数据表 + +表名:`workflow_reservation_task_card` + +| 字段 | 类型建议 | 中文说明 | +| --- | --- | --- | +| `id` | `BIGINT` | 任务卡 ID | +| `hotel_id` | `VARCHAR(64)` | 酒店或业务上下文 | +| `task_id` | `BIGINT` | 所属任务 | +| `task_card_type` | `VARCHAR(64)` | 任务卡类型 | +| `field_contract_version` | `VARCHAR(64)` | 字段契约版本,第一版可写 `code-v1` | +| `ai_payload_json` | `LONGTEXT` | 任务卡使用的 AI 原始 JSON 快照 | +| `draft_payload_json` | `LONGTEXT` | 用户编辑草稿,可为空 | +| `confirmed_payload_json` | `LONGTEXT` | 用户确认后的最终 payload | +| `confirmed_by` | `VARCHAR(128)` | 确认人 | +| `confirmed_at` | `DATETIME(6)` | 确认时间 | +| `created_at` / `updated_at` | `DATETIME(6)` | 创建和更新时间 | + +索引建议: + +- 唯一索引:`hotel_id + task_id` +- 普通索引:`hotel_id + task_card_type` + +说明: + +- 第一版任务卡字段配置写在代码中,但通过 `TaskCardFieldDefinitionProvider` 之类的接口提供,避免 Controller / Service 直接依赖硬编码数组。 +- 后续如果改为数据库配置,只替换 Provider 实现,不改任务核心流程。 + +## 10. 审计表 + +表名:`workflow_reservation_audit_log` + +| 字段 | 类型建议 | 中文说明 | +| --- | --- | --- | +| `id` | `BIGINT` | 审计 ID | +| `hotel_id` | `VARCHAR(64)` | 酒店或业务上下文 | +| `order_id` | `BIGINT` | 订单 ID,可为空 | +| `task_id` | `BIGINT` | 任务 ID,可为空 | +| `operation_id` | `BIGINT` | OPERA 模拟操作 ID,可为空 | +| `actor_type` | `VARCHAR(32)` | 操作人类型,例如 `SYSTEM`、`USER`、`SUPERAGENT` | +| `actor_id` | `VARCHAR(128)` | 操作人标识 | +| `action` | `VARCHAR(128)` | 操作类型 | +| `reason` | `VARCHAR(512)` | 操作原因 | +| `before_snapshot_json` | `LONGTEXT` | 变更前摘要 JSON | +| `after_snapshot_json` | `LONGTEXT` | 变更后摘要 JSON | +| `occurred_at` | `DATETIME(6)` | 发生时间 | +| `created_at` | `DATETIME(6)` | 记录创建时间 | + +索引建议: + +- 普通索引:`hotel_id + order_id + occurred_at` +- 普通索引:`hotel_id + task_id + occurred_at` +- 普通索引:`hotel_id + action + occurred_at` + +审计不得保存 Secret、Token、完整邮件正文、真实附件 URL 或不必要的个人敏感信息。 + +## 11. OPERA 模拟逻辑操作表 + +表名:`workflow_reservation_opera_operation` + +一条任务可能生成多条 OPERA 模拟逻辑操作。 + +| 字段 | 类型建议 | 中文说明 | +| --- | --- | --- | +| `id` | `BIGINT` | OPERA 模拟操作 ID | +| `hotel_id` | `VARCHAR(64)` | 酒店或业务上下文 | +| `order_id` | `BIGINT` | 订单 ID | +| `task_id` | `BIGINT` | 任务 ID | +| `operation_type` | `VARCHAR(128)` | 操作类型,例如创建预订、更新字段、取消订单 | +| `operation_status` | `VARCHAR(32)` | 操作状态:`PENDING`、`EXECUTING`、`SUCCESS`、`FAILED` | +| `operation_order` | `INT` | 同任务下操作顺序 | +| `confirmed_payload_json` | `LONGTEXT` | 生成该操作时使用的确认 payload 快照 | +| `business_key_candidates_json` | `LONGTEXT` | 从模拟结果解析出的订单业务号候选 | +| `last_failure_reason` | `VARCHAR(512)` | 最近失败原因 | +| `retry_count` | `INT` | 已重试次数 | +| `created_at` / `updated_at` | `DATETIME(6)` | 创建和更新时间 | + +索引建议: + +- 普通索引:`hotel_id + task_id + operation_order` +- 普通索引:`hotel_id + operation_status + updated_at` + +## 12. OPERA 模拟尝试记录表 + +表名:`workflow_reservation_opera_operation_attempt` + +每次执行或重试都新增一条 attempt,不能覆盖历史。 + +| 字段 | 类型建议 | 中文说明 | +| --- | --- | --- | +| `id` | `BIGINT` | 尝试记录 ID | +| `hotel_id` | `VARCHAR(64)` | 酒店或业务上下文 | +| `operation_id` | `BIGINT` | 所属逻辑操作 | +| `task_id` | `BIGINT` | 所属任务 | +| `attempt_no` | `INT` | 第几次尝试,从 1 开始 | +| `attempt_status` | `VARCHAR(32)` | 尝试状态:`SUCCESS`、`FAILED` | +| `request_payload_json` | `LONGTEXT` | 本次模拟请求 payload | +| `response_payload_json` | `LONGTEXT` | 本次模拟响应 payload | +| `business_key_candidates_json` | `LONGTEXT` | 本次响应中的业务号候选 | +| `failure_reason` | `VARCHAR(512)` | 失败原因 | +| `started_at` | `DATETIME(6)` | 开始时间 | +| `finished_at` | `DATETIME(6)` | 结束时间 | +| `created_at` | `DATETIME(6)` | 记录创建时间 | + +索引建议: + +- 唯一索引:`hotel_id + operation_id + attempt_no` +- 普通索引:`hotel_id + task_id + created_at` + +规则: + +- 用户不能跳过失败的 OPERA 模拟操作。 +- 失败后任务进入 `FAILED`,只能通过修正 payload 后重试或重新执行使其成功。 +- 不允许用户强制把失败任务改成 `COMPLETED`。 +- 后续真实 OPERA 接入时,应通过 adapter 把真实响应映射到 `response_payload_json` 和 `business_key_candidates_json`。 + +## 13. 订单业务号回填 + +只有 `NEW_BOOKING` 且 `confirmed_payload_json` 没有可用业务号时,才从 OPERA 模拟成功结果回填订单业务号。 + +回填来源暂定为 `business_key_candidates_json`,示例: + +```json +{ + "key_type": "CONFIRMATION_NUMBER", + "business_key": "CNF123456", + "source": "OPERA_SIMULATION_RESULT", + "raw_path": "reserved.for.future.real.opera.path" +} +``` + +说明: + +- 当前没有真实 OPERA 返回样例,`raw_path` 只能预留。 +- FIT Reservation 回填 `CONFIRMATION_NUMBER`。 +- Group / Block / Allotment 回填 `GROUP_CODE` 或后续确认的对应业务号类型。 + +## 14. 第一版不落库的内容 + +以下内容第一版不建议单独建表: + +- 任务卡字段矩阵配置:先写在代码 Provider 中。 +- 任务阻塞状态:实时计算,不落 `BLOCKED`。 +- OPERA 真实接口字段映射:后续真实系统接入后在 adapter 层补充。 +- 全量 Excel 字段路径:引用 `任务卡展示编辑矩阵.xlsx`,不手抄成数据库。 + +## 15. 待确认问题 + +- `ENDED` 是否仅表示取消完成,还是也包含正常完成后的历史订单。 +- 临时订单逻辑删除前是否需要保留空订单一段时间。 +- OPERA 真实响应字段出现后,是否需要新增稳定字段而不是只放 JSON。 +- 任务卡字段代码 Provider 的具体类名和包路径,后续实现时按项目规范确认。 diff --git a/docs/project/requirements/M002-order-task-workflow-v1.md b/docs/project/requirements/M002-order-task-workflow-v1.md new file mode 100644 index 0000000..0c7c8d8 --- /dev/null +++ b/docs/project/requirements/M002-order-task-workflow-v1.md @@ -0,0 +1,334 @@ +# M002 Order Task Workflow 订单任务主流程 V1 + +## 文档信息 + +| 项目 | 内容 | +| --- | --- | +| 文档版本 | 0.1 | +| 日期 | 2026-07-06 | +| 状态 | 第一版流程草稿 | +| 适用范围 | SourceMessage 之后的 SuperAgent 抽取、订单挂靠、任务处理、OPERA/OHIP 模拟操作主流程 | +| 主要读者 | 产品、后端、前端、测试、后续协作 agent | + +## 1. 文档定位 + +本文记录 TH Hotel 当前确认的订单任务主流程第一版理解,用于承接已经完成的 +`SourceMessage Inbox` 能力,并为后续 PRD、接口契约、数据模型和实现 checkpoint 提供 +统一业务语义。 + +本文只沉淀流程和边界,不代表已经开始实现代码。后续进入开发前,仍需要补充具体任务字段、 +SuperAgent 创建任务接口契约、前端页面范围、数据库模型和验收标准。 + +## 2. 已确认结论 + +- AgentBus 是当前原始消息起点,后续也可能增加其他入口。 +- AgentBus 只负责原始来源事实进入本系统,当前已经通过 `SourceMessage Inbox` 保存。 +- SuperAgent 是黑盒抽取能力,本系统不关心它内部如何抽取。 +- SuperAgent 不直连本系统数据库,只能通过本系统后端接口查询已有订单和任务上下文。 +- SuperAgent 抽取成功后,通过调用本系统后端接口创建一个或多个任务。 +- SuperAgent 告知的任务类型是本系统创建任务的依据,本系统暂时不做业务合法性判断。 +- 本系统只做接口鉴权、基础 JSON 可解析性、幂等键、必要技术字段等技术校验。 +- 任务必须挂靠到订单下,异常任务也需要先创建临时订单作为处理容器。 +- 同一次 SuperAgent 调用如果返回多个任务,必须按返回 JSON 列表顺序确定任务执行顺序。 +- 同一个订单下,前一个任务未完成时,后续任务只能查看,不能编辑、确认或执行模拟操作。 +- 用户在任务详情页可以查看、修改字段内容,并确认订单归属。 +- 用户确认字段和订单关系后,才能执行 OPERA/OHIP 模拟操作。 +- 当前 OHIP/OPERA 不接真实接口,只做模拟操作。 +- 所有任务类型转换、订单切换、临时订单逻辑删除都必须记录审计和原因。 + +## 3. 总体流程 + +```text +AgentBus / 未来其他入口 +→ SourceMessage Inbox 记录原始来源事实 +→ SuperAgent 读取或处理原始来源数据 +→ SuperAgent 抽取结构化 JSON +→ SuperAgent 通过本系统后端接口查询已有订单和任务上下文 +→ SuperAgent 调用本系统接口创建一个或多个任务 +→ 本系统按任务类型和返回顺序创建任务并挂靠订单 +→ 用户在任务详情页查看、修改字段、确认订单归属 +→ 用户确认后执行 OPERA/OHIP 模拟操作 +→ 任务完成后,同订单下后续任务解锁处理 +``` + +中文说明: + +- `SourceMessage Inbox` 是来源事实层,不表达订单、任务或业务结论。 +- SuperAgent 负责抽取并告知任务类型,但不直接改变本系统最终业务状态。 +- Order / Task 是本系统后续业务核心,本系统必须掌握任务顺序、可处理状态、审计和模拟操作结果。 + +## 4. 与 M001 SourceMessage 的关系 + +已完成的 M001 只覆盖以下能力: + +```text +AgentBus 原始消息 +→ SourceMessage Inbox 落库 +→ 查询安全摘要 +→ 受控读取原文 +→ 记录原文读取审计 +``` + +M001 不做以下事情: + +- 不创建订单。 +- 不创建任务。 +- 不调用 SuperAgent。 +- 不判断任务类型。 +- 不执行 OPERA/OHIP 模拟操作。 +- 不自动 ACK、回复客户或写业务结果。 + +因此,本文流程和当前 SourceMessage 代码没有冲突。SourceMessage 是后续订单任务流程的上游来源事实。 + +## 5. 核心角色与职责边界 + +| 模块或外部系统 | 职责 | 不负责 | +| --- | --- | --- | +| AgentBus | 接收邮件等外部渠道消息,并把原始 payload 推送给本系统 | 不做 AI 抽取,不创建任务,不调用业务写接口 | +| SourceMessage Inbox | 保存原始来源事实、幂等、查询安全摘要、受控读取原文 | 不表达订单归属,不表达任务类型,不表达业务合法性 | +| SuperAgent | 抽取结构化 JSON,判断任务类型,调用本系统后端接口创建任务 | 不直连数据库,不绕过本系统权限和审计,不直接执行 OPERA/OHIP | +| Order | 承载任务队列、订单号、临时订单状态和业务订单关系 | 不保存 SuperAgent 外部协议细节 | +| Task | 承载任务类型、抽取字段、用户确认字段、处理状态和执行顺序 | 不直接替代订单,不直接保存原始邮件全文 | +| Operation Simulation | 记录用户确认后的 OPERA/OHIP 模拟操作、结果和重试 | 当前不调用真实 OHIP/OPERA 接口 | + +## 6. SuperAgent 任务创建边界 + +SuperAgent 创建任务时,本系统只做技术接收校验,不做业务合法性判断。 + +技术校验包括: + +- 接口鉴权和调用方身份校验。 +- 请求 JSON 可解析。 +- 幂等键或批次标识存在。 +- 任务列表结构可识别。 +- 任务类型是本系统已经约定的稳定代码。 +- 任务列表顺序可保留。 + +暂时不做的业务判断包括: + +- 不判断任务是否应该是 `NEW_BOOKING`。 +- 不判断任务是否应该是 `UPDATE_BOOKING`。 +- 不判断任务是否应该是 `CANCEL_BOOKING`。 +- 不根据字段缺失自动改成异常任务。 +- 不判断 `CANCEL_BOOKING` 前是否真的存在已完成的 `NEW_BOOKING`。 +- 不判断房型、日期、金额、订单号等字段是否业务合理。 + +如果 SuperAgent 告知是异常任务,本系统就按异常任务创建。如果 SuperAgent 告知是其他已知任务类型,本系统就按该类型创建。 + +## 7. 订单模型第一版理解 + +订单应至少区分内部身份、临时展示编号和真实业务订单号。 + +| 概念 | 中文说明 | +| --- | --- | +| `orderId` | 本系统内部订单 ID,永远存在,用于任务挂靠和内部引用 | +| `temporaryOrderCode` | 临时订单编号或前端展示编号,用于还没有明确真实订单号的场景 | +| `businessOrderNo` | 真实业务订单号,可能来自 SuperAgent 抽取结果、用户确认字段或后续模拟操作回填 | +| `orderStatus` | 订单状态,例如临时、有效、逻辑删除等 | +| `deletedReason` | 逻辑删除原因,主要用于异常任务转移后删除原临时订单 | + +`NEW_BOOKING` 任务不一定都需要等待 OPERA 模拟创建后才获得真实订单号。系统应优先查看用户确认后的字段中是否已有订单号: + +- 如果确认字段中已有订单号,则优先使用该订单号作为 `businessOrderNo`。 +- 如果确认字段中没有订单号,则先使用内部订单 ID 或临时编号追踪,后续如果模拟操作返回真实订单号再回填。 + +## 8. 任务模型第一版理解 + +任务至少需要保存 SuperAgent 原始抽取内容和用户确认后的内容。 + +| 概念 | 中文说明 | +| --- | --- | +| `taskId` | 本系统内部任务 ID | +| `orderId` | 当前挂靠订单 ID | +| `taskType` | 任务类型,由 SuperAgent 或后续人工转换确定 | +| `taskStatus` | 任务状态,用于控制是否可编辑、可确认、可执行 | +| `batchId` | SuperAgent 创建任务批次 ID 或调用 ID | +| `batchItemIndex` | 同一次 SuperAgent 返回列表中的顺序,从 0 或 1 开始需后续统一 | +| `orderSequence` | 订单下的任务执行顺序 | +| `extractedJson` | SuperAgent 原始抽取 JSON,不应被用户编辑覆盖 | +| `confirmedJson` | 用户修改并确认后的 JSON,用于后续模拟操作 | +| `sourceMessageId` | 关联 SourceMessage ID,便于追溯原始邮件或来源消息 | + +任务类型当前确认: + +- `NEW_BOOKING`:新建订单任务。 +- `UPDATE_BOOKING`:更新订单任务。后续可按字段组在前端展示不同卡片。 +- `CANCEL_BOOKING`:取消订单任务。 +- `EXCEPTION`:异常任务,表示 SuperAgent 无法判断属于前三类。 + +关于“任务大致 5 种”的表述,当前只确认以上 4 类。后续新增类型以具体任务分类和字段定义为准。 + +## 9. 任务顺序与可处理状态 + +订单下的任务必须按顺序处理。 + +排序规则第一版: + +```text +orderId + orderSequence +``` + +同一次 SuperAgent 调用创建多个任务时: + +- 必须保留 SuperAgent 返回 JSON 列表中的任务顺序。 +- 同一个批次里,列表前面的任务优先执行。 +- 不能只依赖创建时间排序,因为同批次任务时间可能完全一样。 + +后续任务的可处理规则: + +- 当前面存在未完成任务时,后续任务只能查看。 +- 后续任务不能编辑字段。 +- 后续任务不能确认订单关系。 +- 后续任务不能执行 OPERA/OHIP 模拟操作。 +- 后续任务不能重试模拟操作。 + +任务切换订单后: + +- 必须记录操作人、原订单、新订单、原因和时间。 +- 任务进入目标订单队列后,需要按目标订单任务队列规则重新判断是否可处理。 +- 如果目标订单前面已有未完成任务,该任务切过去后也只能查看。 + +## 10. Task Detail 行为 + +任务详情页不是只读确认页,而是用户处理任务的主要工作台。 + +任务可处理时,用户可以: + +- 查看 SuperAgent 原始抽取结果。 +- 查看关联 SourceMessage 的安全摘要或受控原文入口。 +- 修改字段内容。 +- 确认订单归属。 +- 对异常任务选择转换目标类型。 +- 输入转换、切换或删除原因。 +- 确认后触发 OPERA/OHIP 模拟操作。 +- 查看每条模拟操作结果。 +- 对失败或允许重试的模拟操作发起重试。 + +任务不可处理时,用户只能查看: + +- 任务基础信息。 +- 当前挂靠订单。 +- 抽取字段和已确认字段。 +- 阻塞原因,例如“同订单下前置任务未完成”。 +- 前置任务信息或跳转入口。 + +## 11. 异常任务处理流程 + +异常任务也必须挂靠订单。创建异常任务时,系统创建一个新的临时订单,并将异常任务挂靠到该临时订单。 + +```text +SuperAgent 创建 EXCEPTION 任务 +→ 系统创建临时订单 +→ 异常任务挂靠临时订单 +→ 用户在任务详情页处理异常任务 +→ 用户选择转为 NEW_BOOKING / UPDATE_BOOKING / CANCEL_BOOKING +→ 用户填写原因并确认 +→ 系统写审计 +``` + +### 11.1 转为 NEW_BOOKING + +- 临时订单继续保留。 +- 任务类型从 `EXCEPTION` 改为 `NEW_BOOKING`。 +- 用户可以修改并确认字段。 +- 系统优先查看确认字段中的订单号是否有值。 +- 如果订单号有值,则将该值作为真实业务订单号。 +- 如果订单号无值,则继续使用临时编号追踪,后续模拟操作如返回真实订单号再回填。 +- 必须记录任务类型变更审计和原因。 + +### 11.2 转为 UPDATE_BOOKING + +- 用户选择一个已有订单。 +- 任务类型从 `EXCEPTION` 改为 `UPDATE_BOOKING`。 +- 任务从临时订单切换到目标订单。 +- 原临时订单逻辑删除。 +- 必须记录任务类型变更、订单关系变更、临时订单逻辑删除审计和原因。 +- 切换后必须受目标订单任务队列顺序约束。 + +### 11.3 转为 CANCEL_BOOKING + +- 用户选择一个已有订单。 +- 任务类型从 `EXCEPTION` 改为 `CANCEL_BOOKING`。 +- 任务从临时订单切换到目标订单。 +- 原临时订单逻辑删除。 +- 必须记录任务类型变更、订单关系变更、临时订单逻辑删除审计和原因。 +- 切换后必须受目标订单任务队列顺序约束。 + +## 12. OPERA/OHIP 模拟操作 + +当前阶段没有真实 OHIP/OPERA 接口,只做模拟操作。 + +模拟操作第一版规则: + +- 只有当前订单下可处理的任务才能执行模拟操作。 +- 执行前必须完成字段修改和订单关系确认。 +- 一个任务可能生成多条模拟操作。 +- 任务详情页需要展示每条模拟操作的结果。 +- 每条模拟操作应有独立状态、失败原因、重试次数和最近执行时间。 +- 重试必须可追溯,不能覆盖历史执行结果。 +- 任务是否完成,需要根据该任务要求的模拟操作是否全部达到完成条件判断。 + +模拟操作的具体类型、字段、完成条件和重试限制后续另行定义。 + +## 13. 审计要求 + +以下行为必须有审计: + +- SuperAgent 创建任务批次。 +- 同批次内每个任务的创建顺序。 +- 用户修改任务字段。 +- 用户确认订单关系。 +- 任务类型转换。 +- 任务切换订单。 +- 临时订单逻辑删除。 +- OPERA/OHIP 模拟操作执行。 +- 模拟操作重试。 + +审计至少应包含: + +| 字段 | 中文说明 | +| --- | --- | +| `actor` | 操作人或系统调用方 | +| `action` | 操作类型 | +| `reason` | 用户填写或系统记录的原因 | +| `beforeSnapshot` | 变更前摘要 | +| `afterSnapshot` | 变更后摘要 | +| `occurredAt` | 发生时间 | + +审计中不得记录 Secret、Token、完整邮件正文、附件 URL 或不必要的个人敏感信息。 + +## 14. 暂不包含范围 + +第一版流程文档暂不定义: + +- 具体 SuperAgent 创建任务接口 JSON Schema。 +- 具体订单表、任务表、操作表结构。 +- 具体前端页面布局。 +- 具体任务字段和卡片展示规则。 +- 真实 OHIP/OPERA 接口调用。 +- SuperAgent 内部 prompt、skill 或抽取实现。 +- 业务字段合法性判断规则。 +- 任务类型之外的自动分类逻辑。 + +## 15. 后续建议 checkpoint + +建议后续按以下顺序拆分,不一次性做完: + +1. 定义 SuperAgent 调本系统创建任务接口契约。 +2. 定义 Order / Task / Task Audit / Operation Simulation 的最小数据模型。 +3. 实现任务入站创建、幂等、批次顺序和订单挂靠。 +4. 实现任务详情读取、字段编辑和订单关系确认。 +5. 实现异常任务转换、订单切换和临时订单逻辑删除。 +6. 实现 OPERA/OHIP 模拟操作结果和重试机制。 +7. 再根据前端页面范围实现列表、详情和队列状态展示。 + +## 16. 待确认问题 + +- SuperAgent 创建任务接口的正式 JSON 格式是什么。 +- SuperAgent 任务批次 ID 和每个任务 item 的幂等键由谁生成。 +- `batchItemIndex` 从 0 开始还是从 1 开始。 +- 订单号字段在不同任务类型的 `confirmedJson` 中具体叫什么。 +- 任务状态机需要哪些稳定状态。 +- 模拟操作的完成条件和失败后是否允许跳过。 +- 任务完成是否允许人工强制完成,若允许需要什么权限和审计。 +- 第五种任务类型是否存在,若存在具体业务含义是什么。 diff --git a/docs/project/requirements/M002-order-task-workflow-v2.md b/docs/project/requirements/M002-order-task-workflow-v2.md new file mode 100644 index 0000000..37f3218 --- /dev/null +++ b/docs/project/requirements/M002-order-task-workflow-v2.md @@ -0,0 +1,500 @@ +# M002 Order Task Workflow 订单任务主流程 V2 + +## 文档信息 + +| 项目 | 内容 | +| --- | --- | +| 文档版本 | 0.2 | +| 日期 | 2026-07-07 | +| 状态 | 第二版需求草稿 | +| 适用范围 | SourceMessage 之后的 AI 过渡层、订单挂靠、任务卡、人工确认、OPERA 模拟操作主流程 | +| 主要读者 | 产品、后端、前端、测试、后续协作 agent | + +## 1. 文档定位 + +本文是 `M002-order-task-workflow-v1.md` 的第二版修正,目标是把本项目已经讨论确认的订单任务主流程,与 2026-07-06 导入的 AI 任务卡契约对齐。 + +本文只定义业务边界、数据语义和后续实现约束,不代表已经开始写代码。后续开发前仍需要拆分 checkpoint,并补充接口契约、表结构、状态机、前端页面和测试验收标准。 + +## 2. 本版核心修正 + +相对 V1,本版有以下修正: + +- 增加 `AI 过渡层`:系统必须先保存 AI 原始输出,再生成订单、任务和任务卡。 +- 不再使用 `EXCEPTION` 作为底层结果类型,AI 第一版结果类型统一以 `normal_task`、`manual_review`、`informational_message` 为准。 +- 系统主任务类型收敛为 `NEW_BOOKING`、`UPDATE_BOOKING`、`CANCEL_BOOKING`、`MANUAL_REVIEW`,另保留 `INFORMATIONAL_MESSAGE` 作为不参与执行队列的只读提醒任务。 +- 除 `New Booking`、`Cancel Booking`、`Fallback/manual_review` 外,其他业务处理类卡片原则上都归到 `UPDATE_BOOKING` 下的不同任务卡。 +- `Message Notification` 也挂到临时订单下面,便于归档和详情查看,但不参与订单任务执行队列,不阻塞其他任务,也不被其他任务阻塞。 +- 任务卡字段以 `任务卡展示编辑矩阵.xlsx` 为权威来源,不在代码里随意扩展或重命名字段路径。 +- 系统必须同时保存 AI 原始 `task_type`、系统主任务类型、任务卡类型和业务动作 subtype。 +- `ai_payload_json` 与 `confirmed_payload_json` 必须分离。OPERA 模拟只能读取用户确认后的 `confirmed_payload_json`。 +- 订单业务号候选字段和 OPERA 模拟回填边界需要明确记录,但 OPERA 返回 JSON 字段名目前未知,不能写死。 + +## 3. 权威输入资料 + +本版需求参考以下资料: + +| 资料 | 作用 | +| --- | --- | +| `docs/project/requirements/M001-source-message-inbox-prd.md` | 上游 SourceMessage Inbox 边界 | +| `docs/project/requirements/M002-order-task-workflow-v1.md` | 第一版整体业务流程 | +| `docs/import/20260706/开发AI先读_工作顺序.md` | AI 过渡表、任务卡、确认 payload 和 OPERA 写入工作顺序 | +| `docs/import/20260706/AI输出参数并集字典.xlsx` | AI 输出字段路径、字段含义、建议存储方式和索引建议 | +| `docs/import/20260706/任务卡展示编辑矩阵.xlsx` | 任务卡字段展示、编辑、校验和 OPERA 写入约束 | +| `docs/import/20260706/0630AI_副本 2/01_main_agent_prompt.md` | Main Agent 输出职责和聚合 JSON 结构 | +| `docs/import/20260706/0630AI_副本 2/02_agent_workflow.md` | AI 侧事件拆分、Skill 调用和结果聚合流程 | +| `docs/import/20260706/0630AI_副本 2/03_skill_catalog.md` | Skill 目录、结果类型、任务类型和统一输出字段 | +| `docs/import/20260706/0630AI_副本 2/skill/S01-S08*.md` | 各业务任务卡的抽取规则和边界 | +| `docs/import/20260706/0630AI_副本 2/references/qbd_liantai_email_table_rules.md` | QBD / LianTai 表格邮件拆分和证据规则 | +| `docs/import/20260706/0630AI_副本 2/skill/*/references/rate_code_rules.md` | Rate Code 判断规则 | +| `docs/import/20260706/0630AI_副本 2/skill/*/references/room_type_mapping_rules.md` | 房型映射规则 | + +说明:`开发AI先读_工作顺序.md` 中提到的 `AI过渡表开发契约_README.md` 和 `AI过渡表开发契约_两张样例表.xlsx` 在当前导入目录下未找到同名文件。当前以已经导入的两张独立 Excel 作为字段和任务卡矩阵来源。 + +## 4. 总体流程 + +```text +AgentBus / 未来其他入口 +→ SourceMessage Inbox 记录原始来源事实 +→ SuperAgent / Main Agent 读取 SourceMessage 并拆分 current 事件 +→ 对每个事件调用对应 Skill +→ 聚合输出 source_message_id + ai_task_results[] + extraction_warnings[] +→ 本系统接收 AI 结果并写入 AI 过渡层 +→ 本系统根据 result_type + task_type + task_subtype 生成订单、任务和任务卡 +→ 用户在任务详情页查看、修改字段、确认订单归属 +→ 系统生成 confirmed_payload_json +→ 用户触发 OPERA 模拟操作 +→ 系统保存每条模拟操作结果、失败原因和重试记录 +``` + +中文说明: + +- `SourceMessage Inbox` 是来源事实层,不表达订单、任务或业务结论。 +- AI 只输出 JSON,不直接写数据库、不直接创建真实任务卡、不直接写 OPERA。 +- 本系统负责保存 AI 原始 JSON、创建任务卡、接收用户修改、生成确认后的 payload、执行 OPERA 模拟并记录结果。 +- SuperAgent 不直连本系统数据库,只能通过本系统后端接口查询已有订单和任务上下文。 + +## 5. AI 输出接收边界 + +导入文档已经定义了 SuperAgent / Main Agent 应输出的核心业务结构,但还没有定义完整 HTTP 接口契约。 + +AI 聚合输出的顶层结构应包含: + +| 字段 | 中文说明 | +| --- | --- | +| `source_message_id` | 关联的 SourceMessage ID | +| `ai_task_results[]` | AI 拆分出的一个或多个任务结果,顺序必须保留 | +| `extraction_warnings[]` | 抽取警告,不直接等同于业务任务 | + +`ai_task_results[]` 中关键字段包括: + +| 字段 | 中文说明 | +| --- | --- | +| `source_event_index` | AI 拆分出的 current 事件序号,同一次来源消息内用于排序 | +| `catalog_code` | Skill 目录代码,例如 S01、S02 | +| `skill_id` | 具体 Skill 标识 | +| `result_type` | AI 结果类型,第一版只使用 `normal_task`、`manual_review`、`informational_message` | +| `task_type` | AI 原始任务类型,例如 `New Booking`、`Voucher Received` | +| `task_subtype` / 业务动作 | 更细的业务动作,用于任务卡字段路由 | +| `current_or_history` | 当前事件还是历史证据 | +| `case_keys` | 订单关联候选键,例如 `group_code`、`confirmation_number` | +| `visible_reason` | 给用户看的生成原因 | +| `relevant_message_excerpt` | 相关邮件片段 | +| `attachments` / `file_references` | 附件和文件引用 | +| `context_used` | AI 使用的上下文摘要 | +| `extracted_fields` | 业务字段主体 | +| `manual_review` | 人工复核结构化原因和证据 | +| `informational_message` | 信息提醒内容 | +| `additional_operations` | AI 建议的附加动作 | +| `idempotency_key` | 幂等键,最终来源和生成规则待确认 | + +待补充的 HTTP 契约包括 URL、Method、Header、鉴权、幂等策略、错误响应格式、是否允许重复推送同一个 `source_message_id`。 + +## 6. AI 过渡层数据要求 + +系统接收 AI 输出后,应先写入 AI 过渡层,再创建任务卡。过渡层用于追溯、幂等、筛选、人工确认和 OPERA 参数组装。 + +推荐保存完整 JSON: + +| JSON 字段 | 中文说明 | +| --- | --- | +| `ai_payload_json` | AI 原始结果,禁止被用户编辑覆盖 | +| `case_keys_json` | AI 给出的订单关联候选键 | +| `extracted_fields_json` | AI 抽取的业务字段 | +| `manual_review_json` | 人工复核信息 | +| `informational_message_json` | 信息提醒内容 | +| `attachments_json` | 附件和文件引用 | +| `context_used_json` | AI 使用的上下文 | +| `confirmed_payload_json` | 用户修改并确认后的最终参数 | + +推荐冗余物理列: + +| 物理列 | 中文说明 | +| --- | --- | +| `source_message_id` | 来源消息 ID | +| `source_event_index` | AI 事件序号 | +| `catalog_code` | Skill 目录代码 | +| `skill_id` | Skill 标识 | +| `result_type` | AI 结果类型 | +| `ai_task_type` | AI 原始任务类型 | +| `system_task_type` | 系统主任务类型 | +| `task_card_type` | 任务卡类型 | +| `task_subtype` | 业务动作 subtype | +| `current_or_history` | 当前或历史标识 | +| `group_code` | 冗余的 Group Code 候选 | +| `confirmation_number` | 冗余的 Confirmation Number 候选 | +| `idempotency_key` | 幂等键 | +| `manual_reason_code` | 人工复核原因码 | +| `parent_source_event_index` | 父任务事件序号 | +| `linked_task_group_id` | 联动任务组 ID | +| `blocked_until_parent_completed` | 是否等待父任务完成 | +| `execution_order` | 同订单下执行顺序 | +| `created_at` / `updated_at` | 创建和更新时间 | + +不要把所有嵌套字段都建成物理列。只有高频查询、筛选、幂等、排序、队列控制和状态机需要的字段才冗余。 + +## 7. 任务分类模型 + +### 7.1 AI 结果类型 + +AI 第一版结果类型只接受: + +| `result_type` | 中文说明 | 是否可直接 OPERA 模拟 | +| --- | --- | --- | +| `normal_task` | 可生成业务任务卡的正常任务 | 需用户确认后才允许 | +| `manual_review` | 需要人工复核的结构化任务 | 不允许直接写 OPERA | +| `informational_message` | 只读信息提醒 | 不允许写 OPERA | + +不使用 `exception_task` 和 `no_action`。原来讨论中的异常任务,在本版统一落为 `manual_review`;没有业务动作的信息提醒,统一落为 `informational_message`。 + +### 7.2 系统主任务类型 + +系统内部主任务类型建议为: + +| 系统主任务类型 | 中文说明 | +| --- | --- | +| `NEW_BOOKING` | 新建订单任务 | +| `UPDATE_BOOKING` | 更新订单任务,下面承载多个不同任务卡 | +| `CANCEL_BOOKING` | 取消订单任务 | +| `MANUAL_REVIEW` | 人工复核或 Fallback 任务 | +| `INFORMATIONAL_MESSAGE` | 信息提醒任务,只归档展示,不参与执行队列 | + +### 7.3 AI 任务类型到系统任务和卡片的映射 + +| AI `task_type` | 系统主任务类型 | 任务卡类型 | 说明 | +| --- | --- | --- | --- | +| `New Booking` | `NEW_BOOKING` | `NEW_BOOKING` | 新建 FIT、Group Block、Allotment / Control Block | +| `Update Booking` | `UPDATE_BOOKING` | `UPDATE_BOOKING` | 通用订单修改卡 | +| `Voucher Received` | `UPDATE_BOOKING` | `VOUCHER_RECEIVED` | 凭证类更新卡 | +| `Rooming List` | `UPDATE_BOOKING` | `ROOMING_LIST` | 名单/分房表处理卡 | +| `Amend Group Code` | `UPDATE_BOOKING` | `AMEND_GROUP_CODE` | 修改 Group Code 卡 | +| `Trace / Reservation Notes` | `UPDATE_BOOKING` | `TRACE_RESERVATION_NOTES` | Trace 或备注卡,可作为联动任务 | +| `TA Recorder` | `UPDATE_BOOKING` | `TA_RECORDER` | TA Recorder 维护卡,通常由 Rooming List 派生 | +| `Cancel Booking` | `CANCEL_BOOKING` | `CANCEL_BOOKING` | 取消 FIT、Group Block、Allotment / Control Block | +| `Message Notification` | `INFORMATIONAL_MESSAGE` | `MESSAGE_NOTIFICATION` | 挂临时订单,只读展示,不参与执行队列 | +| `Fallback` | `MANUAL_REVIEW` | `FALLBACK_REVIEW` | 人工复核任务 | + +系统不得丢弃 AI 原始 `task_type`。任务落库时应同时保存 AI 原始任务类型、系统主任务类型、任务卡类型和业务动作 subtype。 + +## 8. Message Notification 处理规则 + +`Message Notification` 用于感谢、知会、已收到、转发说明、信息同步等没有明确业务执行动作的消息。 + +本系统处理规则: + +- 创建临时订单作为归档容器。 +- 创建只读信息提醒任务卡。 +- 不参与订单任务执行队列。 +- 不阻塞同订单其他可执行任务。 +- 不被同订单其他可执行任务阻塞。 +- 不允许编辑业务字段。 +- 不允许确认后执行 OPERA 模拟。 +- 可在详情页展示 `visible_reason`、`relevant_message_excerpt`、`attachments` 和 `informational_message`。 + +## 9. 任务卡字段约束 + +任务卡字段以 `docs/import/20260706/任务卡展示编辑矩阵.xlsx` 为权威来源。系统不得直接把整个 `ai_task_results[]` 渲染成表单,也不得绕过矩阵自行扩展字段含义。 + +字段矩阵通过以下列控制任务详情页行为: + +| 列 | 中文说明 | +| --- | --- | +| `任务卡名称` | 前端任务卡名称 | +| `result_type` | 适用的 AI 结果类型 | +| `task_type` | 适用的 AI 原始任务类型 | +| `task_subtype/业务动作` | 适用的业务动作 | +| `字段路径` | 从 AI JSON 或确认 JSON 中取值的路径 | +| `展示名` | 前端展示名称 | +| `是否展示` | 是否在任务卡展示 | +| `是否可编辑` | 用户是否可编辑 | +| `是否为输入方式编辑` | 是否普通输入 | +| `是否为下拉框方式编辑` | 是否下拉选择 | +| `是否日期选择` | 是否日期控件 | +| `是否数字输入` | 是否数字输入 | +| `是否文件展示` | 是否文件或附件展示 | +| `是否表格编辑` | 是否表格型编辑 | +| `下拉选项/枚举值` | 可选值,系统不得自行扩展 | +| `是否必填` | 用户确认前是否必填 | +| `展示条件` | 字段显示条件 | +| `校验规则` | 用户确认前校验规则 | +| `确认后写入位置` | 写入 `confirmed_payload_json` 的位置 | +| `是否参与Opera写入` | 是否允许进入 OPERA 参数 | +| `Opera API参数映射` | OPERA 参数映射意图 | + +### 9.1 关键卡片字段摘要 + +本节只摘录核心字段,完整字段以 Excel 矩阵为准。 + +| 任务卡 | 关键字段 | +| --- | --- | +| New Booking | `case_keys.group_code`、`case_keys.confirmation_number`、入住/离店日期、房量、房型原文、Opera 房型代码、Rate Code、结算价、Fix Charge、子团房量、表格行证据 | +| Update Booking | `case_keys.group_code`、`case_keys.confirmation_number`、修改类型、修改前/后、Rate / 结算价结果、Fix Charge、表格行证据 | +| Cancel Booking | `case_keys.group_code`、`case_keys.confirmation_number`、取消对象类型、取消范围、取消生效日期、取消备注 | +| Voucher Received | 原始凭证、凭证类型、`case_keys.group_code`、确认后动作、凭证展示字段、部门流转、关联 Group Code | +| Rooming List | 名单文件、名单类型、目标 Key、文件类型判断、附件 / Sheet 绑定、后续动作意图、派生任务候选 | +| Amend Group Code | 旧 Group Code、新 Group Code | +| Trace / Reservation Notes | `case_keys.group_code`、`case_keys.confirmation_number`、Trace 类型、通知部门、Trace Text、入住人数更新、改价公式、父任务事件序号 | +| TA Recorder | Group Code、名单 / 分房附件、TA Recorder 状态、Rooming List 父任务 | +| Message Notification | 生成原因、邮件片段、附件、信息提醒内容 | +| Fallback / manual_review | 复核原因码、复核说明、复核记录类型、阻断点、缺失字段、冲突点、建议核对证据、建议人工动作、候选 Group Code、候选 Confirmation No. | + +## 10. AI 原始值和人工确认值 + +AI 原始输出和用户确认值必须分离。 + +| 概念 | 中文说明 | +| --- | --- | +| `ai_payload_json` | AI 原始输出,作为证据和审计,不被用户编辑覆盖 | +| `confirmed_payload_json` | 用户修改并确认后的最终业务参数 | + +系统规则: + +- 任务详情首次展示可从 `ai_payload_json` 按矩阵字段路径取值。 +- 用户编辑后必须写入 `confirmed_payload_json`。 +- 用户确认前必须按矩阵和后端硬校验检查必填、类型、枚举和执行条件。 +- OPERA 模拟只能从 `confirmed_payload_json` 取值。 +- 不允许直接从 `ai_payload_json` 组装 OPERA 参数。 +- 不允许因为用户修改而覆盖 AI 原始输出。 + +## 11. 订单业务号和临时订单 + +系统不应只用一个含义模糊的“订单号”。建议至少区分: + +| 字段 | 中文说明 | +| --- | --- | +| `order_id` | 系统内部订单 ID,永远存在 | +| `order_key_type` | 业务号类型,例如 `GROUP_CODE`、`CONFIRMATION_NUMBER`、`TEMPORARY` | +| `order_business_key` | 真实业务号,例如 Group Code 或 Confirmation Number | +| `temporary_order_code` | 临时订单展示编号 | + +### 11.1 可作为订单业务号候选的字段 + +订单业务号候选字段包括: + +| 字段路径 | 适用场景 | 中文说明 | +| --- | --- | --- | +| `case_keys.group_code` | Group / Block / Allotment 类任务 | 主要 Group Code 候选 | +| `case_keys.confirmation_number` | FIT Reservation 类任务 | 主要 Confirmation Number 候选 | +| `extracted_fields.old_group_code` | Amend Group Code | 定位原订单 | +| `extracted_fields.new_group_code` | Amend Group Code | 修改成功后的新业务号候选 | +| `extracted_fields.group_code` | TA Recorder 等卡片 | 任务卡内 Group Code 候选 | +| `extracted_fields.target_key` | Rooming List | 目标 Key,需要判断实际是 Group Code 还是 Confirmation Number | +| `related_group_codes[]` | Voucher 等场景 | 关联展示或候选,不应自动作为唯一主订单号 | + +系统匹配订单时应优先使用用户确认后的 `confirmed_payload_json`;如果尚未确认,只能把 AI 字段作为候选和展示依据。 + +### 11.2 New Booking 的订单号处理 + +`NEW_BOOKING` 不一定都需要等待 OPERA 模拟创建后才有真实业务号。 + +规则: + +- 如果用户确认后的 payload 已有可用业务号,优先使用该业务号。 +- FIT Reservation 优先看 `confirmation_number`。 +- Group / Block / Allotment 优先看 `group_code`。 +- 如果确认后的 payload 没有可用业务号,先创建临时订单并使用 `temporary_order_code` 展示和追踪。 +- OPERA 模拟创建成功后,如果结果中返回真实业务号,再回填订单业务号。 +- 第一版尚未实现确认 payload 时,Fallback 转 `NEW_BOOKING` 可先读取 AI 原始 `case_keys.group_code` / `case_keys.confirmation_number` 作为 `AI_CANDIDATE`,用于把原临时订单激活为 ACTIVE;后续用户确认字段时再把来源升级为 `USER_CONFIRMED`。 + +### 11.3 什么时候从 OPERA 模拟结果回填订单号 + +只有在 `NEW_BOOKING` 且确认后的 payload 没有可用业务号时,才需要从 OPERA 模拟结果回填订单号。 + +| 任务场景 | 回填业务含义 | +| --- | --- | +| New FIT Reservation | 从 OPERA 模拟结果中的 Confirmation No. / Reservation Confirmation Number 含义字段回填 | +| New Group Block | 从 OPERA 模拟结果中的 Group Code / Block Code 含义字段回填 | +| New Allotment / Control Block | 从 OPERA 模拟结果中的 Group Code / Block Code / Allotment Code 含义字段回填 | + +当前导入文档没有定义 OPERA 模拟结果的 JSON 字段名,因此不能在需求中写死 `result.confirmationNumber`、`result.groupCode` 等具体路径。该字段名需要在 OPERA 模拟模块设计时另行确认。 + +### 11.4 非 New Booking 的订单号规则 + +`UPDATE_BOOKING`、`CANCEL_BOOKING`、`Voucher Received`、`Rooming List`、`Trace / Reservation Notes`、`TA Recorder` 等任务,原则上不应依赖 OPERA 模拟结果生成订单号。 + +这些任务应先通过 `case_keys` 或对应卡片字段挂靠已有订单;如果无法自动归属,则创建或使用临时订单,并等待人工确认订单关系。 + +## 12. 订单挂靠和临时订单 + +任务必须挂靠到订单下,便于详情查看、队列控制和审计。 + +| 场景 | 挂靠规则 | +| --- | --- | +| New Booking 有业务号 | 按确认后的业务号创建或匹配订单 | +| New Booking 无业务号 | 创建临时订单 | +| Update Booking 可匹配已有订单 | 挂靠已有订单 | +| Update Booking 无法匹配订单 | 挂靠临时订单,等待人工确认 | +| Cancel Booking 可匹配已有订单 | 挂靠已有订单 | +| Cancel Booking 无法匹配订单 | 挂靠临时订单,等待人工确认 | +| Fallback / manual_review | 先挂靠临时订单,人工处理时可转换类型和订单归属 | +| Message Notification | 挂靠临时订单,只读归档 | + +任务从临时订单迁移到已有订单时,必须记录审计。若原临时订单已无有效任务,应逻辑删除,并记录删除原因。 + +## 13. 任务顺序和可处理状态 + +同一个订单下,任务必须按顺序处理。 + +排序依据: + +```text +order_id + execution_order +``` + +同一次 AI 返回多个任务时: + +- 必须保留 `ai_task_results[]` 列表顺序。 +- 可使用 `source_event_index`、`execution_order` 或批次内 item index 保存顺序。 +- 不得只依赖创建时间,因为同批次任务创建时间可能完全一致。 + +处理规则: + +- 前置任务未完成时,后续任务只能查看。 +- `FAILED` 视为当前前置任务已经结束,不阻塞后续任务;用户不能跳过失败的 OPERA 操作,但失败任务本身不再卡住后续任务查看和处理顺序。 +- 后续任务不能编辑字段。 +- 后续任务不能确认订单关系。 +- 后续任务不能执行 OPERA 模拟。 +- 后续任务不能重试 OPERA 模拟。 +- `Message Notification` 不参与执行队列,不阻塞其他任务,也不被其他任务阻塞。 +- 联动任务可通过 `parent_source_event_index`、`linked_task_group_id`、`blocked_until_parent_completed` 表达依赖。 + +## 14. 人工复核和 Fallback + +`manual_review` 是结构化人工复核,不是空结果,也不是系统失败。 + +人工复核卡至少应展示: + +- `manual_review.reason_code` +- `manual_review.visible_reason` +- `manual_review.review_record_type` +- `manual_review.blocking_points[]` +- `manual_review.missing_fields[]` +- `manual_review.conflicting_points[]` +- `manual_review.evidence_to_check[]` +- `manual_review.suggested_human_actions[]` + +Fallback 处理规则: + +- 创建 `MANUAL_REVIEW` 任务。 +- 默认挂靠临时订单。 +- 用户处理时可以选择转为 `NEW_BOOKING`、`UPDATE_BOOKING` 或 `CANCEL_BOOKING`。 +- 转换时必须记录审计;原因字段第一版允许为空。 +- 转为 `NEW_BOOKING` 时,临时订单可以继续保留;如果已有确认 payload 则优先使用确认业务号,第一版尚未实现确认 payload 时可先使用 AI 原始 `case_keys` 中的业务号候选。 +- 转为 `UPDATE_BOOKING` 或 `CANCEL_BOOKING` 时,用户必须选择目标订单;任务迁移后原临时订单无有效任务时可逻辑删除。 +- 类型转换、订单迁移和临时订单逻辑删除都必须记录审计。 + +## 15. OPERA 模拟操作 + +当前阶段没有真实 OHIP / OPERA 接口,只做 OPERA 模拟操作。 + +允许进入 OPERA 模拟的前置条件: + +- `result_type = normal_task`。 +- 当前任务是订单执行队列中可处理的任务。 +- 用户已确认订单归属。 +- 用户已确认字段,且系统已生成 `confirmed_payload_json`。 +- 字段矩阵中 `是否参与Opera写入` 为 `是` 或满足 `条件参与`。 +- 后端硬校验通过。 + +不允许进入 OPERA 模拟的场景: + +- `manual_review`。 +- `informational_message`。 +- 未确认的 AI 原始值。 +- 证据字段、只读字段、历史字段、uncertain 字段。 +- S04 的 `voucher_display_fields` 等展示字段。 +- 字段矩阵明确 `是否参与Opera写入=否` 的字段。 + +一个任务可能产生多条 OPERA 模拟操作。任务详情页应展示每条模拟操作的结果、状态、失败原因、重试次数和最近执行时间。重试必须保留历史记录,不能覆盖原始失败记录。 + +## 16. 审计要求 + +以下行为必须记录审计: + +- AI 结果接收批次。 +- `ai_task_results[]` 中每个 item 的创建顺序。 +- AI 原始 JSON 保存。 +- 订单自动挂靠或临时订单创建。 +- 用户修改任务字段。 +- 用户确认订单关系。 +- `confirmed_payload_json` 生成。 +- 任务类型转换。 +- 任务切换订单。 +- 临时订单逻辑删除。 +- OPERA 模拟操作执行。 +- OPERA 模拟操作重试。 +- 从 OPERA 模拟结果回填订单业务号。 + +审计至少包含: + +| 字段 | 中文说明 | +| --- | --- | +| `actor` | 操作人或系统调用方 | +| `action` | 操作类型 | +| `reason` | 用户填写或系统记录的原因 | +| `before_snapshot` | 变更前摘要 | +| `after_snapshot` | 变更后摘要 | +| `occurred_at` | 发生时间 | + +审计中不得记录 Secret、Token、完整邮件正文、真实附件 URL 或不必要的个人敏感信息。 + +## 17. 暂不包含范围 + +本版暂不定义: + +- SuperAgent 调本系统的正式 HTTP 接口契约。 +- 幂等键到底由 SuperAgent 生成还是本系统生成。 +- 订单完整状态机。 +- 任务完整状态机。 +- OPERA 模拟结果 JSON 字段名。 +- 真实 OHIP / OPERA 接口地址、鉴权和返回结构。 +- 前端具体页面布局和交互细节。 +- 全量 158 条任务卡字段配置复制版。 +- Rate Code 和房型规则的代码实现。 + +## 18. 待确认问题 + +- SuperAgent 创建任务接口的 URL、Method、Header、鉴权和错误响应格式。 +- SuperAgent 查询上下文接口暂不在本 checkpoint 实现,但 SuperAgent 侧已经在整理,后续梳理未完成事项时必须持续提醒。 +- `source_event_index`、批次 item index、`execution_order` 的最终编号规则是否都从 1 开始。 +- `idempotency_key` 的来源和冲突处理策略。 +- 订单状态机有哪些稳定状态。 +- 任务状态机有哪些稳定状态。 +- `Message Notification` 是否需要在前端订单列表上单独标识为只读提醒。 +- OPERA 模拟结果中 Confirmation No.、Group Code、Block Code、Allotment Code 的具体字段路径。 +- 临时订单在无任务后是否立即逻辑删除,还是保留一段时间便于追溯。 +- 用户是否允许强制完成任务;如果允许,需要什么权限和审计原因。 + +## 19. 后续建议 checkpoint + +建议后续按以下顺序拆分: + +1. 定义 SuperAgent 调本系统的任务结果接收接口契约。 +2. 定义 AI 过渡层、订单、任务、任务卡、审计、OPERA 模拟结果的最小数据模型。 +3. 定义系统主任务类型、任务卡类型、`result_type`、任务状态和订单状态枚举。 +4. 实现 AI 结果接收、幂等、顺序保存和任务卡创建。 +5. 实现订单自动挂靠、临时订单创建和人工订单切换。 +6. 实现任务详情字段展示、编辑、确认和 `confirmed_payload_json`。 +7. 实现任务队列阻塞规则和 `Message Notification` 只读归档规则。 +8. 实现 OPERA 模拟操作结果底表、重试和订单业务号回填。 +9. 根据前端页面范围实现列表、详情和只读/可处理状态展示。 diff --git a/docs/project/requirements/M002-superagent-task-result-api-contract.md b/docs/project/requirements/M002-superagent-task-result-api-contract.md new file mode 100644 index 0000000..e87956a --- /dev/null +++ b/docs/project/requirements/M002-superagent-task-result-api-contract.md @@ -0,0 +1,378 @@ +# M002 SuperAgent Task Result API Contract + +## 文档信息 + +| 项目 | 内容 | +| --- | --- | +| 文档版本 | 0.1 | +| 日期 | 2026-07-07 | +| 状态 | 后端接口契约草稿 | +| 适用范围 | SuperAgent / Main Agent 调用本系统提交 AI 任务结果 | +| 主要读者 | 后端、SuperAgent 对接方、测试、后续协作 agent | + +## 1. 文档定位 + +本文定义 SuperAgent / Main Agent 向本系统提交 `ai_task_results[]` 的第一版后端接口契约。 + +本文承接 `M002-order-task-workflow-v2.md`,只定义本系统入站接口、鉴权、幂等、请求响应和技术校验边界,不定义 SuperAgent 内部 prompt、Skill 实现、OPERA 真实接口或前端展示细节。 + +## 2. 接口概览 + +| 项目 | 内容 | +| --- | --- | +| Method | `POST` | +| Path | `/api/integrations/superagent/task-results` | +| Content-Type | `application/json` | +| 响应格式 | `application/json` | +| 一次请求范围 | 只能包含一个 `source_message_id` | +| 业务动作 | 接收 AI 结果、写入 AI 过渡层、生成订单 / 任务 / 任务卡 | +| 鉴权方式 | HMAC-SHA256 签名 | + +中文说明: + +- 该接口是服务到服务的入站接口,不给前端直接调用。 +- SuperAgent 不直连数据库,只能通过本接口提交任务结果。 +- 本接口只做技术校验和系统接收,不替代用户确认和 OPERA 模拟操作。 + +## 3. 鉴权方案 + +第一版使用 HMAC-SHA256 签名,做到简单、可实现、可防重放。 + +### 3.1 环境变量 + +| 环境变量 | 是否必填 | 中文说明 | +| --- | --- | --- | +| `SUPERAGENT_TASK_RESULT_HMAC_SECRET` | 是 | SuperAgent 任务结果入站签名密钥,只能通过 Secret 注入 | +| `SUPERAGENT_TASK_RESULT_CLOCK_SKEW_SECONDS` | 否 | 请求时间允许偏移,默认 `300` 秒 | +| `SUPERAGENT_TASK_RESULT_NONCE_TTL_SECONDS` | 否 | Nonce 去重窗口,默认 `600` 秒 | +| `SUPERAGENT_TASK_RESULT_MAX_BODY_BYTES` | 否 | 请求体最大字节数,默认 `1048576` | + +如果 `SUPERAGENT_TASK_RESULT_HMAC_SECRET` 为空,生产环境应拒绝接口调用。 + +### 3.2 请求 Header + +| Header | 是否必填 | 中文说明 | +| --- | --- | --- | +| `X-TH-Hotel-SuperAgent-Client-Id` | 是 | 调用方客户端 ID,用于区分不同 SuperAgent 调用方 | +| `X-TH-Hotel-SuperAgent-Timestamp` | 是 | UTC 时间,ISO-8601 格式,例如 `2026-07-07T08:30:00Z` | +| `X-TH-Hotel-SuperAgent-Nonce` | 是 | 每次请求唯一随机值,用于防重放 | +| `X-TH-Hotel-SuperAgent-Signature` | 是 | HMAC 签名,格式 `sha256=` | +| `X-TH-Hotel-Request-Id` | 否 | 调用方请求 ID,用于排查和日志串联 | + +### 3.3 签名串 + +签名使用原始请求体字节计算 SHA-256,再参与 HMAC。 + +规范签名串: + +```text +POST +/api/integrations/superagent/task-results + + + + +``` + +签名算法: + +```text +signature = HMAC_SHA256(SUPERAGENT_TASK_RESULT_HMAC_SECRET, canonical_string) +``` + +Header 写法: + +```text +X-TH-Hotel-SuperAgent-Signature: sha256= +``` + +### 3.4 服务端校验 + +服务端必须按顺序完成以下校验: + +1. 校验必要 Header 是否存在。 +2. 校验 timestamp 可解析且在允许时间窗口内。 +3. 校验同一个 `client_id + nonce` 在 TTL 窗口内没有被使用过。 +4. 计算原始请求体 SHA-256。 +5. 使用 HMAC secret 重新计算签名。 +6. 使用常量时间比较签名。 +7. 鉴权通过后再解析 JSON。 + +日志和错误响应不得输出 secret、签名原文、完整请求体、邮件正文、附件 URL 或个人敏感信息。 + +## 4. 请求体 + +请求体沿用 AI 导入文档定义的聚合结构。 + +```json +{ + "source_message_id": "1900000000000000001", + "ai_task_results": [ + { + "source_event_index": 1, + "catalog_code": "S01", + "skill_id": "S01_new_booking_skill", + "result_type": "normal_task", + "task_type": "New Booking", + "task_subtype": "new_fit_reservation", + "current_or_history": "current", + "case_keys": { + "group_code": null, + "confirmation_number": null + }, + "visible_reason": "邮件正文包含新建预订请求。", + "relevant_message_excerpt": "Please create a new booking...", + "attachments": [], + "file_references": [], + "context_used": {}, + "extracted_fields": {}, + "manual_review": null, + "informational_message": null, + "additional_operations": [], + "idempotency_key": null + } + ], + "extraction_warnings": [] +} +``` + +### 4.1 顶层字段 + +| 字段 | 是否必填 | 中文说明 | +| --- | --- | --- | +| `source_message_id` | 是 | 关联本系统 SourceMessage ID;一次请求只能有一个 | +| `ai_task_results[]` | 是 | AI 拆分出的任务结果列表,必须保留数组顺序 | +| `extraction_warnings[]` | 否 | 抽取警告;不直接等同于业务任务 | + +第一版要求 `ai_task_results[]` 至少包含一条记录。没有业务动作时应由 AI 输出 `Message Notification` 或 `Fallback/manual_review`,而不是提交空数组。 + +### 4.2 `ai_task_results[]` 字段 + +| 字段 | 是否必填 | 中文说明 | +| --- | --- | --- | +| `source_event_index` | 是 | AI current 事件序号,建议从 1 开始 | +| `catalog_code` | 是 | Skill 目录代码,例如 S01、S02 | +| `skill_id` | 是 | Skill 标识 | +| `result_type` | 是 | 只接受 `normal_task`、`manual_review`、`informational_message` | +| `task_type` | 是 | AI 原始任务类型 | +| `task_subtype` | 否 | 业务动作 subtype;有则用于任务卡路由 | +| `current_or_history` | 否 | 当前或历史标识 | +| `case_keys` | 否 | 订单关联候选键 | +| `visible_reason` | 否 | 给用户看的生成原因 | +| `relevant_message_excerpt` | 否 | 相关邮件片段,注意不要超长 | +| `attachments` / `file_references` | 否 | 附件和文件引用 | +| `context_used` | 否 | AI 使用的上下文 | +| `extracted_fields` | 否 | 业务字段主体 | +| `manual_review` | 条件必填 | `result_type=manual_review` 时应提供 | +| `informational_message` | 条件必填 | `result_type=informational_message` 时应提供 | +| `additional_operations` | 否 | 附加动作建议 | +| `idempotency_key` | 否 | 可忽略;本系统第一版自行生成幂等键 | + +## 5. 技术校验边界 + +本接口只做技术校验,不做业务合法性判断。 + +### 5.1 必须校验 + +- 鉴权签名合法。 +- 请求体大小不超过限制。 +- JSON 可解析。 +- 顶层只有一个 `source_message_id`。 +- `source_message_id` 对应的 SourceMessage 存在。 +- `ai_task_results[]` 是非空数组。 +- `result_type` 属于允许值。 +- `task_type` 属于当前系统可识别的稳定值或可进入 Fallback 处理。 +- 同一个请求内 `source_event_index` 和数组顺序可保存。 +- 关键字符串长度不超过数据库限制。 + +### 5.2 不在本接口判断 + +- 不判断 AI 任务类型是否业务正确。 +- 不判断房型、价格、日期、Rate Code 是否合理。 +- 不判断 Cancel Booking 前是否已经有 New Booking。 +- 不因为字段缺失自动改成 Fallback。 +- 不直接执行 OPERA 模拟。 +- 不直接把 AI 原始值写入 OPERA 参数。 + +## 6. 幂等设计 + +`idempotency_key` 第一版由系统生成,不依赖 SuperAgent 传值。 + +### 6.1 请求哈希 + +系统必须保存原始请求体的 SHA-256: + +```text +request_payload_sha256 = sha256(raw_request_body) +``` + +### 6.2 批次幂等键 + +批次幂等键建议: + +```text +batch_idempotency_key = +sha256( + "superagent-task-result-batch:v1" + + "|" + source_message_id + + "|" + request_payload_sha256 +) +``` + +作用: + +- 相同请求体重复提交时识别为幂等重放。 +- 同一个 `source_message_id` 如果提交了不同请求体,不会被误认为同一个批次。 + +### 6.3 item 幂等键 + +每条 `ai_task_results[]` 的幂等键建议: + +```text +item_idempotency_key = +sha256( + "superagent-task-result-item:v1" + + "|" + source_message_id + + "|" + source_event_index + + "|" + array_index + + "|" + catalog_code + + "|" + skill_id + + "|" + result_type + + "|" + task_type + + "|" + task_subtype + + "|" + item_payload_sha256 +) +``` + +说明: + +- `array_index` 按 AI 返回列表顺序保存,建议从 1 开始。 +- `item_payload_sha256` 是单个 item 规范 JSON 或原始片段的 SHA-256。 +- 数据库应对 `hotel_id + item_idempotency_key` 建唯一约束。 + +### 6.4 重复提交处理 + +| 场景 | 处理方式 | +| --- | --- | +| 完全相同请求体重复提交 | 返回已有 batch 和 item,不重复创建任务 | +| 同一 `source_message_id` 不同请求体 | 作为潜在冲突处理,第一版建议拒绝并返回 `IDEMPOTENCY_CONFLICT`,除非后续明确支持重新抽取版本 | +| 同一请求内 item 幂等键重复 | 拒绝请求,返回 `DUPLICATE_TASK_RESULT_ITEM` | + +## 7. 系统映射规则 + +接口接收后,应将 AI 字段映射到系统字段。 + +| AI 字段 | 系统字段 | 中文说明 | +| --- | --- | --- | +| `task_type` | `ai_task_type` | 保留 AI 原始任务类型 | +| `result_type` | `result_type` | 保留 AI 结果类型 | +| `task_type + result_type` | `system_task_type` | 映射为系统主任务类型 | +| `task_type + task_subtype` | `task_card_type` | 映射为任务卡类型 | +| `source_event_index + array_index` | `execution_order` | 生成同订单任务顺序 | +| 完整 item JSON | `ai_payload_json` | 保存 AI 原始 payload | +| `case_keys` | `case_keys_json` | 保存订单候选键 | +| `extracted_fields` | `extracted_fields_json` | 保存业务字段 | +| `manual_review` | `manual_review_json` | 保存人工复核结构 | +| `informational_message` | `informational_message_json` | 保存信息提醒 | + +系统主任务类型映射以 `M002-order-task-workflow-v2.md` 为准。 + +## 8. 响应体 + +### 8.1 创建成功 + +首次成功创建时返回 `201 Created`。 + +```json +{ + "request_id": "req-20260707-0001", + "source_message_id": "1900000000000000001", + "batch_id": "1900000000000001001", + "idempotent_replay": false, + "accepted_count": 1, + "items": [ + { + "source_event_index": 1, + "array_index": 1, + "ai_transition_id": "1900000000000002001", + "order_id": "1900000000000003001", + "task_id": "1900000000000004001", + "system_task_type": "NEW_BOOKING", + "task_card_type": "NEW_BOOKING", + "task_status": "PENDING_CONFIRM", + "order_status": "TEMPORARY", + "execution_order": 1 + } + ], + "warnings": [] +} +``` + +### 8.2 幂等重放 + +相同请求体重复提交时返回 `200 OK`。 + +```json +{ + "request_id": "req-20260707-0002", + "source_message_id": "1900000000000000001", + "batch_id": "1900000000000001001", + "idempotent_replay": true, + "accepted_count": 1, + "items": [], + "warnings": [ + { + "code": "IDEMPOTENT_REPLAY", + "message": "相同请求已经处理,本次未重复创建任务。" + } + ] +} +``` + +## 9. 错误响应 + +错误响应统一结构: + +```json +{ + "request_id": "req-20260707-0003", + "error_code": "AUTH_SIGNATURE_INVALID", + "message": "签名校验失败。", + "details": [] +} +``` + +常见错误码: + +| HTTP 状态 | error_code | 中文说明 | +| --- | --- | --- | +| 401 | `AUTH_HEADER_MISSING` | 鉴权 Header 缺失 | +| 401 | `AUTH_TIMESTAMP_INVALID` | 请求时间无效或超出窗口 | +| 401 | `AUTH_SIGNATURE_INVALID` | 签名不匹配 | +| 409 | `AUTH_NONCE_REPLAY` | Nonce 重放 | +| 413 | `REQUEST_BODY_TOO_LARGE` | 请求体过大 | +| 400 | `INVALID_JSON` | JSON 不可解析 | +| 400 | `SOURCE_MESSAGE_REQUIRED` | `source_message_id` 缺失 | +| 404 | `SOURCE_MESSAGE_NOT_FOUND` | SourceMessage 不存在 | +| 400 | `TASK_RESULTS_EMPTY` | `ai_task_results[]` 为空 | +| 400 | `TASK_RESULT_UNSUPPORTED_TYPE` | `result_type` 或 `task_type` 不可识别 | +| 400 | `DUPLICATE_TASK_RESULT_ITEM` | 同一请求内 item 重复 | +| 409 | `IDEMPOTENCY_CONFLICT` | 同一 SourceMessage 出现不同请求体重复提交 | +| 500 | `INTERNAL_ERROR` | 系统内部错误 | + +错误响应不得返回原始请求体、邮件正文、附件 URL、Token、签名 secret 或完整个人敏感信息。 + +## 10. 验收标准 + +第一版接口实现完成时至少满足: + +- 缺失 HMAC Header 时返回 `401`。 +- timestamp 超出窗口时返回 `401`。 +- nonce 重放时返回 `409`。 +- 签名错误时返回 `401`。 +- 一个请求只能包含一个 `source_message_id`。 +- `source_message_id` 不存在时返回 `404`。 +- 相同请求重复提交不会重复创建任务。 +- 同一 `source_message_id` 不同请求体重复提交返回 `409`。 +- 成功接收后能保存 AI 过渡层记录,并返回 batch、task 和 order 标识。 +- 日志和响应不暴露 secret、签名原文、完整邮件正文或附件 URL。 diff --git a/server/src/main/java/cn/nianxx/thhotel/ThHotelApplication.java b/server/src/main/java/cn/nianxx/thhotel/ThHotelApplication.java index ca081c4..e90d7b5 100644 --- a/server/src/main/java/cn/nianxx/thhotel/ThHotelApplication.java +++ b/server/src/main/java/cn/nianxx/thhotel/ThHotelApplication.java @@ -7,7 +7,11 @@ import org.springframework.boot.autoconfigure.SpringBootApplication; /** * TH Hotel 后端应用入口。 */ -@MapperScan("cn.nianxx.thhotel.platform.message.mapper") +@MapperScan({ + "cn.nianxx.thhotel.platform.message.mapper", + "cn.nianxx.thhotel.workflows.reservation.mapper", + "cn.nianxx.thhotel.integrations.ai.superagent.mapper" +}) @SpringBootApplication public class ThHotelApplication { diff --git a/server/src/main/java/cn/nianxx/thhotel/integrations/ai/superagent/common/request/SuperAgentTaskResultSecurityRequest.java b/server/src/main/java/cn/nianxx/thhotel/integrations/ai/superagent/common/request/SuperAgentTaskResultSecurityRequest.java new file mode 100644 index 0000000..38f553c --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/integrations/ai/superagent/common/request/SuperAgentTaskResultSecurityRequest.java @@ -0,0 +1,15 @@ +package cn.nianxx.thhotel.integrations.ai.superagent.common.request; + +/** + * SuperAgent 任务结果入站签名校验请求。只包含鉴权所需上下文,不承载业务 JSON 解析结果。 + */ +public record SuperAgentTaskResultSecurityRequest( + String httpMethod, + String requestPath, + String clientId, + String timestamp, + String nonce, + String signature, + String rawBody +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/integrations/ai/superagent/common/result/SuperAgentTaskResultErrorResponse.java b/server/src/main/java/cn/nianxx/thhotel/integrations/ai/superagent/common/result/SuperAgentTaskResultErrorResponse.java new file mode 100644 index 0000000..0fede7b --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/integrations/ai/superagent/common/result/SuperAgentTaskResultErrorResponse.java @@ -0,0 +1,17 @@ +package cn.nianxx.thhotel.integrations.ai.superagent.common.result; + +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.List; + +/** + * SuperAgent 任务结果入站错误响应。错误信息必须安全,不返回 Secret、签名原文或完整请求体。 + */ +public record SuperAgentTaskResultErrorResponse( + @JsonProperty("request_id") + String requestId, + @JsonProperty("error_code") + String errorCode, + String message, + List details +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/integrations/ai/superagent/control/SuperAgentTaskResultBodySizeFilter.java b/server/src/main/java/cn/nianxx/thhotel/integrations/ai/superagent/control/SuperAgentTaskResultBodySizeFilter.java new file mode 100644 index 0000000..5c97279 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/integrations/ai/superagent/control/SuperAgentTaskResultBodySizeFilter.java @@ -0,0 +1,207 @@ +package cn.nianxx.thhotel.integrations.ai.superagent.control; + +import cn.nianxx.thhotel.integrations.ai.superagent.common.result.SuperAgentTaskResultErrorResponse; +import cn.nianxx.thhotel.integrations.ai.superagent.service.impl.SuperAgentTaskResultProperties; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ReadListener; +import jakarta.servlet.ServletInputStream; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletRequestWrapper; +import jakarta.servlet.http.HttpServletResponse; +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.util.List; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +/** + * SuperAgent 任务结果请求体大小过滤器。进入 Controller 前拦截明显超限请求,并对未知长度请求做读取限流。 + */ +@Component +public class SuperAgentTaskResultBodySizeFilter extends OncePerRequestFilter { + + private static final String REQUEST_PATH = "/api/integrations/superagent/task-results"; + + private final SuperAgentTaskResultProperties properties; + private final ObjectMapper objectMapper; + + /** + * 注入 SuperAgent 请求大小配置和 JSON 序列化器,用于输出统一安全错误响应。 + */ + public SuperAgentTaskResultBodySizeFilter( + SuperAgentTaskResultProperties properties, + ObjectMapper objectMapper) { + this.properties = properties; + this.objectMapper = objectMapper; + } + + /** + * 对 SuperAgent 入站接口执行大小预检查和流式限流,超限时不再进入业务处理。 + */ + @Override + protected void doFilterInternal( + HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + long maxBodyBytes = properties.getMaxBodyBytes(); + long contentLength = request.getContentLengthLong(); + if (matchesTaskResultEndpoint(request) && maxBodyBytes >= 0 && contentLength > maxBodyBytes) { + writePayloadTooLarge(response); + return; + } + if (matchesTaskResultEndpoint(request) && maxBodyBytes >= 0) { + try { + filterChain.doFilter(new LimitedBodyRequest(request, maxBodyBytes), response); + } catch (RequestBodyTooLargeException exception) { + if (!response.isCommitted()) { + writePayloadTooLarge(response); + } + } + return; + } + filterChain.doFilter(request, response); + } + + /** + * 判断当前请求是否为 SuperAgent 任务结果入站接口。 + */ + private boolean matchesTaskResultEndpoint(HttpServletRequest request) { + return "POST".equalsIgnoreCase(request.getMethod()) && REQUEST_PATH.equals(request.getServletPath()); + } + + /** + * 写出统一 413 错误,避免响应中包含原始请求体或 Secret。 + */ + private void writePayloadTooLarge(HttpServletResponse response) throws IOException { + response.setStatus(HttpStatus.PAYLOAD_TOO_LARGE.value()); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + objectMapper.writeValue(response.getWriter(), new SuperAgentTaskResultErrorResponse( + null, + "REQUEST_BODY_TOO_LARGE", + "请求体超过允许大小。", + List.of())); + } + + /** + * 限流请求包装器。用于 Content-Length 不可靠或缺失时,在读取请求体过程中强制限制字节数。 + */ + private static final class LimitedBodyRequest extends HttpServletRequestWrapper { + + private final long maxBodyBytes; + + /** + * 包装原始请求,并记录最大允许读取字节数。 + */ + private LimitedBodyRequest(HttpServletRequest request, long maxBodyBytes) { + super(request); + this.maxBodyBytes = maxBodyBytes; + } + + /** + * 返回带字节计数的输入流。 + */ + @Override + public ServletInputStream getInputStream() throws IOException { + return new LimitedServletInputStream(super.getInputStream(), maxBodyBytes); + } + + /** + * 返回带字节计数的字符读取器,保持请求原始字符集。 + */ + @Override + public BufferedReader getReader() throws IOException { + String encoding = getCharacterEncoding(); + Charset charset = encoding == null ? StandardCharsets.UTF_8 : Charset.forName(encoding); + return new BufferedReader(new InputStreamReader(getInputStream(), charset)); + } + } + + /** + * 带读取上限的 ServletInputStream。超过限制立即抛出受控 IO 异常。 + */ + private static final class LimitedServletInputStream extends ServletInputStream { + + private final ServletInputStream delegate; + private final long maxBodyBytes; + private long bytesRead; + + /** + * 注入原始输入流和最大允许读取字节数。 + */ + private LimitedServletInputStream(ServletInputStream delegate, long maxBodyBytes) { + this.delegate = delegate; + this.maxBodyBytes = maxBodyBytes; + } + + /** + * 逐字节读取并累计大小,超过限制则中断。 + */ + @Override + public int read() throws IOException { + int value = delegate.read(); + if (value != -1) { + countBytes(1); + } + return value; + } + + /** + * 批量读取并累计实际读取大小,超过限制则中断。 + */ + @Override + public int read(byte[] buffer, int offset, int length) throws IOException { + int count = delegate.read(buffer, offset, length); + if (count > 0) { + countBytes(count); + } + return count; + } + + /** + * 委托容器判断流是否结束。 + */ + @Override + public boolean isFinished() { + return delegate.isFinished(); + } + + /** + * 委托容器判断流是否可读。 + */ + @Override + public boolean isReady() { + return delegate.isReady(); + } + + /** + * 委托异步读取监听器。 + */ + @Override + public void setReadListener(ReadListener readListener) { + delegate.setReadListener(readListener); + } + + /** + * 累计已读字节数,超过上限时抛出专用异常。 + */ + private void countBytes(int count) throws IOException { + bytesRead += count; + if (bytesRead > maxBodyBytes) { + throw new RequestBodyTooLargeException(); + } + } + } + + /** + * 请求体读取超限异常。只在过滤器内部转换为 413 响应。 + */ + private static final class RequestBodyTooLargeException extends IOException { + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/integrations/ai/superagent/control/SuperAgentTaskResultController.java b/server/src/main/java/cn/nianxx/thhotel/integrations/ai/superagent/control/SuperAgentTaskResultController.java new file mode 100644 index 0000000..c4b87b3 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/integrations/ai/superagent/control/SuperAgentTaskResultController.java @@ -0,0 +1,84 @@ +package cn.nianxx.thhotel.integrations.ai.superagent.control; + +import cn.nianxx.thhotel.integrations.ai.superagent.common.request.SuperAgentTaskResultSecurityRequest; +import cn.nianxx.thhotel.integrations.ai.superagent.service.SuperAgentTaskResultSecurityService; +import cn.nianxx.thhotel.integrations.ai.superagent.service.impl.SuperAgentTaskResultException; +import cn.nianxx.thhotel.integrations.ai.superagent.service.impl.SuperAgentTaskResultProperties; +import cn.nianxx.thhotel.workflows.reservation.common.result.SuperAgentTaskResultResponse; +import cn.nianxx.thhotel.workflows.reservation.service.ReservationAiTaskIntakeService; +import java.nio.charset.StandardCharsets; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * SuperAgent 任务结果入站接口。Controller 只处理外部协议、安全校验和请求分发。 + */ +@RestController +@RequestMapping("/api/integrations/superagent/task-results") +public class SuperAgentTaskResultController { + + private static final String REQUEST_PATH = "/api/integrations/superagent/task-results"; + + private final SuperAgentTaskResultSecurityService securityService; + private final SuperAgentTaskResultProperties properties; + private final ReservationAiTaskIntakeService intakeService; + + /** + * 注入 SuperAgent 安全服务和 Reservation 接收服务,避免 Controller 直接访问业务持久化层。 + */ + public SuperAgentTaskResultController( + SuperAgentTaskResultSecurityService securityService, + SuperAgentTaskResultProperties properties, + ReservationAiTaskIntakeService intakeService) { + this.securityService = securityService; + this.properties = properties; + this.intakeService = intakeService; + } + + /** + * 接收 SuperAgent AI 任务结果,先限制请求体大小,再完成 HMAC 鉴权,最后委托业务服务创建 CP1-3 数据。 + */ + @PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity accept( + @RequestBody(required = false) String rawBody, + @RequestHeader(name = "X-TH-Hotel-SuperAgent-Client-Id", required = false) String clientId, + @RequestHeader(name = "X-TH-Hotel-SuperAgent-Timestamp", required = false) String timestamp, + @RequestHeader(name = "X-TH-Hotel-SuperAgent-Nonce", required = false) String nonce, + @RequestHeader(name = "X-TH-Hotel-SuperAgent-Signature", required = false) String signature, + @RequestHeader(name = "X-TH-Hotel-Request-Id", required = false) String requestId) { + String requestBody = rawBody == null ? "" : rawBody; + rejectBodyWhenTooLarge(requestBody); + securityService.verify(new SuperAgentTaskResultSecurityRequest( + "POST", + REQUEST_PATH, + clientId, + timestamp, + nonce, + signature, + requestBody + )); + SuperAgentTaskResultResponse response = intakeService.accept(requestBody, clientId, requestId); + HttpStatus status = response.idempotentReplay() ? HttpStatus.OK : HttpStatus.CREATED; + return ResponseEntity.status(status).body(response); + } + + /** + * 在解析 JSON 和校验 Header 之前限制请求体大小,避免超限请求进入后续处理。 + */ + private void rejectBodyWhenTooLarge(String rawBody) { + long maxBodyBytes = properties.getMaxBodyBytes(); + int actualBytes = rawBody.getBytes(StandardCharsets.UTF_8).length; + if (maxBodyBytes >= 0 && actualBytes > maxBodyBytes) { + throw new SuperAgentTaskResultException( + HttpStatus.PAYLOAD_TOO_LARGE, + "REQUEST_BODY_TOO_LARGE", + "请求体超过允许大小。"); + } + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/integrations/ai/superagent/control/SuperAgentTaskResultControllerAdvice.java b/server/src/main/java/cn/nianxx/thhotel/integrations/ai/superagent/control/SuperAgentTaskResultControllerAdvice.java new file mode 100644 index 0000000..d7adb77 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/integrations/ai/superagent/control/SuperAgentTaskResultControllerAdvice.java @@ -0,0 +1,43 @@ +package cn.nianxx.thhotel.integrations.ai.superagent.control; + +import cn.nianxx.thhotel.integrations.ai.superagent.common.result.SuperAgentTaskResultErrorResponse; +import cn.nianxx.thhotel.integrations.ai.superagent.service.impl.SuperAgentTaskResultException; +import cn.nianxx.thhotel.workflows.reservation.service.impl.ReservationAiTaskIntakeException; +import java.util.List; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +/** + * SuperAgent 任务结果入站统一异常处理。错误响应只返回安全错误码和简短说明。 + */ +@RestControllerAdvice(assignableTypes = SuperAgentTaskResultController.class) +public class SuperAgentTaskResultControllerAdvice { + + /** + * 处理 SuperAgent 鉴权、请求大小等协议层受控异常。 + */ + @ExceptionHandler(SuperAgentTaskResultException.class) + public ResponseEntity handleSecurityException( + SuperAgentTaskResultException exception) { + return ResponseEntity.status(exception.getStatus()) + .body(error(exception.getErrorCode(), exception.getMessage())); + } + + /** + * 处理 Reservation 接收阶段的 SourceMessage、幂等和 AI item 技术校验异常。 + */ + @ExceptionHandler(ReservationAiTaskIntakeException.class) + public ResponseEntity handleIntakeException( + ReservationAiTaskIntakeException exception) { + return ResponseEntity.status(exception.getStatus()) + .body(error(exception.getErrorCode(), exception.getMessage())); + } + + /** + * 构建统一错误响应,第一版不回显 request_id,避免异常路径暴露未经校验的外部输入。 + */ + private SuperAgentTaskResultErrorResponse error(String errorCode, String message) { + return new SuperAgentTaskResultErrorResponse(null, errorCode, message, List.of()); + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/integrations/ai/superagent/domain/SuperAgentTaskResultNonceEntity.java b/server/src/main/java/cn/nianxx/thhotel/integrations/ai/superagent/domain/SuperAgentTaskResultNonceEntity.java new file mode 100644 index 0000000..dc8b1bd --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/integrations/ai/superagent/domain/SuperAgentTaskResultNonceEntity.java @@ -0,0 +1,65 @@ +package cn.nianxx.thhotel.integrations.ai.superagent.domain; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import java.time.LocalDateTime; + +/** + * SuperAgent 任务结果入站 nonce 实体。用于 HMAC 请求防重放,不保存签名 secret。 + */ +@TableName("integration_superagent_task_result_nonce") +public class SuperAgentTaskResultNonceEntity { + + /** Nonce 记录 ID。 */ + @TableId(type = IdType.ASSIGN_ID) + private Long id; + /** SuperAgent 调用方客户端 ID。 */ + private String clientId; + /** 单次请求随机值,和 clientId 组成防重放唯一键。 */ + private String nonce; + /** Nonce 过期 UTC 时间。 */ + private LocalDateTime expiresAt; + /** 记录创建 UTC 时间。 */ + private LocalDateTime createdAt; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getClientId() { + return clientId; + } + + public void setClientId(String clientId) { + this.clientId = clientId; + } + + public String getNonce() { + return nonce; + } + + public void setNonce(String nonce) { + this.nonce = nonce; + } + + public LocalDateTime getExpiresAt() { + return expiresAt; + } + + public void setExpiresAt(LocalDateTime expiresAt) { + this.expiresAt = expiresAt; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(LocalDateTime createdAt) { + this.createdAt = createdAt; + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/integrations/ai/superagent/mapper/SuperAgentTaskResultNonceMapper.java b/server/src/main/java/cn/nianxx/thhotel/integrations/ai/superagent/mapper/SuperAgentTaskResultNonceMapper.java new file mode 100644 index 0000000..d17c80c --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/integrations/ai/superagent/mapper/SuperAgentTaskResultNonceMapper.java @@ -0,0 +1,12 @@ +package cn.nianxx.thhotel.integrations.ai.superagent.mapper; + +import cn.nianxx.thhotel.integrations.ai.superagent.domain.SuperAgentTaskResultNonceEntity; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; + +/** + * SuperAgent 任务结果入站 nonce Mapper,只负责防重放表访问。 + */ +@Mapper +public interface SuperAgentTaskResultNonceMapper extends BaseMapper { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/integrations/ai/superagent/repository/MybatisSuperAgentTaskResultNonceRepository.java b/server/src/main/java/cn/nianxx/thhotel/integrations/ai/superagent/repository/MybatisSuperAgentTaskResultNonceRepository.java new file mode 100644 index 0000000..9a25ee0 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/integrations/ai/superagent/repository/MybatisSuperAgentTaskResultNonceRepository.java @@ -0,0 +1,46 @@ +package cn.nianxx.thhotel.integrations.ai.superagent.repository; + +import cn.nianxx.thhotel.integrations.ai.superagent.domain.SuperAgentTaskResultNonceEntity; +import cn.nianxx.thhotel.integrations.ai.superagent.mapper.SuperAgentTaskResultNonceMapper; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import java.time.LocalDateTime; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.stereotype.Repository; + +/** + * SuperAgent nonce 的 MyBatis-Plus 持久化实现。数据库唯一键是防重放最终防线。 + */ +@Repository +public class MybatisSuperAgentTaskResultNonceRepository implements SuperAgentTaskResultNonceRepository { + + private final SuperAgentTaskResultNonceMapper nonceMapper; + + /** + * 注入 nonce Mapper,Repository 负责屏蔽唯一键冲突细节。 + */ + public MybatisSuperAgentTaskResultNonceRepository(SuperAgentTaskResultNonceMapper nonceMapper) { + this.nonceMapper = nonceMapper; + } + + /** + * 插入 nonce 防重放记录;并发重复请求会命中唯一键并返回 false。 + */ + @Override + public boolean saveIfAbsent(String clientId, String nonce, LocalDateTime expiresAt, LocalDateTime createdAt) { + nonceMapper.delete(Wrappers.lambdaQuery() + .eq(SuperAgentTaskResultNonceEntity::getClientId, clientId) + .eq(SuperAgentTaskResultNonceEntity::getNonce, nonce) + .lt(SuperAgentTaskResultNonceEntity::getExpiresAt, createdAt)); + SuperAgentTaskResultNonceEntity entity = new SuperAgentTaskResultNonceEntity(); + entity.setClientId(clientId); + entity.setNonce(nonce); + entity.setExpiresAt(expiresAt); + entity.setCreatedAt(createdAt); + try { + nonceMapper.insert(entity); + return true; + } catch (DuplicateKeyException exception) { + return false; + } + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/integrations/ai/superagent/repository/SuperAgentTaskResultNonceRepository.java b/server/src/main/java/cn/nianxx/thhotel/integrations/ai/superagent/repository/SuperAgentTaskResultNonceRepository.java new file mode 100644 index 0000000..111c018 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/integrations/ai/superagent/repository/SuperAgentTaskResultNonceRepository.java @@ -0,0 +1,14 @@ +package cn.nianxx.thhotel.integrations.ai.superagent.repository; + +import java.time.LocalDateTime; + +/** + * SuperAgent 入站 nonce 持久化边界。Service 通过该接口完成防重放,不直接访问 Mapper。 + */ +public interface SuperAgentTaskResultNonceRepository { + + /** + * 保存本次请求 nonce。若同一调用方 nonce 已存在,返回 false 表示重放请求。 + */ + boolean saveIfAbsent(String clientId, String nonce, LocalDateTime expiresAt, LocalDateTime createdAt); +} diff --git a/server/src/main/java/cn/nianxx/thhotel/integrations/ai/superagent/service/SuperAgentTaskResultSecurityService.java b/server/src/main/java/cn/nianxx/thhotel/integrations/ai/superagent/service/SuperAgentTaskResultSecurityService.java new file mode 100644 index 0000000..963d356 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/integrations/ai/superagent/service/SuperAgentTaskResultSecurityService.java @@ -0,0 +1,14 @@ +package cn.nianxx.thhotel.integrations.ai.superagent.service; + +import cn.nianxx.thhotel.integrations.ai.superagent.common.request.SuperAgentTaskResultSecurityRequest; + +/** + * SuperAgent 任务结果入站安全服务。负责 HMAC、时间窗口和 nonce 防重放。 + */ +public interface SuperAgentTaskResultSecurityService { + + /** + * 校验 SuperAgent 入站请求签名。失败时抛出受控异常,成功时记录 nonce。 + */ + void verify(SuperAgentTaskResultSecurityRequest request); +} diff --git a/server/src/main/java/cn/nianxx/thhotel/integrations/ai/superagent/service/impl/SuperAgentTaskResultException.java b/server/src/main/java/cn/nianxx/thhotel/integrations/ai/superagent/service/impl/SuperAgentTaskResultException.java new file mode 100644 index 0000000..7a67312 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/integrations/ai/superagent/service/impl/SuperAgentTaskResultException.java @@ -0,0 +1,26 @@ +package cn.nianxx.thhotel.integrations.ai.superagent.service.impl; + +import org.springframework.http.HttpStatus; + +/** + * SuperAgent 任务结果入站受控异常。只携带安全错误码和简短提示。 + */ +public class SuperAgentTaskResultException extends RuntimeException { + + private final HttpStatus status; + private final String errorCode; + + public SuperAgentTaskResultException(HttpStatus status, String errorCode, String message) { + super(message); + this.status = status; + this.errorCode = errorCode; + } + + public HttpStatus getStatus() { + return status; + } + + public String getErrorCode() { + return errorCode; + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/integrations/ai/superagent/service/impl/SuperAgentTaskResultProperties.java b/server/src/main/java/cn/nianxx/thhotel/integrations/ai/superagent/service/impl/SuperAgentTaskResultProperties.java new file mode 100644 index 0000000..0661ace --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/integrations/ai/superagent/service/impl/SuperAgentTaskResultProperties.java @@ -0,0 +1,53 @@ +package cn.nianxx.thhotel.integrations.ai.superagent.service.impl; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +/** + * SuperAgent 任务结果入站配置。Secret 只能来自环境变量或部署平台 Secret。 + */ +@Component +@ConfigurationProperties(prefix = "superagent.task-result") +public class SuperAgentTaskResultProperties { + + /** HMAC 签名密钥,生产环境不能为空。 */ + private String hmacSecret = ""; + /** 请求时间允许偏移秒数。 */ + private long clockSkewSeconds = 300; + /** nonce 防重放保存秒数。 */ + private long nonceTtlSeconds = 600; + /** 请求体最大字节数。 */ + private long maxBodyBytes = 1048576; + + public String getHmacSecret() { + return hmacSecret; + } + + public void setHmacSecret(String hmacSecret) { + this.hmacSecret = hmacSecret; + } + + public long getClockSkewSeconds() { + return clockSkewSeconds; + } + + public void setClockSkewSeconds(long clockSkewSeconds) { + this.clockSkewSeconds = clockSkewSeconds; + } + + public long getNonceTtlSeconds() { + return nonceTtlSeconds; + } + + public void setNonceTtlSeconds(long nonceTtlSeconds) { + this.nonceTtlSeconds = nonceTtlSeconds; + } + + public long getMaxBodyBytes() { + return maxBodyBytes; + } + + public void setMaxBodyBytes(long maxBodyBytes) { + this.maxBodyBytes = maxBodyBytes; + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/integrations/ai/superagent/service/impl/SuperAgentTaskResultSecurityServiceImpl.java b/server/src/main/java/cn/nianxx/thhotel/integrations/ai/superagent/service/impl/SuperAgentTaskResultSecurityServiceImpl.java new file mode 100644 index 0000000..a96dc2c --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/integrations/ai/superagent/service/impl/SuperAgentTaskResultSecurityServiceImpl.java @@ -0,0 +1,174 @@ +package cn.nianxx.thhotel.integrations.ai.superagent.service.impl; + +import cn.nianxx.thhotel.integrations.ai.superagent.common.request.SuperAgentTaskResultSecurityRequest; +import cn.nianxx.thhotel.integrations.ai.superagent.repository.SuperAgentTaskResultNonceRepository; +import cn.nianxx.thhotel.integrations.ai.superagent.service.SuperAgentTaskResultSecurityService; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.HexFormat; +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; + +/** + * SuperAgent HMAC 签名校验实现。该服务不解析业务 JSON,避免鉴权前处理外部业务内容。 + */ +@Service +public class SuperAgentTaskResultSecurityServiceImpl implements SuperAgentTaskResultSecurityService { + + private static final String SIGNATURE_PREFIX = "sha256="; + private static final int CLIENT_ID_MAX_LENGTH = 128; + private static final int TIMESTAMP_MAX_LENGTH = 64; + private static final int NONCE_MAX_LENGTH = 256; + private static final int SIGNATURE_MAX_LENGTH = 71; + + private final SuperAgentTaskResultProperties properties; + private final SuperAgentTaskResultNonceRepository nonceRepository; + private final Clock clock; + + /** + * 注入配置、nonce 持久化边界和 UTC 时钟,便于后续测试时间窗口。 + */ + public SuperAgentTaskResultSecurityServiceImpl( + SuperAgentTaskResultProperties properties, + SuperAgentTaskResultNonceRepository nonceRepository) { + this.properties = properties; + this.nonceRepository = nonceRepository; + this.clock = Clock.systemUTC(); + } + + /** + * 按 Header 完整性、时间窗口、签名和 nonce 顺序校验请求;失败时只返回安全错误码。 + */ + @Override + public void verify(SuperAgentTaskResultSecurityRequest request) { + String clientId = requireHeader(request.clientId(), CLIENT_ID_MAX_LENGTH); + String timestamp = requireHeader(request.timestamp(), TIMESTAMP_MAX_LENGTH); + String nonce = requireHeader(request.nonce(), NONCE_MAX_LENGTH); + String signature = requireHeader(request.signature(), SIGNATURE_MAX_LENGTH); + String secret = trimToNull(properties.getHmacSecret()); + if (secret == null) { + throw error(HttpStatus.UNAUTHORIZED, "AUTH_SIGNATURE_INVALID", "签名校验失败。"); + } + + Instant requestInstant = parseTimestamp(timestamp); + long skew = Math.max(properties.getClockSkewSeconds(), 0L); + if (Math.abs(Duration.between(Instant.now(clock), requestInstant).toSeconds()) > skew) { + throw error(HttpStatus.UNAUTHORIZED, "AUTH_TIMESTAMP_INVALID", "请求时间无效或超出窗口。"); + } + + String expectedSignature = SIGNATURE_PREFIX + hmac(secret, canonicalString(request, timestamp, nonce, clientId)); + if (!constantTimeEquals(expectedSignature, signature)) { + throw error(HttpStatus.UNAUTHORIZED, "AUTH_SIGNATURE_INVALID", "签名校验失败。"); + } + + LocalDateTime now = LocalDateTime.ofInstant(Instant.now(clock), ZoneOffset.UTC); + LocalDateTime expiresAt = now.plusSeconds(Math.max(properties.getNonceTtlSeconds(), 1L)); + if (!nonceRepository.saveIfAbsent(clientId, nonce, expiresAt, now)) { + throw error(HttpStatus.CONFLICT, "AUTH_NONCE_REPLAY", "Nonce 已被使用。"); + } + } + + /** + * 校验必填 Header 文本,避免空白调用方或 nonce 进入签名逻辑。 + */ + private String requireHeader(String value, int maxLength) { + String trimmed = trimToNull(value); + if (trimmed == null) { + throw error(HttpStatus.UNAUTHORIZED, "AUTH_HEADER_MISSING", "鉴权 Header 缺失。"); + } + if (trimmed.length() > maxLength) { + throw error(HttpStatus.UNAUTHORIZED, "AUTH_HEADER_INVALID", "鉴权 Header 无效。"); + } + return trimmed; + } + + /** + * 解析 ISO-8601 UTC 时间,格式错误统一返回时间非法。 + */ + private Instant parseTimestamp(String timestamp) { + try { + return Instant.parse(timestamp); + } catch (RuntimeException exception) { + throw error(HttpStatus.UNAUTHORIZED, "AUTH_TIMESTAMP_INVALID", "请求时间无效或超出窗口。"); + } + } + + /** + * 按契约拼接规范签名串,确保双方签名输入一致。 + */ + private String canonicalString( + SuperAgentTaskResultSecurityRequest request, + String timestamp, + String nonce, + String clientId) { + return request.httpMethod() + "\n" + + request.requestPath() + "\n" + + timestamp + "\n" + + nonce + "\n" + + clientId + "\n" + + sha256(request.rawBody() == null ? "" : request.rawBody()); + } + + /** + * 对规范签名串计算 HMAC-SHA256。 + */ + private String hmac(String secret, String canonical) { + try { + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); + return HexFormat.of().formatHex(mac.doFinal(canonical.getBytes(StandardCharsets.UTF_8))); + } catch (Exception exception) { + throw new IllegalStateException("当前 Java 运行时不支持 HmacSHA256", exception); + } + } + + /** + * 计算原始请求体 SHA-256,签名只使用哈希,不把请求体写入日志。 + */ + private String sha256(String value) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + return HexFormat.of().formatHex(digest.digest(value.getBytes(StandardCharsets.UTF_8))); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("当前 Java 运行时不支持 SHA-256", exception); + } + } + + /** + * 常量时间比较签名,避免因字符串比较提前退出泄漏签名差异。 + */ + private boolean constantTimeEquals(String expected, String actual) { + if (expected == null || actual == null) { + return false; + } + return MessageDigest.isEqual( + expected.getBytes(StandardCharsets.UTF_8), + actual.getBytes(StandardCharsets.UTF_8)); + } + + /** + * 构建安全受控异常,响应不包含外部请求原文。 + */ + private SuperAgentTaskResultException error(HttpStatus status, String errorCode, String message) { + return new SuperAgentTaskResultException(status, errorCode, message); + } + + /** + * 将空白字符串统一为 null。 + */ + private String trimToNull(String value) { + if (value == null) { + return null; + } + String trimmed = value.trim(); + return trimmed.isEmpty() ? null : trimmed; + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/ReservationAiBatchDraft.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/ReservationAiBatchDraft.java new file mode 100644 index 0000000..661f5c0 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/ReservationAiBatchDraft.java @@ -0,0 +1,19 @@ +package cn.nianxx.thhotel.workflows.reservation.common.dto; + +import java.time.LocalDateTime; + +/** + * AI 接收批次入库草稿。Service 构建草稿,Repository 负责转换为数据库实体。 + */ +public record ReservationAiBatchDraft( + String hotelId, + Long sourceMessageId, + String requestPayloadSha256, + String batchIdempotencyKey, + String clientId, + String requestId, + LocalDateTime receivedAt, + int itemCount, + String extractionWarningsJson +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/ReservationAiBatchSnapshot.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/ReservationAiBatchSnapshot.java new file mode 100644 index 0000000..8b28b69 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/ReservationAiBatchSnapshot.java @@ -0,0 +1,14 @@ +package cn.nianxx.thhotel.workflows.reservation.common.dto; + +/** + * AI 接收批次快照。用于幂等重放和冲突判断,不暴露数据库 Entity。 + */ +public record ReservationAiBatchSnapshot( + Long id, + String hotelId, + Long sourceMessageId, + String requestPayloadSha256, + String batchIdempotencyKey, + Integer itemCount +) { +} 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 new file mode 100644 index 0000000..a317398 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/ReservationAiTransitionDraft.java @@ -0,0 +1,40 @@ +package cn.nianxx.thhotel.workflows.reservation.common.dto; + +import java.time.LocalDateTime; + +/** + * AI 过渡记录入库草稿。保留 AI 原始 item JSON 和查询所需冗余字段。 + */ +public record ReservationAiTransitionDraft( + String hotelId, + Long batchId, + Long sourceMessageId, + Integer sourceEventIndex, + Integer arrayIndex, + Integer executionOrder, + String catalogCode, + String skillId, + String resultType, + String aiTaskType, + String systemTaskType, + String taskCardType, + String taskSubtype, + String currentOrHistory, + String groupCode, + String confirmationNumber, + String itemPayloadSha256, + String itemIdempotencyKey, + String manualReasonCode, + Integer parentSourceEventIndex, + String linkedTaskGroupId, + Boolean blockedUntilParentCompleted, + String aiPayloadJson, + String caseKeysJson, + String extractedFieldsJson, + String manualReviewJson, + String informationalMessageJson, + String attachmentsJson, + String contextUsedJson, + LocalDateTime now +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/ReservationAuditLogDraft.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/ReservationAuditLogDraft.java new file mode 100644 index 0000000..c8405fc --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/ReservationAuditLogDraft.java @@ -0,0 +1,21 @@ +package cn.nianxx.thhotel.workflows.reservation.common.dto; + +import java.time.LocalDateTime; + +/** + * 审计日志入库草稿。调用方只传业务摘要,不保存完整原始报文或 Secret。 + */ +public record ReservationAuditLogDraft( + String hotelId, + Long orderId, + Long taskId, + Long operationId, + String actorType, + String actorId, + String action, + String reason, + String beforeSnapshotJson, + String afterSnapshotJson, + LocalDateTime occurredAt +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/ReservationOrderDraft.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/ReservationOrderDraft.java new file mode 100644 index 0000000..ddb8041 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/ReservationOrderDraft.java @@ -0,0 +1,21 @@ +package cn.nianxx.thhotel.workflows.reservation.common.dto; + +import java.time.LocalDateTime; + +/** + * 订单入库草稿。用于创建临时订单或带业务号的有效订单。 + */ +public record ReservationOrderDraft( + String hotelId, + String orderKeyType, + String orderBusinessKey, + String activeBusinessKey, + String temporaryOrderCode, + String orderStatus, + String businessKeySource, + LocalDateTime businessKeyBackfilledAt, + String displayName, + Long sourceMessageId, + LocalDateTime now +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/ReservationOrderSnapshot.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/ReservationOrderSnapshot.java new file mode 100644 index 0000000..e85e9f1 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/ReservationOrderSnapshot.java @@ -0,0 +1,15 @@ +package cn.nianxx.thhotel.workflows.reservation.common.dto; + +/** + * 订单快照。Service 通过快照完成挂靠,不直接使用数据库 Entity。 + */ +public record ReservationOrderSnapshot( + Long id, + String hotelId, + String orderKeyType, + String orderBusinessKey, + String activeBusinessKey, + String temporaryOrderCode, + String orderStatus +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/ReservationTaskCardDraft.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/ReservationTaskCardDraft.java new file mode 100644 index 0000000..ccee0e1 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/ReservationTaskCardDraft.java @@ -0,0 +1,16 @@ +package cn.nianxx.thhotel.workflows.reservation.common.dto; + +import java.time.LocalDateTime; + +/** + * 任务卡入库草稿。CP1-3 只保存 AI 原始快照,不生成 confirmed payload。 + */ +public record ReservationTaskCardDraft( + String hotelId, + Long taskId, + String taskCardType, + String fieldContractVersion, + String aiPayloadJson, + LocalDateTime now +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/ReservationTaskCardFieldDefinition.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/ReservationTaskCardFieldDefinition.java new file mode 100644 index 0000000..f591bf3 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/ReservationTaskCardFieldDefinition.java @@ -0,0 +1,35 @@ +package cn.nianxx.thhotel.workflows.reservation.common.dto; + +/** + * 任务卡字段矩阵定义。字段含义来源于《任务卡展示编辑矩阵.xlsx》。 + */ +public record ReservationTaskCardFieldDefinition( + Integer rowNumber, + String cardName, + String resultType, + String taskType, + String taskSubtype, + String applicableScenario, + String displayArea, + String fieldPath, + String displayName, + String fieldSource, + String visible, + String editable, + String inputEditable, + String selectEditable, + String datePicker, + String numberInput, + String fileDisplay, + String tableEditable, + String enumOptions, + String requiredRule, + String defaultValueSource, + String displayCondition, + String validationRule, + String writePath, + String operaWriteParticipation, + String operaParameterMapping, + String notes +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/ReservationTaskCardSnapshot.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/ReservationTaskCardSnapshot.java new file mode 100644 index 0000000..14b1f05 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/ReservationTaskCardSnapshot.java @@ -0,0 +1,14 @@ +package cn.nianxx.thhotel.workflows.reservation.common.dto; + +/** + * 任务卡快照。保存字段契约版本和 AI 原始快照,供详情接口按矩阵组装展示字段。 + */ +public record ReservationTaskCardSnapshot( + Long id, + String hotelId, + Long taskId, + String taskCardType, + String fieldContractVersion, + String aiPayloadJson +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/ReservationTaskDraft.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/ReservationTaskDraft.java new file mode 100644 index 0000000..eff08af --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/ReservationTaskDraft.java @@ -0,0 +1,27 @@ +package cn.nianxx.thhotel.workflows.reservation.common.dto; + +import java.time.LocalDateTime; + +/** + * 任务入库草稿。保存 AI 映射后的系统任务类型、卡片类型和队列顺序。 + */ +public record ReservationTaskDraft( + String hotelId, + Long orderId, + Long sourceMessageId, + Long aiTransitionId, + String resultType, + String aiTaskType, + String systemTaskType, + String taskCardType, + String taskSubtype, + String taskStatus, + Boolean queueParticipation, + Integer executionOrder, + Integer parentSourceEventIndex, + String linkedTaskGroupId, + Boolean blockedUntilParentCompleted, + LocalDateTime completedAt, + LocalDateTime now +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/ReservationTaskSnapshot.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/ReservationTaskSnapshot.java new file mode 100644 index 0000000..ef8904a --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/ReservationTaskSnapshot.java @@ -0,0 +1,25 @@ +package cn.nianxx.thhotel.workflows.reservation.common.dto; + +/** + * 任务快照。用于任务详情、队列可处理状态和人工复核转换,不向上暴露 Entity。 + */ +public record ReservationTaskSnapshot( + Long id, + String hotelId, + Long orderId, + Long sourceMessageId, + Long aiTransitionId, + String resultType, + String aiTaskType, + String systemTaskType, + String taskCardType, + String taskSubtype, + String taskStatus, + Boolean queueParticipation, + Integer executionOrder, + Long parentTaskId, + Integer parentSourceEventIndex, + String linkedTaskGroupId, + Boolean blockedUntilParentCompleted +) { +} 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 new file mode 100644 index 0000000..0a7d9d6 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/enums/AiResultType.java @@ -0,0 +1,20 @@ +package cn.nianxx.thhotel.workflows.reservation.common.enums; + +/** + * AI 结果类型稳定代码。第一版只允许 normal_task、manual_review 和 informational_message。 + */ +public enum AiResultType { + NORMAL_TASK("normal_task"), + MANUAL_REVIEW("manual_review"), + INFORMATIONAL_MESSAGE("informational_message"); + + private final String code; + + AiResultType(String code) { + this.code = code; + } + + public String code() { + return code; + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/enums/ReservationBusinessKeySource.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/enums/ReservationBusinessKeySource.java new file mode 100644 index 0000000..0662692 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/enums/ReservationBusinessKeySource.java @@ -0,0 +1,10 @@ +package cn.nianxx.thhotel.workflows.reservation.common.enums; + +/** + * 订单业务号来源。用于区分 AI 候选、用户确认和后续 OPERA 模拟回填。 + */ +public enum ReservationBusinessKeySource { + AI_CANDIDATE, + USER_CONFIRMED, + OPERA_SIMULATION_RESULT +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/enums/ReservationOrderKeyType.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/enums/ReservationOrderKeyType.java new file mode 100644 index 0000000..72f4fb6 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/enums/ReservationOrderKeyType.java @@ -0,0 +1,10 @@ +package cn.nianxx.thhotel.workflows.reservation.common.enums; + +/** + * 订单业务号类型。用于区分 Group Code、Confirmation Number 和临时订单号。 + */ +public enum ReservationOrderKeyType { + GROUP_CODE, + CONFIRMATION_NUMBER, + TEMPORARY +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/enums/ReservationOrderStatus.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/enums/ReservationOrderStatus.java new file mode 100644 index 0000000..ea0d045 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/enums/ReservationOrderStatus.java @@ -0,0 +1,11 @@ +package cn.nianxx.thhotel.workflows.reservation.common.enums; + +/** + * 订单状态第一版枚举。状态代码用于数据库和接口,不使用显示文案做判断。 + */ +public enum ReservationOrderStatus { + TEMPORARY, + ACTIVE, + ENDED, + LOGIC_DELETED +} 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 new file mode 100644 index 0000000..2a328f3 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/enums/ReservationSystemTaskType.java @@ -0,0 +1,12 @@ +package cn.nianxx.thhotel.workflows.reservation.common.enums; + +/** + * 系统主任务类型。AI 原始 task_type 会保留,但内部队列和状态机使用该枚举。 + */ +public enum ReservationSystemTaskType { + NEW_BOOKING, + UPDATE_BOOKING, + CANCEL_BOOKING, + MANUAL_REVIEW, + INFORMATIONAL_MESSAGE +} 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 new file mode 100644 index 0000000..758b2ce --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/enums/ReservationTaskCardType.java @@ -0,0 +1,17 @@ +package cn.nianxx.thhotel.workflows.reservation.common.enums; + +/** + * 任务卡类型。第一版字段配置写在代码 Provider 中,后续可迁移到数据库配置。 + */ +public enum ReservationTaskCardType { + NEW_BOOKING, + UPDATE_BOOKING, + CANCEL_BOOKING, + VOUCHER_RECEIVED, + ROOMING_LIST, + AMEND_GROUP_CODE, + TRACE_RESERVATION_NOTES, + TA_RECORDER, + MESSAGE_NOTIFICATION, + FALLBACK_REVIEW +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/enums/ReservationTaskStatus.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/enums/ReservationTaskStatus.java new file mode 100644 index 0000000..a781fd2 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/enums/ReservationTaskStatus.java @@ -0,0 +1,12 @@ +package cn.nianxx.thhotel.workflows.reservation.common.enums; + +/** + * 任务状态第一版枚举。BLOCKED 不落库,由同订单前置任务实时计算。 + */ +public enum ReservationTaskStatus { + PENDING_CONFIRM, + READY, + EXECUTING, + FAILED, + COMPLETED +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/request/ManualReviewConversionRequest.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/request/ManualReviewConversionRequest.java new file mode 100644 index 0000000..b44cbf2 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/request/ManualReviewConversionRequest.java @@ -0,0 +1,15 @@ +package cn.nianxx.thhotel.workflows.reservation.common.request; + +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Fallback / manual_review 人工转换请求。UPDATE / CANCEL 第一版必须指定目标订单。 + */ +public record ManualReviewConversionRequest( + @JsonProperty("target_task_type") + String targetTaskType, + @JsonProperty("target_order_id") + String targetOrderId, + String reason +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ManualReviewConversionResult.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ManualReviewConversionResult.java new file mode 100644 index 0000000..e1f6667 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ManualReviewConversionResult.java @@ -0,0 +1,24 @@ +package cn.nianxx.thhotel.workflows.reservation.common.result; + +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Fallback / manual_review 人工转换结果。ID 以字符串返回,避免前端 Long 精度问题。 + */ +public record ManualReviewConversionResult( + @JsonProperty("task_id") + String taskId, + @JsonProperty("order_id") + String orderId, + @JsonProperty("original_order_id") + String originalOrderId, + @JsonProperty("system_task_type") + String systemTaskType, + @JsonProperty("task_card_type") + String taskCardType, + @JsonProperty("task_status") + String taskStatus, + @JsonProperty("original_order_logic_deleted") + boolean originalOrderLogicDeleted +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationTaskAvailabilityResult.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationTaskAvailabilityResult.java new file mode 100644 index 0000000..0de50f1 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationTaskAvailabilityResult.java @@ -0,0 +1,20 @@ +package cn.nianxx.thhotel.workflows.reservation.common.result; + +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * 任务实时可处理状态。BLOCKED 不落库,由同订单前置任务实时计算。 + */ +public record ReservationTaskAvailabilityResult( + boolean blocked, + @JsonProperty("read_only") + boolean readOnly, + boolean editable, + boolean confirmable, + boolean executable, + @JsonProperty("blocked_by_task_id") + String blockedByTaskId, + @JsonProperty("blocked_reason") + String blockedReason +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationTaskDetailResult.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationTaskDetailResult.java new file mode 100644 index 0000000..8e47126 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationTaskDetailResult.java @@ -0,0 +1,27 @@ +package cn.nianxx.thhotel.workflows.reservation.common.result; + +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.List; + +/** + * 任务详情结果。第一版提供队列可处理状态和按矩阵生成的字段列表。 + */ +public record ReservationTaskDetailResult( + @JsonProperty("task_id") + String taskId, + @JsonProperty("order_id") + String orderId, + @JsonProperty("source_message_id") + String sourceMessageId, + @JsonProperty("system_task_type") + String systemTaskType, + @JsonProperty("task_card_type") + String taskCardType, + @JsonProperty("task_status") + String taskStatus, + @JsonProperty("field_contract_version") + String fieldContractVersion, + ReservationTaskAvailabilityResult availability, + List fields +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationTaskFieldResult.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationTaskFieldResult.java new file mode 100644 index 0000000..0c80a44 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationTaskFieldResult.java @@ -0,0 +1,50 @@ +package cn.nianxx.thhotel.workflows.reservation.common.result; + +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * 任务详情字段结果。字段规则来自矩阵,value 为当前可回显值。 + */ +public record ReservationTaskFieldResult( + @JsonProperty("row_number") + Integer rowNumber, + @JsonProperty("card_name") + String cardName, + @JsonProperty("display_area") + String displayArea, + @JsonProperty("field_path") + String fieldPath, + @JsonProperty("display_name") + String displayName, + String visible, + String editable, + @JsonProperty("input_editable") + String inputEditable, + @JsonProperty("select_editable") + String selectEditable, + @JsonProperty("date_picker") + String datePicker, + @JsonProperty("number_input") + String numberInput, + @JsonProperty("file_display") + String fileDisplay, + @JsonProperty("table_editable") + String tableEditable, + @JsonProperty("enum_options") + String enumOptions, + @JsonProperty("required_rule") + String requiredRule, + @JsonProperty("display_condition") + String displayCondition, + @JsonProperty("validation_rule") + String validationRule, + @JsonProperty("write_path") + String writePath, + @JsonProperty("opera_write_participation") + String operaWriteParticipation, + @JsonProperty("opera_parameter_mapping") + String operaParameterMapping, + String notes, + Object value +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationWorkflowErrorResponse.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationWorkflowErrorResponse.java new file mode 100644 index 0000000..24ff57b --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationWorkflowErrorResponse.java @@ -0,0 +1,15 @@ +package cn.nianxx.thhotel.workflows.reservation.common.result; + +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.List; + +/** + * Reservation 工作流错误响应。错误内容不回显原始 AI payload、邮件正文或敏感字段。 + */ +public record ReservationWorkflowErrorResponse( + @JsonProperty("error_code") + String errorCode, + String message, + List details +) { +} 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 new file mode 100644 index 0000000..6f173d7 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/SuperAgentTaskResultItemResponse.java @@ -0,0 +1,30 @@ +package cn.nianxx.thhotel.workflows.reservation.common.result; + +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * SuperAgent 任务结果入站单个 item 响应。ID 以字符串返回,避免前端 Long 精度丢失。 + */ +public record SuperAgentTaskResultItemResponse( + @JsonProperty("source_event_index") + Integer sourceEventIndex, + @JsonProperty("array_index") + Integer arrayIndex, + @JsonProperty("ai_transition_id") + String aiTransitionId, + @JsonProperty("order_id") + String orderId, + @JsonProperty("task_id") + String taskId, + @JsonProperty("system_task_type") + String systemTaskType, + @JsonProperty("task_card_type") + String taskCardType, + @JsonProperty("task_status") + String taskStatus, + @JsonProperty("order_status") + String orderStatus, + @JsonProperty("execution_order") + Integer executionOrder +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/SuperAgentTaskResultResponse.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/SuperAgentTaskResultResponse.java new file mode 100644 index 0000000..4aecb23 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/SuperAgentTaskResultResponse.java @@ -0,0 +1,23 @@ +package cn.nianxx.thhotel.workflows.reservation.common.result; + +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.List; + +/** + * SuperAgent 任务结果入站响应。创建成功和幂等重放都使用该结构。 + */ +public record SuperAgentTaskResultResponse( + @JsonProperty("request_id") + String requestId, + @JsonProperty("source_message_id") + String sourceMessageId, + @JsonProperty("batch_id") + String batchId, + @JsonProperty("idempotent_replay") + boolean idempotentReplay, + @JsonProperty("accepted_count") + int acceptedCount, + List items, + List warnings +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/SuperAgentTaskResultWarning.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/SuperAgentTaskResultWarning.java new file mode 100644 index 0000000..68b187a --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/SuperAgentTaskResultWarning.java @@ -0,0 +1,10 @@ +package cn.nianxx.thhotel.workflows.reservation.common.result; + +/** + * SuperAgent 任务结果入站警告。用于表达幂等重放等非失败信息。 + */ +public record SuperAgentTaskResultWarning( + String code, + String message +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationTaskController.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationTaskController.java new file mode 100644 index 0000000..efc748c --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationTaskController.java @@ -0,0 +1,51 @@ +package cn.nianxx.thhotel.workflows.reservation.control; + +import cn.nianxx.thhotel.workflows.reservation.common.request.ManualReviewConversionRequest; +import cn.nianxx.thhotel.workflows.reservation.common.result.ManualReviewConversionResult; +import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationTaskDetailResult; +import cn.nianxx.thhotel.workflows.reservation.service.ReservationTaskWorkflowService; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * Reservation 任务 Controller。提供任务详情和人工复核转换接口。 + */ +@RestController +@RequestMapping("/api/reservation/tasks") +public class ReservationTaskController { + + private final ReservationTaskWorkflowService taskWorkflowService; + + /** + * 注入任务工作流服务,Controller 不直接访问 Mapper 或 Repository。 + */ + public ReservationTaskController(ReservationTaskWorkflowService taskWorkflowService) { + this.taskWorkflowService = taskWorkflowService; + } + + /** + * 查询任务详情,返回字段矩阵、当前值和实时可处理状态。 + */ + @GetMapping(value = "/{taskId}", produces = MediaType.APPLICATION_JSON_VALUE) + public ReservationTaskDetailResult detail(@PathVariable Long taskId) { + return taskWorkflowService.getTaskDetail(taskId); + } + + /** + * 将 Fallback / manual_review 任务转换为 New、Update 或 Cancel。 + */ + @PostMapping( + value = "/{taskId}/manual-review-conversions", + consumes = MediaType.APPLICATION_JSON_VALUE, + produces = MediaType.APPLICATION_JSON_VALUE) + public ManualReviewConversionResult convertManualReview( + @PathVariable Long taskId, + @RequestBody(required = false) ManualReviewConversionRequest request) { + return taskWorkflowService.convertManualReviewTask(taskId, request); + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationTaskControllerAdvice.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationTaskControllerAdvice.java new file mode 100644 index 0000000..0d72443 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationTaskControllerAdvice.java @@ -0,0 +1,25 @@ +package cn.nianxx.thhotel.workflows.reservation.control; + +import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationWorkflowErrorResponse; +import cn.nianxx.thhotel.workflows.reservation.service.impl.ReservationTaskWorkflowException; +import java.util.List; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +/** + * Reservation 任务接口统一异常处理。错误响应不暴露内部堆栈或原始 AI payload。 + */ +@RestControllerAdvice(assignableTypes = ReservationTaskController.class) +public class ReservationTaskControllerAdvice { + + /** + * 处理任务详情和人工转换阶段的受控业务异常。 + */ + @ExceptionHandler(ReservationTaskWorkflowException.class) + public ResponseEntity handleTaskWorkflowException( + ReservationTaskWorkflowException exception) { + return ResponseEntity.status(exception.getStatus()) + .body(new ReservationWorkflowErrorResponse(exception.getErrorCode(), exception.getMessage(), List.of())); + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/domain/ReservationAiBatchEntity.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/domain/ReservationAiBatchEntity.java new file mode 100644 index 0000000..95bf2e8 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/domain/ReservationAiBatchEntity.java @@ -0,0 +1,135 @@ +package cn.nianxx.thhotel.workflows.reservation.domain; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import java.time.LocalDateTime; + +/** + * Reservation AI 接收批次实体。保存一次 SuperAgent 入站请求的幂等和追溯信息。 + */ +@TableName("workflow_reservation_ai_batch") +public class ReservationAiBatchEntity { + + /** AI 任务结果接收批次 ID。 */ + @TableId(type = IdType.ASSIGN_ID) + private Long id; + /** 酒店或业务上下文 ID。 */ + private String hotelId; + /** 关联 SourceMessage Inbox ID。 */ + private Long sourceMessageId; + /** 原始请求体 SHA-256,用于幂等和排查。 */ + private String requestPayloadSha256; + /** 系统生成的批次幂等键。 */ + private String batchIdempotencyKey; + /** SuperAgent 调用方客户端 ID。 */ + private String clientId; + /** 调用方请求 ID,用于日志串联。 */ + private String requestId; + /** 本系统接收 UTC 时间。 */ + private LocalDateTime receivedAt; + /** 本批次 AI task result 数量。 */ + private Integer itemCount; + /** AI 抽取警告 JSON。 */ + private String extractionWarningsJson; + /** 记录创建 UTC 时间。 */ + private LocalDateTime createdAt; + /** 记录更新 UTC 时间。 */ + private LocalDateTime updatedAt; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getHotelId() { + return hotelId; + } + + public void setHotelId(String hotelId) { + this.hotelId = hotelId; + } + + public Long getSourceMessageId() { + return sourceMessageId; + } + + public void setSourceMessageId(Long sourceMessageId) { + this.sourceMessageId = sourceMessageId; + } + + public String getRequestPayloadSha256() { + return requestPayloadSha256; + } + + public void setRequestPayloadSha256(String requestPayloadSha256) { + this.requestPayloadSha256 = requestPayloadSha256; + } + + public String getBatchIdempotencyKey() { + return batchIdempotencyKey; + } + + public void setBatchIdempotencyKey(String batchIdempotencyKey) { + this.batchIdempotencyKey = batchIdempotencyKey; + } + + public String getClientId() { + return clientId; + } + + public void setClientId(String clientId) { + this.clientId = clientId; + } + + public String getRequestId() { + return requestId; + } + + public void setRequestId(String requestId) { + this.requestId = requestId; + } + + public LocalDateTime getReceivedAt() { + return receivedAt; + } + + public void setReceivedAt(LocalDateTime receivedAt) { + this.receivedAt = receivedAt; + } + + public Integer getItemCount() { + return itemCount; + } + + public void setItemCount(Integer itemCount) { + this.itemCount = itemCount; + } + + public String getExtractionWarningsJson() { + return extractionWarningsJson; + } + + public void setExtractionWarningsJson(String extractionWarningsJson) { + this.extractionWarningsJson = extractionWarningsJson; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(LocalDateTime createdAt) { + this.createdAt = createdAt; + } + + public LocalDateTime getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(LocalDateTime updatedAt) { + this.updatedAt = updatedAt; + } +} 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 new file mode 100644 index 0000000..796ca69 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/domain/ReservationAiTransitionEntity.java @@ -0,0 +1,144 @@ +package cn.nianxx.thhotel.workflows.reservation.domain; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import java.time.LocalDateTime; + +/** + * Reservation AI 过渡实体。保存单条 AI item 原始 JSON、映射类型和任务创建冗余字段。 + */ +@TableName("workflow_reservation_ai_transition") +public class ReservationAiTransitionEntity { + + /** AI 过渡记录 ID。 */ + @TableId(type = IdType.ASSIGN_ID) + private Long id; + /** 酒店或业务上下文 ID。 */ + private String hotelId; + /** AI 任务结果接收批次 ID。 */ + private Long batchId; + /** 关联 SourceMessage Inbox ID。 */ + private Long sourceMessageId; + /** AI current 事件序号。 */ + private Integer sourceEventIndex; + /** AI 返回列表中的顺序,从 1 开始。 */ + private Integer arrayIndex; + /** 映射到订单任务队列的初始顺序。 */ + private Integer executionOrder; + /** Skill 目录代码。 */ + private String catalogCode; + /** Skill 标识。 */ + private String skillId; + /** AI 结果类型。 */ + private String resultType; + /** AI 原始任务类型。 */ + private String aiTaskType; + /** 系统主任务类型。 */ + private String systemTaskType; + /** 任务卡类型。 */ + private String taskCardType; + /** 业务动作 subtype。 */ + private String taskSubtype; + /** 当前或历史标识。 */ + private String currentOrHistory; + /** Group Code 候选。 */ + private String groupCode; + /** Confirmation Number 候选。 */ + private String confirmationNumber; + /** 单个 AI item JSON SHA-256。 */ + private String itemPayloadSha256; + /** 系统生成 item 幂等键。 */ + private String itemIdempotencyKey; + /** manual_review 原因码。 */ + private String manualReasonCode; + /** 父任务事件序号。 */ + private Integer parentSourceEventIndex; + /** 联动任务组 ID。 */ + private String linkedTaskGroupId; + /** 是否等待父任务完成。 */ + private Boolean blockedUntilParentCompleted; + /** AI 原始 item JSON,禁止被用户覆盖。 */ + private String aiPayloadJson; + /** 订单候选键 JSON。 */ + private String caseKeysJson; + /** AI 抽取业务字段 JSON。 */ + private String extractedFieldsJson; + /** 人工复核 JSON。 */ + private String manualReviewJson; + /** 信息提醒 JSON。 */ + private String informationalMessageJson; + /** 附件和文件引用 JSON。 */ + private String attachmentsJson; + /** AI 使用上下文 JSON。 */ + private String contextUsedJson; + /** 记录创建 UTC 时间。 */ + private LocalDateTime createdAt; + /** 记录更新 UTC 时间。 */ + private LocalDateTime updatedAt; + + public Long getId() { return id; } + public void setId(Long id) { this.id = id; } + public String getHotelId() { return hotelId; } + public void setHotelId(String hotelId) { this.hotelId = hotelId; } + public Long getBatchId() { return batchId; } + public void setBatchId(Long batchId) { this.batchId = batchId; } + public Long getSourceMessageId() { return sourceMessageId; } + public void setSourceMessageId(Long sourceMessageId) { this.sourceMessageId = sourceMessageId; } + public Integer getSourceEventIndex() { return sourceEventIndex; } + public void setSourceEventIndex(Integer sourceEventIndex) { this.sourceEventIndex = sourceEventIndex; } + public Integer getArrayIndex() { return arrayIndex; } + public void setArrayIndex(Integer arrayIndex) { this.arrayIndex = arrayIndex; } + public Integer getExecutionOrder() { return executionOrder; } + public void setExecutionOrder(Integer executionOrder) { this.executionOrder = executionOrder; } + public String getCatalogCode() { return catalogCode; } + public void setCatalogCode(String catalogCode) { this.catalogCode = catalogCode; } + public String getSkillId() { return skillId; } + public void setSkillId(String skillId) { this.skillId = skillId; } + public String getResultType() { return resultType; } + public void setResultType(String resultType) { this.resultType = resultType; } + public String getAiTaskType() { return aiTaskType; } + public void setAiTaskType(String aiTaskType) { this.aiTaskType = aiTaskType; } + public String getSystemTaskType() { return systemTaskType; } + public void setSystemTaskType(String systemTaskType) { this.systemTaskType = systemTaskType; } + public String getTaskCardType() { return taskCardType; } + public void setTaskCardType(String taskCardType) { this.taskCardType = taskCardType; } + public String getTaskSubtype() { return taskSubtype; } + public void setTaskSubtype(String taskSubtype) { this.taskSubtype = taskSubtype; } + public String getCurrentOrHistory() { return currentOrHistory; } + public void setCurrentOrHistory(String currentOrHistory) { this.currentOrHistory = currentOrHistory; } + public String getGroupCode() { return groupCode; } + public void setGroupCode(String groupCode) { this.groupCode = groupCode; } + public String getConfirmationNumber() { return confirmationNumber; } + public void setConfirmationNumber(String confirmationNumber) { this.confirmationNumber = confirmationNumber; } + public String getItemPayloadSha256() { return itemPayloadSha256; } + public void setItemPayloadSha256(String itemPayloadSha256) { this.itemPayloadSha256 = itemPayloadSha256; } + public String getItemIdempotencyKey() { return itemIdempotencyKey; } + public void setItemIdempotencyKey(String itemIdempotencyKey) { this.itemIdempotencyKey = itemIdempotencyKey; } + public String getManualReasonCode() { return manualReasonCode; } + public void setManualReasonCode(String manualReasonCode) { this.manualReasonCode = manualReasonCode; } + public Integer getParentSourceEventIndex() { return parentSourceEventIndex; } + public void setParentSourceEventIndex(Integer parentSourceEventIndex) { this.parentSourceEventIndex = parentSourceEventIndex; } + public String getLinkedTaskGroupId() { return linkedTaskGroupId; } + public void setLinkedTaskGroupId(String linkedTaskGroupId) { this.linkedTaskGroupId = linkedTaskGroupId; } + public Boolean getBlockedUntilParentCompleted() { return blockedUntilParentCompleted; } + public void setBlockedUntilParentCompleted(Boolean blockedUntilParentCompleted) { this.blockedUntilParentCompleted = blockedUntilParentCompleted; } + public String getAiPayloadJson() { return aiPayloadJson; } + public void setAiPayloadJson(String aiPayloadJson) { this.aiPayloadJson = aiPayloadJson; } + public String getCaseKeysJson() { return caseKeysJson; } + public void setCaseKeysJson(String caseKeysJson) { this.caseKeysJson = caseKeysJson; } + public String getExtractedFieldsJson() { return extractedFieldsJson; } + public void setExtractedFieldsJson(String extractedFieldsJson) { this.extractedFieldsJson = extractedFieldsJson; } + public String getManualReviewJson() { return manualReviewJson; } + public void setManualReviewJson(String manualReviewJson) { this.manualReviewJson = manualReviewJson; } + public String getInformationalMessageJson() { return informationalMessageJson; } + public void setInformationalMessageJson(String informationalMessageJson) { this.informationalMessageJson = informationalMessageJson; } + public String getAttachmentsJson() { return attachmentsJson; } + public void setAttachmentsJson(String attachmentsJson) { this.attachmentsJson = attachmentsJson; } + public String getContextUsedJson() { return contextUsedJson; } + public void setContextUsedJson(String contextUsedJson) { this.contextUsedJson = contextUsedJson; } + public LocalDateTime getCreatedAt() { return createdAt; } + public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } + public LocalDateTime getUpdatedAt() { return updatedAt; } + public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/domain/ReservationAuditLogEntity.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/domain/ReservationAuditLogEntity.java new file mode 100644 index 0000000..7f8fa36 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/domain/ReservationAuditLogEntity.java @@ -0,0 +1,68 @@ +package cn.nianxx.thhotel.workflows.reservation.domain; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import java.time.LocalDateTime; + +/** + * Reservation 审计实体。记录人工转换、订单迁移和后续 OPERA 模拟相关动作。 + */ +@TableName("workflow_reservation_audit_log") +public class ReservationAuditLogEntity { + + /** 审计 ID。 */ + @TableId(type = IdType.ASSIGN_ID) + private Long id; + /** 酒店或业务上下文 ID。 */ + private String hotelId; + /** 关联订单 ID,可为空。 */ + private Long orderId; + /** 关联任务 ID,可为空。 */ + private Long taskId; + /** OPERA 模拟操作 ID,当前阶段为空。 */ + private Long operationId; + /** 操作人类型,例如 SYSTEM、USER、SUPERAGENT。 */ + private String actorType; + /** 操作人标识。 */ + private String actorId; + /** 操作类型,例如 MANUAL_REVIEW_CONVERT。 */ + private String action; + /** 操作原因,第一版允许为空。 */ + private String reason; + /** 变更前摘要 JSON,不保存 Secret 或完整原文。 */ + private String beforeSnapshotJson; + /** 变更后摘要 JSON,不保存 Secret 或完整原文。 */ + private String afterSnapshotJson; + /** 业务动作发生 UTC 时间。 */ + private LocalDateTime occurredAt; + /** 审计记录创建 UTC 时间。 */ + private LocalDateTime createdAt; + + public Long getId() { return id; } + public void setId(Long id) { this.id = id; } + public String getHotelId() { return hotelId; } + public void setHotelId(String hotelId) { this.hotelId = hotelId; } + public Long getOrderId() { return orderId; } + public void setOrderId(Long orderId) { this.orderId = orderId; } + public Long getTaskId() { return taskId; } + public void setTaskId(Long taskId) { this.taskId = taskId; } + public Long getOperationId() { return operationId; } + public void setOperationId(Long operationId) { this.operationId = operationId; } + public String getActorType() { return actorType; } + public void setActorType(String actorType) { this.actorType = actorType; } + public String getActorId() { return actorId; } + public void setActorId(String actorId) { this.actorId = actorId; } + public String getAction() { return action; } + public void setAction(String action) { this.action = action; } + public String getReason() { return reason; } + public void setReason(String reason) { this.reason = reason; } + public String getBeforeSnapshotJson() { return beforeSnapshotJson; } + public void setBeforeSnapshotJson(String beforeSnapshotJson) { this.beforeSnapshotJson = beforeSnapshotJson; } + public String getAfterSnapshotJson() { return afterSnapshotJson; } + public void setAfterSnapshotJson(String afterSnapshotJson) { this.afterSnapshotJson = afterSnapshotJson; } + public LocalDateTime getOccurredAt() { return occurredAt; } + public void setOccurredAt(LocalDateTime occurredAt) { this.occurredAt = occurredAt; } + public LocalDateTime getCreatedAt() { return createdAt; } + public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/domain/ReservationOrderEntity.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/domain/ReservationOrderEntity.java new file mode 100644 index 0000000..62b9546 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/domain/ReservationOrderEntity.java @@ -0,0 +1,88 @@ +package cn.nianxx.thhotel.workflows.reservation.domain; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import java.time.LocalDateTime; + +/** + * Reservation 订单实体。承载业务订单号、临时订单和任务队列归属。 + */ +@TableName("workflow_reservation_order") +public class ReservationOrderEntity { + + /** 系统内部订单 ID。 */ + @TableId(type = IdType.ASSIGN_ID) + private Long id; + /** 酒店或业务上下文 ID。 */ + private String hotelId; + /** 业务号类型。 */ + private String orderKeyType; + /** 真实业务号,例如 Group Code 或 Confirmation Number。 */ + private String orderBusinessKey; + /** ACTIVE 业务订单唯一约束辅助键,非 ACTIVE 或临时订单为空。 */ + private String activeBusinessKey; + /** 临时订单展示编号。 */ + private String temporaryOrderCode; + /** 订单状态。 */ + private String orderStatus; + /** 业务号来源,例如 AI 候选、用户确认或 OPERA 模拟回填。 */ + private String businessKeySource; + /** New Booking 成功后回填真实业务号的 UTC 时间。 */ + private LocalDateTime businessKeyBackfilledAt; + /** 前端展示名称。 */ + private String displayName; + /** 首次创建该订单的来源消息 ID。 */ + private Long sourceMessageId; + /** 首次创建该订单的任务 ID,CP1-3 允许为空。 */ + private Long createdFromTaskId; + /** 订单进入 ENDED 状态的 UTC 时间。 */ + private LocalDateTime endedAt; + /** 逻辑删除 UTC 时间。 */ + private LocalDateTime logicDeletedAt; + /** 逻辑删除原因。 */ + private String logicDeletedReason; + /** 乐观锁版本。 */ + private Long version; + /** 记录创建 UTC 时间。 */ + private LocalDateTime createdAt; + /** 记录更新 UTC 时间。 */ + private LocalDateTime updatedAt; + + public Long getId() { return id; } + public void setId(Long id) { this.id = id; } + public String getHotelId() { return hotelId; } + public void setHotelId(String hotelId) { this.hotelId = hotelId; } + public String getOrderKeyType() { return orderKeyType; } + public void setOrderKeyType(String orderKeyType) { this.orderKeyType = orderKeyType; } + public String getOrderBusinessKey() { return orderBusinessKey; } + public void setOrderBusinessKey(String orderBusinessKey) { this.orderBusinessKey = orderBusinessKey; } + public String getActiveBusinessKey() { return activeBusinessKey; } + public void setActiveBusinessKey(String activeBusinessKey) { this.activeBusinessKey = activeBusinessKey; } + public String getTemporaryOrderCode() { return temporaryOrderCode; } + public void setTemporaryOrderCode(String temporaryOrderCode) { this.temporaryOrderCode = temporaryOrderCode; } + public String getOrderStatus() { return orderStatus; } + public void setOrderStatus(String orderStatus) { this.orderStatus = orderStatus; } + public String getBusinessKeySource() { return businessKeySource; } + public void setBusinessKeySource(String businessKeySource) { this.businessKeySource = businessKeySource; } + public LocalDateTime getBusinessKeyBackfilledAt() { return businessKeyBackfilledAt; } + public void setBusinessKeyBackfilledAt(LocalDateTime businessKeyBackfilledAt) { this.businessKeyBackfilledAt = businessKeyBackfilledAt; } + public String getDisplayName() { return displayName; } + public void setDisplayName(String displayName) { this.displayName = displayName; } + public Long getSourceMessageId() { return sourceMessageId; } + public void setSourceMessageId(Long sourceMessageId) { this.sourceMessageId = sourceMessageId; } + public Long getCreatedFromTaskId() { return createdFromTaskId; } + public void setCreatedFromTaskId(Long createdFromTaskId) { this.createdFromTaskId = createdFromTaskId; } + public LocalDateTime getEndedAt() { return endedAt; } + public void setEndedAt(LocalDateTime endedAt) { this.endedAt = endedAt; } + public LocalDateTime getLogicDeletedAt() { return logicDeletedAt; } + public void setLogicDeletedAt(LocalDateTime logicDeletedAt) { this.logicDeletedAt = logicDeletedAt; } + public String getLogicDeletedReason() { return logicDeletedReason; } + public void setLogicDeletedReason(String logicDeletedReason) { this.logicDeletedReason = logicDeletedReason; } + public Long getVersion() { return version; } + public void setVersion(Long version) { this.version = version; } + public LocalDateTime getCreatedAt() { return createdAt; } + public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } + public LocalDateTime getUpdatedAt() { return updatedAt; } + public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/domain/ReservationTaskCardEntity.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/domain/ReservationTaskCardEntity.java new file mode 100644 index 0000000..022a3f7 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/domain/ReservationTaskCardEntity.java @@ -0,0 +1,48 @@ +package cn.nianxx.thhotel.workflows.reservation.domain; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import java.time.LocalDateTime; + +/** + * Reservation 任务卡实体。CP1-3 只保存 AI 原始快照,不承载用户编辑确认 payload。 + */ +@TableName("workflow_reservation_task_card") +public class ReservationTaskCardEntity { + + /** 任务卡 ID。 */ + @TableId(type = IdType.ASSIGN_ID) + private Long id; + /** 酒店或业务上下文 ID。 */ + private String hotelId; + /** 所属任务 ID。 */ + private Long taskId; + /** 任务卡类型。 */ + private String taskCardType; + /** 字段契约版本,第一版使用 code-v1。 */ + private String fieldContractVersion; + /** 任务卡使用的 AI 原始 JSON 快照。 */ + private String aiPayloadJson; + /** 记录创建 UTC 时间。 */ + private LocalDateTime createdAt; + /** 记录更新 UTC 时间。 */ + private LocalDateTime updatedAt; + + public Long getId() { return id; } + public void setId(Long id) { this.id = id; } + public String getHotelId() { return hotelId; } + public void setHotelId(String hotelId) { this.hotelId = hotelId; } + public Long getTaskId() { return taskId; } + public void setTaskId(Long taskId) { this.taskId = taskId; } + public String getTaskCardType() { return taskCardType; } + public void setTaskCardType(String taskCardType) { this.taskCardType = taskCardType; } + public String getFieldContractVersion() { return fieldContractVersion; } + public void setFieldContractVersion(String fieldContractVersion) { this.fieldContractVersion = fieldContractVersion; } + public String getAiPayloadJson() { return aiPayloadJson; } + public void setAiPayloadJson(String aiPayloadJson) { this.aiPayloadJson = aiPayloadJson; } + public LocalDateTime getCreatedAt() { return createdAt; } + public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } + public LocalDateTime getUpdatedAt() { return updatedAt; } + public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/domain/ReservationTaskEntity.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/domain/ReservationTaskEntity.java new file mode 100644 index 0000000..d2f1de9 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/domain/ReservationTaskEntity.java @@ -0,0 +1,104 @@ +package cn.nianxx.thhotel.workflows.reservation.domain; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import java.time.LocalDateTime; + +/** + * Reservation 任务实体。保存 AI 结果映射后的任务类型、任务卡类型和订单队列顺序。 + */ +@TableName("workflow_reservation_task") +public class ReservationTaskEntity { + + /** 系统任务 ID。 */ + @TableId(type = IdType.ASSIGN_ID) + private Long id; + /** 酒店或业务上下文 ID。 */ + private String hotelId; + /** 当前挂靠订单 ID。 */ + private Long orderId; + /** 来源消息 ID。 */ + private Long sourceMessageId; + /** AI 过渡记录 ID。 */ + private Long aiTransitionId; + /** AI 结果类型。 */ + private String resultType; + /** AI 原始任务类型。 */ + private String aiTaskType; + /** 系统主任务类型。 */ + private String systemTaskType; + /** 任务卡类型。 */ + private String taskCardType; + /** 业务动作 subtype。 */ + private String taskSubtype; + /** 任务状态。 */ + private String taskStatus; + /** 是否参与订单执行队列。 */ + private Boolean queueParticipation; + /** 同订单执行顺序。 */ + private Integer executionOrder; + /** 父任务 ID。 */ + private Long parentTaskId; + /** 父任务 source event index。 */ + private Integer parentSourceEventIndex; + /** 联动任务组 ID。 */ + private String linkedTaskGroupId; + /** 是否等待父任务完成。 */ + private Boolean blockedUntilParentCompleted; + /** 最近失败原因摘要。 */ + private String lastFailureReason; + /** 任务完成 UTC 时间。 */ + private LocalDateTime completedAt; + /** 乐观锁版本。 */ + private Long version; + /** 记录创建 UTC 时间。 */ + private LocalDateTime createdAt; + /** 记录更新 UTC 时间。 */ + private LocalDateTime updatedAt; + + public Long getId() { return id; } + public void setId(Long id) { this.id = id; } + public String getHotelId() { return hotelId; } + public void setHotelId(String hotelId) { this.hotelId = hotelId; } + public Long getOrderId() { return orderId; } + public void setOrderId(Long orderId) { this.orderId = orderId; } + public Long getSourceMessageId() { return sourceMessageId; } + public void setSourceMessageId(Long sourceMessageId) { this.sourceMessageId = sourceMessageId; } + public Long getAiTransitionId() { return aiTransitionId; } + public void setAiTransitionId(Long aiTransitionId) { this.aiTransitionId = aiTransitionId; } + public String getResultType() { return resultType; } + public void setResultType(String resultType) { this.resultType = resultType; } + public String getAiTaskType() { return aiTaskType; } + public void setAiTaskType(String aiTaskType) { this.aiTaskType = aiTaskType; } + public String getSystemTaskType() { return systemTaskType; } + public void setSystemTaskType(String systemTaskType) { this.systemTaskType = systemTaskType; } + public String getTaskCardType() { return taskCardType; } + public void setTaskCardType(String taskCardType) { this.taskCardType = taskCardType; } + public String getTaskSubtype() { return taskSubtype; } + public void setTaskSubtype(String taskSubtype) { this.taskSubtype = taskSubtype; } + public String getTaskStatus() { return taskStatus; } + public void setTaskStatus(String taskStatus) { this.taskStatus = taskStatus; } + public Boolean getQueueParticipation() { return queueParticipation; } + public void setQueueParticipation(Boolean queueParticipation) { this.queueParticipation = queueParticipation; } + public Integer getExecutionOrder() { return executionOrder; } + public void setExecutionOrder(Integer executionOrder) { this.executionOrder = executionOrder; } + public Long getParentTaskId() { return parentTaskId; } + public void setParentTaskId(Long parentTaskId) { this.parentTaskId = parentTaskId; } + public Integer getParentSourceEventIndex() { return parentSourceEventIndex; } + public void setParentSourceEventIndex(Integer parentSourceEventIndex) { this.parentSourceEventIndex = parentSourceEventIndex; } + public String getLinkedTaskGroupId() { return linkedTaskGroupId; } + public void setLinkedTaskGroupId(String linkedTaskGroupId) { this.linkedTaskGroupId = linkedTaskGroupId; } + public Boolean getBlockedUntilParentCompleted() { return blockedUntilParentCompleted; } + public void setBlockedUntilParentCompleted(Boolean blockedUntilParentCompleted) { this.blockedUntilParentCompleted = blockedUntilParentCompleted; } + public String getLastFailureReason() { return lastFailureReason; } + public void setLastFailureReason(String lastFailureReason) { this.lastFailureReason = lastFailureReason; } + public LocalDateTime getCompletedAt() { return completedAt; } + public void setCompletedAt(LocalDateTime completedAt) { this.completedAt = completedAt; } + public Long getVersion() { return version; } + public void setVersion(Long version) { this.version = version; } + public LocalDateTime getCreatedAt() { return createdAt; } + public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } + public LocalDateTime getUpdatedAt() { return updatedAt; } + public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/mapper/ReservationAiBatchMapper.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/mapper/ReservationAiBatchMapper.java new file mode 100644 index 0000000..4869f4c --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/mapper/ReservationAiBatchMapper.java @@ -0,0 +1,12 @@ +package cn.nianxx.thhotel.workflows.reservation.mapper; + +import cn.nianxx.thhotel.workflows.reservation.domain.ReservationAiBatchEntity; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; + +/** + * Reservation AI 接收批次 Mapper,只负责本表持久化访问。 + */ +@Mapper +public interface ReservationAiBatchMapper extends BaseMapper { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/mapper/ReservationAiTransitionMapper.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/mapper/ReservationAiTransitionMapper.java new file mode 100644 index 0000000..04bb265 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/mapper/ReservationAiTransitionMapper.java @@ -0,0 +1,12 @@ +package cn.nianxx.thhotel.workflows.reservation.mapper; + +import cn.nianxx.thhotel.workflows.reservation.domain.ReservationAiTransitionEntity; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; + +/** + * Reservation AI 过渡表 Mapper,只负责本表持久化访问。 + */ +@Mapper +public interface ReservationAiTransitionMapper extends BaseMapper { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/mapper/ReservationAuditLogMapper.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/mapper/ReservationAuditLogMapper.java new file mode 100644 index 0000000..3ec626e --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/mapper/ReservationAuditLogMapper.java @@ -0,0 +1,12 @@ +package cn.nianxx.thhotel.workflows.reservation.mapper; + +import cn.nianxx.thhotel.workflows.reservation.domain.ReservationAuditLogEntity; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; + +/** + * Reservation 审计 Mapper,只负责审计表持久化访问。 + */ +@Mapper +public interface ReservationAuditLogMapper extends BaseMapper { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/mapper/ReservationOrderMapper.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/mapper/ReservationOrderMapper.java new file mode 100644 index 0000000..060607d --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/mapper/ReservationOrderMapper.java @@ -0,0 +1,12 @@ +package cn.nianxx.thhotel.workflows.reservation.mapper; + +import cn.nianxx.thhotel.workflows.reservation.domain.ReservationOrderEntity; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; + +/** + * Reservation 订单 Mapper,只负责本表持久化访问。 + */ +@Mapper +public interface ReservationOrderMapper extends BaseMapper { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/mapper/ReservationTaskCardMapper.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/mapper/ReservationTaskCardMapper.java new file mode 100644 index 0000000..92c75dd --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/mapper/ReservationTaskCardMapper.java @@ -0,0 +1,12 @@ +package cn.nianxx.thhotel.workflows.reservation.mapper; + +import cn.nianxx.thhotel.workflows.reservation.domain.ReservationTaskCardEntity; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; + +/** + * Reservation 任务卡 Mapper,只负责本表持久化访问。 + */ +@Mapper +public interface ReservationTaskCardMapper extends BaseMapper { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/mapper/ReservationTaskMapper.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/mapper/ReservationTaskMapper.java new file mode 100644 index 0000000..55a4bd4 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/mapper/ReservationTaskMapper.java @@ -0,0 +1,12 @@ +package cn.nianxx.thhotel.workflows.reservation.mapper; + +import cn.nianxx.thhotel.workflows.reservation.domain.ReservationTaskEntity; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; + +/** + * Reservation 任务 Mapper,只负责本表持久化访问。 + */ +@Mapper +public interface ReservationTaskMapper extends BaseMapper { +} 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 new file mode 100644 index 0000000..1b25c8a --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/repository/MybatisReservationAiWorkflowRepository.java @@ -0,0 +1,453 @@ +package cn.nianxx.thhotel.workflows.reservation.repository; + +import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationAiBatchDraft; +import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationAiBatchSnapshot; +import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationAiTransitionDraft; +import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationAuditLogDraft; +import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationOrderDraft; +import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationOrderSnapshot; +import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationTaskCardDraft; +import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationTaskCardSnapshot; +import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationTaskDraft; +import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationTaskSnapshot; +import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationOrderStatus; +import cn.nianxx.thhotel.workflows.reservation.domain.ReservationAiBatchEntity; +import cn.nianxx.thhotel.workflows.reservation.domain.ReservationAiTransitionEntity; +import cn.nianxx.thhotel.workflows.reservation.domain.ReservationAuditLogEntity; +import cn.nianxx.thhotel.workflows.reservation.domain.ReservationOrderEntity; +import cn.nianxx.thhotel.workflows.reservation.domain.ReservationTaskCardEntity; +import cn.nianxx.thhotel.workflows.reservation.domain.ReservationTaskEntity; +import cn.nianxx.thhotel.workflows.reservation.mapper.ReservationAiBatchMapper; +import cn.nianxx.thhotel.workflows.reservation.mapper.ReservationAiTransitionMapper; +import cn.nianxx.thhotel.workflows.reservation.mapper.ReservationAuditLogMapper; +import cn.nianxx.thhotel.workflows.reservation.mapper.ReservationOrderMapper; +import cn.nianxx.thhotel.workflows.reservation.mapper.ReservationTaskCardMapper; +import cn.nianxx.thhotel.workflows.reservation.mapper.ReservationTaskMapper; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; +import org.springframework.stereotype.Repository; + +/** + * Reservation AI 工作流 MyBatis-Plus 持久化实现。数据库 Entity 和 Mapper 不泄漏到 Service。 + */ +@Repository +public class MybatisReservationAiWorkflowRepository implements ReservationAiWorkflowRepository { + + private final ReservationAiBatchMapper batchMapper; + private final ReservationAiTransitionMapper transitionMapper; + private final ReservationOrderMapper orderMapper; + private final ReservationTaskMapper taskMapper; + private final ReservationTaskCardMapper taskCardMapper; + private final ReservationAuditLogMapper auditLogMapper; + + /** + * 注入本工作流 Mapper,Repository 负责实体转换和查询细节。 + */ + public MybatisReservationAiWorkflowRepository( + ReservationAiBatchMapper batchMapper, + ReservationAiTransitionMapper transitionMapper, + ReservationOrderMapper orderMapper, + ReservationTaskMapper taskMapper, + ReservationTaskCardMapper taskCardMapper, + ReservationAuditLogMapper auditLogMapper) { + this.batchMapper = batchMapper; + this.transitionMapper = transitionMapper; + this.orderMapper = orderMapper; + this.taskMapper = taskMapper; + this.taskCardMapper = taskCardMapper; + this.auditLogMapper = auditLogMapper; + } + + /** + * 按 SourceMessage 读取第一条批次。第一版同一 SourceMessage 不支持不同 payload 重抽版本。 + */ + @Override + public Optional findBatchBySourceMessageId(String hotelId, Long sourceMessageId) { + ReservationAiBatchEntity entity = batchMapper.selectOne(Wrappers.lambdaQuery() + .eq(ReservationAiBatchEntity::getHotelId, hotelId) + .eq(ReservationAiBatchEntity::getSourceMessageId, sourceMessageId) + .last("LIMIT 1")); + return Optional.ofNullable(entity).map(this::toBatchSnapshot); + } + + /** + * 插入 AI 接收批次。 + */ + @Override + public Long insertBatch(ReservationAiBatchDraft draft) { + ReservationAiBatchEntity entity = new ReservationAiBatchEntity(); + entity.setHotelId(draft.hotelId()); + entity.setSourceMessageId(draft.sourceMessageId()); + entity.setRequestPayloadSha256(draft.requestPayloadSha256()); + entity.setBatchIdempotencyKey(draft.batchIdempotencyKey()); + entity.setClientId(draft.clientId()); + entity.setRequestId(draft.requestId()); + entity.setReceivedAt(draft.receivedAt()); + entity.setItemCount(draft.itemCount()); + entity.setExtractionWarningsJson(draft.extractionWarningsJson()); + entity.setCreatedAt(draft.receivedAt()); + entity.setUpdatedAt(draft.receivedAt()); + batchMapper.insert(entity); + return entity.getId(); + } + + /** + * 插入单条 AI 过渡记录。 + */ + @Override + public Long insertTransition(ReservationAiTransitionDraft draft) { + ReservationAiTransitionEntity entity = new ReservationAiTransitionEntity(); + entity.setHotelId(draft.hotelId()); + entity.setBatchId(draft.batchId()); + entity.setSourceMessageId(draft.sourceMessageId()); + entity.setSourceEventIndex(draft.sourceEventIndex()); + entity.setArrayIndex(draft.arrayIndex()); + entity.setExecutionOrder(draft.executionOrder()); + entity.setCatalogCode(draft.catalogCode()); + entity.setSkillId(draft.skillId()); + entity.setResultType(draft.resultType()); + entity.setAiTaskType(draft.aiTaskType()); + entity.setSystemTaskType(draft.systemTaskType()); + entity.setTaskCardType(draft.taskCardType()); + entity.setTaskSubtype(draft.taskSubtype()); + entity.setCurrentOrHistory(draft.currentOrHistory()); + entity.setGroupCode(draft.groupCode()); + entity.setConfirmationNumber(draft.confirmationNumber()); + entity.setItemPayloadSha256(draft.itemPayloadSha256()); + entity.setItemIdempotencyKey(draft.itemIdempotencyKey()); + entity.setManualReasonCode(draft.manualReasonCode()); + entity.setParentSourceEventIndex(draft.parentSourceEventIndex()); + entity.setLinkedTaskGroupId(draft.linkedTaskGroupId()); + entity.setBlockedUntilParentCompleted(Boolean.TRUE.equals(draft.blockedUntilParentCompleted())); + entity.setAiPayloadJson(draft.aiPayloadJson()); + entity.setCaseKeysJson(draft.caseKeysJson()); + entity.setExtractedFieldsJson(draft.extractedFieldsJson()); + entity.setManualReviewJson(draft.manualReviewJson()); + entity.setInformationalMessageJson(draft.informationalMessageJson()); + entity.setAttachmentsJson(draft.attachmentsJson()); + entity.setContextUsedJson(draft.contextUsedJson()); + entity.setCreatedAt(draft.now()); + entity.setUpdatedAt(draft.now()); + transitionMapper.insert(entity); + return entity.getId(); + } + + /** + * 根据业务号查找 ACTIVE 订单,LOGIC_DELETED 和 ENDED 不再自动承载新任务。 + */ + @Override + public Optional findActiveOrderByBusinessKey( + String hotelId, + String orderKeyType, + String orderBusinessKey) { + if (orderBusinessKey == null || orderBusinessKey.isBlank()) { + return Optional.empty(); + } + ReservationOrderEntity entity = orderMapper.selectOne(Wrappers.lambdaQuery() + .eq(ReservationOrderEntity::getHotelId, hotelId) + .eq(ReservationOrderEntity::getOrderKeyType, orderKeyType) + .eq(ReservationOrderEntity::getOrderBusinessKey, orderBusinessKey) + .eq(ReservationOrderEntity::getOrderStatus, ReservationOrderStatus.ACTIVE.name()) + .last("LIMIT 1")); + return Optional.ofNullable(entity).map(this::toOrderSnapshot); + } + + /** + * 插入订单并返回快照。 + */ + @Override + public ReservationOrderSnapshot insertOrder(ReservationOrderDraft draft) { + ReservationOrderEntity entity = new ReservationOrderEntity(); + entity.setHotelId(draft.hotelId()); + entity.setOrderKeyType(draft.orderKeyType()); + entity.setOrderBusinessKey(draft.orderBusinessKey()); + entity.setActiveBusinessKey(draft.activeBusinessKey()); + entity.setTemporaryOrderCode(draft.temporaryOrderCode()); + entity.setOrderStatus(draft.orderStatus()); + entity.setBusinessKeySource(draft.businessKeySource()); + entity.setBusinessKeyBackfilledAt(draft.businessKeyBackfilledAt()); + entity.setDisplayName(draft.displayName()); + entity.setSourceMessageId(draft.sourceMessageId()); + entity.setVersion(0L); + entity.setCreatedAt(draft.now()); + entity.setUpdatedAt(draft.now()); + orderMapper.insert(entity); + return toOrderSnapshot(entity); + } + + /** + * 计算下一条参与队列任务的顺序。Message Notification 不参与队列,但仍保存当前返回值。 + */ + @Override + public int nextExecutionOrder(String hotelId, Long orderId) { + ReservationTaskEntity lastTask = taskMapper.selectOne(Wrappers.lambdaQuery() + .eq(ReservationTaskEntity::getHotelId, hotelId) + .eq(ReservationTaskEntity::getOrderId, orderId) + .orderByDesc(ReservationTaskEntity::getExecutionOrder) + .last("LIMIT 1")); + return lastTask == null || lastTask.getExecutionOrder() == null ? 1 : lastTask.getExecutionOrder() + 1; + } + + /** + * 插入系统任务。 + */ + @Override + public Long insertTask(ReservationTaskDraft draft) { + ReservationTaskEntity entity = new ReservationTaskEntity(); + entity.setHotelId(draft.hotelId()); + entity.setOrderId(draft.orderId()); + entity.setSourceMessageId(draft.sourceMessageId()); + entity.setAiTransitionId(draft.aiTransitionId()); + entity.setResultType(draft.resultType()); + entity.setAiTaskType(draft.aiTaskType()); + entity.setSystemTaskType(draft.systemTaskType()); + entity.setTaskCardType(draft.taskCardType()); + entity.setTaskSubtype(draft.taskSubtype()); + entity.setTaskStatus(draft.taskStatus()); + entity.setQueueParticipation(Boolean.TRUE.equals(draft.queueParticipation())); + entity.setExecutionOrder(draft.executionOrder()); + entity.setParentSourceEventIndex(draft.parentSourceEventIndex()); + entity.setLinkedTaskGroupId(draft.linkedTaskGroupId()); + entity.setBlockedUntilParentCompleted(Boolean.TRUE.equals(draft.blockedUntilParentCompleted())); + entity.setCompletedAt(draft.completedAt()); + entity.setVersion(0L); + entity.setCreatedAt(draft.now()); + entity.setUpdatedAt(draft.now()); + taskMapper.insert(entity); + return entity.getId(); + } + + /** + * 插入任务卡初始记录。 + */ + @Override + public Long insertTaskCard(ReservationTaskCardDraft draft) { + ReservationTaskCardEntity entity = new ReservationTaskCardEntity(); + entity.setHotelId(draft.hotelId()); + entity.setTaskId(draft.taskId()); + entity.setTaskCardType(draft.taskCardType()); + entity.setFieldContractVersion(draft.fieldContractVersion()); + entity.setAiPayloadJson(draft.aiPayloadJson()); + entity.setCreatedAt(draft.now()); + entity.setUpdatedAt(draft.now()); + taskCardMapper.insert(entity); + return entity.getId(); + } + + /** + * 按任务 ID 查询任务快照,用于详情和人工复核转换。 + */ + @Override + public Optional findTaskById(Long taskId) { + return Optional.ofNullable(taskMapper.selectById(taskId)).map(this::toTaskSnapshot); + } + + /** + * 按任务 ID 查询任务卡快照。 + */ + @Override + public Optional findTaskCardByTaskId(String hotelId, Long taskId) { + ReservationTaskCardEntity entity = taskCardMapper.selectOne(Wrappers.lambdaQuery() + .eq(ReservationTaskCardEntity::getHotelId, hotelId) + .eq(ReservationTaskCardEntity::getTaskId, taskId) + .last("LIMIT 1")); + return Optional.ofNullable(entity).map(this::toTaskCardSnapshot); + } + + /** + * 查询同订单前置队列任务,Message Notification 等非队列任务不会进入结果。 + */ + @Override + public List findQueueTasksBefore(String hotelId, Long orderId, Integer executionOrder) { + return taskMapper.selectList(Wrappers.lambdaQuery() + .eq(ReservationTaskEntity::getHotelId, hotelId) + .eq(ReservationTaskEntity::getOrderId, orderId) + .eq(ReservationTaskEntity::getQueueParticipation, Boolean.TRUE) + .lt(ReservationTaskEntity::getExecutionOrder, executionOrder) + .orderByAsc(ReservationTaskEntity::getExecutionOrder)) + .stream() + .map(this::toTaskSnapshot) + .toList(); + } + + /** + * 按订单 ID 查询订单快照。 + */ + @Override + public Optional findOrderById(String hotelId, Long orderId) { + ReservationOrderEntity entity = orderMapper.selectOne(Wrappers.lambdaQuery() + .eq(ReservationOrderEntity::getHotelId, hotelId) + .eq(ReservationOrderEntity::getId, orderId) + .last("LIMIT 1")); + return Optional.ofNullable(entity).map(this::toOrderSnapshot); + } + + /** + * 将临时订单激活为有真实业务号的 ACTIVE 订单。唯一约束冲突由 Service 转换为业务错误。 + */ + @Override + public boolean activateTemporaryOrderWithBusinessKey( + String hotelId, + Long orderId, + String orderKeyType, + String orderBusinessKey, + String businessKeySource, + LocalDateTime now) { + return orderMapper.update(null, Wrappers.lambdaUpdate() + .set(ReservationOrderEntity::getOrderKeyType, orderKeyType) + .set(ReservationOrderEntity::getOrderBusinessKey, orderBusinessKey) + .set(ReservationOrderEntity::getActiveBusinessKey, orderBusinessKey) + .set(ReservationOrderEntity::getOrderStatus, ReservationOrderStatus.ACTIVE.name()) + .set(ReservationOrderEntity::getBusinessKeySource, businessKeySource) + .set(ReservationOrderEntity::getDisplayName, orderBusinessKey) + .set(ReservationOrderEntity::getUpdatedAt, now) + .eq(ReservationOrderEntity::getHotelId, hotelId) + .eq(ReservationOrderEntity::getId, orderId) + .eq(ReservationOrderEntity::getOrderStatus, ReservationOrderStatus.TEMPORARY.name())) > 0; + } + + /** + * 更新人工复核任务转换后的类型和订单归属。 + */ + @Override + public void updateTaskForManualConversion( + String hotelId, + Long taskId, + Long orderId, + String aiTaskType, + String systemTaskType, + String taskCardType, + Integer executionOrder, + LocalDateTime now) { + ReservationTaskEntity entity = new ReservationTaskEntity(); + entity.setOrderId(orderId); + entity.setAiTaskType(aiTaskType); + entity.setSystemTaskType(systemTaskType); + entity.setTaskCardType(taskCardType); + entity.setExecutionOrder(executionOrder); + entity.setUpdatedAt(now); + taskMapper.update(entity, Wrappers.lambdaUpdate() + .eq(ReservationTaskEntity::getHotelId, hotelId) + .eq(ReservationTaskEntity::getId, taskId)); + ReservationTaskCardEntity cardEntity = new ReservationTaskCardEntity(); + cardEntity.setTaskCardType(taskCardType); + cardEntity.setUpdatedAt(now); + taskCardMapper.update(cardEntity, Wrappers.lambdaUpdate() + .eq(ReservationTaskCardEntity::getHotelId, hotelId) + .eq(ReservationTaskCardEntity::getTaskId, taskId)); + } + + /** + * 统计订单下全部任务数量。 + */ + @Override + public int countTasksByOrder(String hotelId, Long orderId) { + return Math.toIntExact(taskMapper.selectCount(Wrappers.lambdaQuery() + .eq(ReservationTaskEntity::getHotelId, hotelId) + .eq(ReservationTaskEntity::getOrderId, orderId))); + } + + /** + * 将订单逻辑删除。该方法只做状态更新,是否空订单由 Service 决策。 + */ + @Override + public boolean logicDeleteOrder(String hotelId, Long orderId, String reason, LocalDateTime now) { + return orderMapper.update(null, Wrappers.lambdaUpdate() + .set(ReservationOrderEntity::getOrderStatus, ReservationOrderStatus.LOGIC_DELETED.name()) + .set(ReservationOrderEntity::getActiveBusinessKey, null) + .set(ReservationOrderEntity::getLogicDeletedAt, now) + .set(ReservationOrderEntity::getLogicDeletedReason, reason) + .set(ReservationOrderEntity::getUpdatedAt, now) + .eq(ReservationOrderEntity::getHotelId, hotelId) + .eq(ReservationOrderEntity::getId, orderId) + .ne(ReservationOrderEntity::getOrderStatus, ReservationOrderStatus.LOGIC_DELETED.name())) > 0; + } + + /** + * 插入审计日志,记录人工转换和订单迁移摘要。 + */ + @Override + public Long insertAuditLog(ReservationAuditLogDraft draft) { + ReservationAuditLogEntity entity = new ReservationAuditLogEntity(); + entity.setHotelId(draft.hotelId()); + entity.setOrderId(draft.orderId()); + entity.setTaskId(draft.taskId()); + entity.setOperationId(draft.operationId()); + entity.setActorType(draft.actorType()); + entity.setActorId(draft.actorId()); + entity.setAction(draft.action()); + entity.setReason(draft.reason()); + entity.setBeforeSnapshotJson(draft.beforeSnapshotJson()); + entity.setAfterSnapshotJson(draft.afterSnapshotJson()); + entity.setOccurredAt(draft.occurredAt()); + entity.setCreatedAt(draft.occurredAt()); + auditLogMapper.insert(entity); + return entity.getId(); + } + + /** + * 转换批次实体为快照。 + */ + private ReservationAiBatchSnapshot toBatchSnapshot(ReservationAiBatchEntity entity) { + return new ReservationAiBatchSnapshot( + entity.getId(), + entity.getHotelId(), + entity.getSourceMessageId(), + entity.getRequestPayloadSha256(), + entity.getBatchIdempotencyKey(), + entity.getItemCount()); + } + + /** + * 转换订单实体为快照。 + */ + private ReservationOrderSnapshot toOrderSnapshot(ReservationOrderEntity entity) { + return new ReservationOrderSnapshot( + entity.getId(), + entity.getHotelId(), + entity.getOrderKeyType(), + entity.getOrderBusinessKey(), + entity.getActiveBusinessKey(), + entity.getTemporaryOrderCode(), + entity.getOrderStatus()); + } + + /** + * 转换任务实体为快照。 + */ + private ReservationTaskSnapshot toTaskSnapshot(ReservationTaskEntity entity) { + return new ReservationTaskSnapshot( + entity.getId(), + entity.getHotelId(), + entity.getOrderId(), + entity.getSourceMessageId(), + entity.getAiTransitionId(), + entity.getResultType(), + entity.getAiTaskType(), + entity.getSystemTaskType(), + entity.getTaskCardType(), + entity.getTaskSubtype(), + entity.getTaskStatus(), + entity.getQueueParticipation(), + entity.getExecutionOrder(), + entity.getParentTaskId(), + entity.getParentSourceEventIndex(), + entity.getLinkedTaskGroupId(), + entity.getBlockedUntilParentCompleted()); + } + + /** + * 转换任务卡实体为快照。 + */ + private ReservationTaskCardSnapshot toTaskCardSnapshot(ReservationTaskCardEntity entity) { + return new ReservationTaskCardSnapshot( + entity.getId(), + entity.getHotelId(), + entity.getTaskId(), + entity.getTaskCardType(), + entity.getFieldContractVersion(), + entity.getAiPayloadJson()); + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/repository/ReservationAiWorkflowRepository.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/repository/ReservationAiWorkflowRepository.java new file mode 100644 index 0000000..d5a113f --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/repository/ReservationAiWorkflowRepository.java @@ -0,0 +1,123 @@ +package cn.nianxx.thhotel.workflows.reservation.repository; + +import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationAiBatchDraft; +import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationAiBatchSnapshot; +import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationAiTransitionDraft; +import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationAuditLogDraft; +import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationOrderDraft; +import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationOrderSnapshot; +import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationTaskCardDraft; +import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationTaskCardSnapshot; +import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationTaskDraft; +import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationTaskSnapshot; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +/** + * Reservation AI 工作流持久化边界。Service 通过该接口访问数据库,不直接依赖 Mapper。 + */ +public interface ReservationAiWorkflowRepository { + + /** + * 按 SourceMessage 查询已接收批次,用于第一版幂等冲突判断。 + */ + Optional findBatchBySourceMessageId(String hotelId, Long sourceMessageId); + + /** + * 新增 AI 接收批次,并返回批次 ID。 + */ + Long insertBatch(ReservationAiBatchDraft draft); + + /** + * 新增 AI 过渡记录,并返回过渡记录 ID。 + */ + Long insertTransition(ReservationAiTransitionDraft draft); + + /** + * 按业务号查询可承载任务的有效订单。 + */ + Optional findActiveOrderByBusinessKey( + String hotelId, + String orderKeyType, + String orderBusinessKey); + + /** + * 新增订单,并返回订单快照。 + */ + ReservationOrderSnapshot insertOrder(ReservationOrderDraft draft); + + /** + * 计算同订单下下一条任务执行顺序。 + */ + int nextExecutionOrder(String hotelId, Long orderId); + + /** + * 新增任务,并返回任务 ID。 + */ + Long insertTask(ReservationTaskDraft draft); + + /** + * 新增任务卡,并返回任务卡 ID。 + */ + Long insertTaskCard(ReservationTaskCardDraft draft); + + /** + * 按任务 ID 查询任务快照。 + */ + Optional findTaskById(Long taskId); + + /** + * 按任务 ID 查询任务卡快照。 + */ + Optional findTaskCardByTaskId(String hotelId, Long taskId); + + /** + * 查询同订单中排在当前任务之前且参与队列的任务。 + */ + List findQueueTasksBefore(String hotelId, Long orderId, Integer executionOrder); + + /** + * 按订单 ID 查询订单快照。 + */ + Optional findOrderById(String hotelId, Long orderId); + + /** + * 将临时订单激活为带真实业务号的 ACTIVE 订单。 + */ + boolean activateTemporaryOrderWithBusinessKey( + String hotelId, + Long orderId, + String orderKeyType, + String orderBusinessKey, + String businessKeySource, + LocalDateTime now); + + /** + * 更新人工复核任务的类型、卡片和订单归属。 + */ + void updateTaskForManualConversion( + String hotelId, + Long taskId, + Long orderId, + String aiTaskType, + String systemTaskType, + String taskCardType, + Integer executionOrder, + LocalDateTime now); + + /** + * 统计订单当前剩余任务数量,用于判断临时订单能否逻辑删除。 + */ + int countTasksByOrder(String hotelId, Long orderId); + + /** + * 将空临时订单逻辑删除,并清空 ACTIVE 唯一辅助键。 + */ + boolean logicDeleteOrder(String hotelId, Long orderId, String reason, LocalDateTime now); + + /** + * 新增订单任务审计记录。 + */ + Long insertAuditLog(ReservationAuditLogDraft draft); +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/ReservationAiTaskIntakeService.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/ReservationAiTaskIntakeService.java new file mode 100644 index 0000000..52ef73b --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/ReservationAiTaskIntakeService.java @@ -0,0 +1,14 @@ +package cn.nianxx.thhotel.workflows.reservation.service; + +import cn.nianxx.thhotel.workflows.reservation.common.result.SuperAgentTaskResultResponse; + +/** + * Reservation AI 任务结果接收服务。负责把 SuperAgent JSON 转换为 AI 过渡层、订单、任务和任务卡。 + */ +public interface ReservationAiTaskIntakeService { + + /** + * 接收已通过鉴权的 SuperAgent 原始请求体,并完成 CP1-3 范围内的持久化和任务创建。 + */ + SuperAgentTaskResultResponse accept(String rawBody, String clientId, String requestId); +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/ReservationTaskCardFieldDefinitionProvider.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/ReservationTaskCardFieldDefinitionProvider.java new file mode 100644 index 0000000..13e2904 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/ReservationTaskCardFieldDefinitionProvider.java @@ -0,0 +1,15 @@ +package cn.nianxx.thhotel.workflows.reservation.service; + +import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationTaskCardFieldDefinition; +import java.util.List; + +/** + * 任务卡字段定义提供者。第一版从代码资源读取,后续可替换为数据库配置。 + */ +public interface ReservationTaskCardFieldDefinitionProvider { + + /** + * 按任务卡类型和 AI 结果类型读取字段矩阵定义。 + */ + List listDefinitions(String taskCardType, String resultType); +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/ReservationTaskWorkflowService.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/ReservationTaskWorkflowService.java new file mode 100644 index 0000000..ae6e653 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/ReservationTaskWorkflowService.java @@ -0,0 +1,21 @@ +package cn.nianxx.thhotel.workflows.reservation.service; + +import cn.nianxx.thhotel.workflows.reservation.common.request.ManualReviewConversionRequest; +import cn.nianxx.thhotel.workflows.reservation.common.result.ManualReviewConversionResult; +import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationTaskDetailResult; + +/** + * Reservation 任务工作流服务。提供任务详情、队列可处理状态和人工复核转换能力。 + */ +public interface ReservationTaskWorkflowService { + + /** + * 查询任务详情,并实时计算同订单前置任务阻塞状态。 + */ + ReservationTaskDetailResult getTaskDetail(Long taskId); + + /** + * 将 Fallback / manual_review 任务人工转换为 New、Update 或 Cancel。 + */ + ManualReviewConversionResult convertManualReviewTask(Long taskId, ManualReviewConversionRequest request); +} 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 new file mode 100644 index 0000000..2e8a3e1 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/JsonReservationTaskCardFieldDefinitionProvider.java @@ -0,0 +1,174 @@ +package cn.nianxx.thhotel.workflows.reservation.service.impl; + +import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationTaskCardFieldDefinition; +import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationTaskCardType; +import cn.nianxx.thhotel.workflows.reservation.service.ReservationTaskCardFieldDefinitionProvider; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.PostConstruct; +import java.io.IOException; +import java.io.InputStream; +import java.util.List; +import java.util.Map; +import org.springframework.core.io.ClassPathResource; +import org.springframework.stereotype.Service; + +/** + * JSON 任务卡字段定义提供者。JSON 由《任务卡展示编辑矩阵.xlsx》机械生成,代码只负责加载和筛选。 + */ +@Service +public class JsonReservationTaskCardFieldDefinitionProvider implements ReservationTaskCardFieldDefinitionProvider { + + private static final String MATRIX_RESOURCE = "reservation-task-card/field-matrix-v20260706.json"; + private static final Map CARD_NAME_BY_TYPE = Map.ofEntries( + 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.VOUCHER_RECEIVED.name(), "Voucher Received 卡"), + 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.MESSAGE_NOTIFICATION.name(), "Message Notification 信息提醒卡"), + Map.entry(ReservationTaskCardType.FALLBACK_REVIEW.name(), "Fallback 人工复核卡") + ); + + private final ObjectMapper objectMapper; + private List definitions = List.of(); + + /** + * 注入 JSON 解析器,字段矩阵资源在应用启动后加载。 + */ + public JsonReservationTaskCardFieldDefinitionProvider(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + } + + /** + * 加载字段矩阵 JSON 资源;资源缺失属于配置错误,应阻止服务错误运行。 + */ + @PostConstruct + public void loadDefinitions() { + try (InputStream inputStream = new ClassPathResource(MATRIX_RESOURCE).getInputStream()) { + MatrixResource resource = objectMapper.readValue(inputStream, MatrixResource.class); + this.definitions = resource.fields().stream() + .map(RawFieldDefinition::toDefinition) + .toList(); + } catch (IOException exception) { + throw new IllegalStateException("任务卡字段矩阵资源加载失败", exception); + } + } + + /** + * 按系统任务卡类型读取对应矩阵字段。result_type 当前仅用于后续扩展,不做中文卡名反推。 + */ + @Override + public List listDefinitions(String taskCardType, String resultType) { + String cardName = CARD_NAME_BY_TYPE.get(taskCardType); + if (cardName == null) { + return List.of(); + } + return definitions.stream() + .filter(definition -> cardName.equals(definition.cardName())) + .toList(); + } + + /** + * 字段矩阵 JSON 顶层结构。 + */ + private record MatrixResource( + List fields + ) { + } + + /** + * 字段矩阵 JSON 单行结构,与生成后的 snake_case 字段保持一致。 + */ + private record RawFieldDefinition( + @JsonProperty("row_number") + Integer rowNumber, + @JsonProperty("card_name") + String cardName, + @JsonProperty("result_type") + String resultType, + @JsonProperty("task_type") + String taskType, + @JsonProperty("task_subtype") + String taskSubtype, + @JsonProperty("applicable_scenario") + String applicableScenario, + @JsonProperty("display_area") + String displayArea, + @JsonProperty("field_path") + String fieldPath, + @JsonProperty("display_name") + String displayName, + @JsonProperty("field_source") + String fieldSource, + String visible, + String editable, + @JsonProperty("input_editable") + String inputEditable, + @JsonProperty("select_editable") + String selectEditable, + @JsonProperty("date_picker") + String datePicker, + @JsonProperty("number_input") + String numberInput, + @JsonProperty("file_display") + String fileDisplay, + @JsonProperty("table_editable") + String tableEditable, + @JsonProperty("enum_options") + String enumOptions, + @JsonProperty("required_rule") + String requiredRule, + @JsonProperty("default_value_source") + String defaultValueSource, + @JsonProperty("display_condition") + String displayCondition, + @JsonProperty("validation_rule") + String validationRule, + @JsonProperty("write_path") + String writePath, + @JsonProperty("opera_write_participation") + String operaWriteParticipation, + @JsonProperty("opera_parameter_mapping") + String operaParameterMapping, + String notes + ) { + + /** + * 将资源行转换为模块内部字段定义,避免业务服务依赖资源 JSON 结构。 + */ + private ReservationTaskCardFieldDefinition toDefinition() { + return new ReservationTaskCardFieldDefinition( + rowNumber, + cardName, + resultType, + taskType, + taskSubtype, + applicableScenario, + displayArea, + fieldPath, + displayName, + fieldSource, + visible, + editable, + inputEditable, + selectEditable, + datePicker, + numberInput, + fileDisplay, + tableEditable, + enumOptions, + requiredRule, + defaultValueSource, + displayCondition, + validationRule, + writePath, + operaWriteParticipation, + operaParameterMapping, + notes); + } + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationAiTaskIntakeException.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationAiTaskIntakeException.java new file mode 100644 index 0000000..41d3798 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationAiTaskIntakeException.java @@ -0,0 +1,26 @@ +package cn.nianxx.thhotel.workflows.reservation.service.impl; + +import org.springframework.http.HttpStatus; + +/** + * Reservation AI 任务结果接收受控异常。用于业务接收阶段的安全错误响应。 + */ +public class ReservationAiTaskIntakeException extends RuntimeException { + + private final HttpStatus status; + private final String errorCode; + + public ReservationAiTaskIntakeException(HttpStatus status, String errorCode, String message) { + super(message); + this.status = status; + this.errorCode = errorCode; + } + + public HttpStatus getStatus() { + return status; + } + + public String getErrorCode() { + return errorCode; + } +} 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 new file mode 100644 index 0000000..9cda7a8 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationAiTaskIntakeServiceImpl.java @@ -0,0 +1,673 @@ +package cn.nianxx.thhotel.workflows.reservation.service.impl; + +import cn.nianxx.thhotel.platform.message.common.dto.SourceMessageInboxSnapshot; +import cn.nianxx.thhotel.platform.message.repository.SourceMessageInboxRepository; +import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationAiBatchDraft; +import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationAiBatchSnapshot; +import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationAiTransitionDraft; +import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationOrderDraft; +import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationOrderSnapshot; +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.ReservationBusinessKeySource; +import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationOrderKeyType; +import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationOrderStatus; +import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationSystemTaskType; +import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationTaskCardType; +import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationTaskStatus; +import cn.nianxx.thhotel.workflows.reservation.common.result.SuperAgentTaskResultItemResponse; +import cn.nianxx.thhotel.workflows.reservation.common.result.SuperAgentTaskResultResponse; +import cn.nianxx.thhotel.workflows.reservation.common.result.SuperAgentTaskResultWarning; +import cn.nianxx.thhotel.workflows.reservation.repository.ReservationAiWorkflowRepository; +import cn.nianxx.thhotel.workflows.reservation.service.ReservationAiTaskIntakeService; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.HexFormat; +import java.util.List; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * Reservation AI 任务结果接收实现。负责 CP1-3:AI 过渡层、订单、任务和任务卡创建。 + */ +@Service +public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskIntakeService { + + private static final String FIELD_CONTRACT_VERSION = "code-v1"; + private static final String BATCH_KEY_PREFIX = "superagent-task-result-batch:v1"; + private static final String ITEM_KEY_PREFIX = "superagent-task-result-item:v1"; + private static final int LENGTH_32 = 32; + private static final int LENGTH_64 = 64; + private static final int LENGTH_128 = 128; + private static final int MAX_QUEUE_ORDER_RETRY = 5; + + private final ObjectMapper objectMapper; + private final SourceMessageInboxRepository sourceMessageInboxRepository; + private final ReservationAiWorkflowRepository workflowRepository; + + /** + * 注入 JSON 解析器、SourceMessage 查询边界和 Reservation 持久化边界。 + */ + public ReservationAiTaskIntakeServiceImpl( + ObjectMapper objectMapper, + SourceMessageInboxRepository sourceMessageInboxRepository, + ReservationAiWorkflowRepository workflowRepository) { + this.objectMapper = objectMapper; + this.sourceMessageInboxRepository = sourceMessageInboxRepository; + this.workflowRepository = workflowRepository; + } + + /** + * 接收已通过 HMAC 鉴权的 SuperAgent 任务结果,创建 AI 过渡记录和最小任务模型。 + */ + @Override + @Transactional + public SuperAgentTaskResultResponse accept(String rawBody, String clientId, String requestId) { + JsonNode root = parseJson(rawBody); + Long sourceMessageId = parseSourceMessageId(root); + JsonNode taskResults = root.path("ai_task_results"); + if (!taskResults.isArray() || taskResults.isEmpty()) { + throw error(HttpStatus.BAD_REQUEST, "TASK_RESULTS_EMPTY", "ai_task_results 不能为空。"); + } + + SourceMessageInboxSnapshot sourceMessage = sourceMessageInboxRepository.findById(sourceMessageId) + .orElseThrow(() -> error(HttpStatus.NOT_FOUND, "SOURCE_MESSAGE_NOT_FOUND", "SourceMessage 不存在。")); + String hotelId = sourceMessage.hotelId(); + 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, sourceMessageId, 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, + taskResults.size(), + nodeJson(root.get("extraction_warnings")) + ); + Long batchId = insertBatchOrReplay(batchDraft); + if (batchId == null) { + return handleExistingBatch( + requestId, + sourceMessageId, + requestPayloadSha256, + workflowRepository.findBatchBySourceMessageId(hotelId, sourceMessageId) + .orElseThrow(() -> error(HttpStatus.CONFLICT, "IDEMPOTENCY_CONFLICT", "AI 批次并发写入状态不确定。"))); + } + + List responseItems = new ArrayList<>(taskResults.size()); + for (int index = 0; index < taskResults.size(); index++) { + JsonNode item = taskResults.get(index); + responseItems.add(createItem(hotelId, sourceMessageId, batchId, item, index + 1, now)); + } + + return new SuperAgentTaskResultResponse( + safeRequestId, + sourceMessageId.toString(), + batchId.toString(), + false, + responseItems.size(), + responseItems, + List.of() + ); + } + + /** + * 插入批次;如果并发请求已经写入同一 SourceMessage,则交由调用方按幂等规则重新读取处理。 + */ + private Long insertBatchOrReplay(ReservationAiBatchDraft batchDraft) { + try { + return workflowRepository.insertBatch(batchDraft); + } catch (DuplicateKeyException exception) { + return null; + } + } + + /** + * 处理已存在批次:完全相同 payload 返回幂等重放,不同 payload 返回冲突。 + */ + private SuperAgentTaskResultResponse handleExistingBatch( + String requestId, + Long sourceMessageId, + String requestPayloadSha256, + ReservationAiBatchSnapshot existingBatch) { + if (!requestPayloadSha256.equals(existingBatch.requestPayloadSha256())) { + throw error(HttpStatus.CONFLICT, "IDEMPOTENCY_CONFLICT", "同一 SourceMessage 已存在不同 AI 结果请求。"); + } + return new SuperAgentTaskResultResponse( + requestId, + sourceMessageId.toString(), + existingBatch.id().toString(), + true, + existingBatch.itemCount() == null ? 0 : existingBatch.itemCount(), + List.of(), + List.of(new SuperAgentTaskResultWarning("IDEMPOTENT_REPLAY", "相同请求已经处理,本次未重复创建任务。")) + ); + } + + /** + * 创建单条 AI item 对应的过渡记录、订单、任务和任务卡。 + */ + private SuperAgentTaskResultItemResponse createItem( + String hotelId, + Long sourceMessageId, + Long batchId, + JsonNode item, + int arrayIndex, + LocalDateTime now) { + 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 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); + 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); + String confirmationNumber = optionalText(textAt(caseKeys, "confirmation_number"), "confirmation_number", LENGTH_128); + String manualReasonCode = optionalText(textAt(item.path("manual_review"), "reason_code"), "manual_review.reason_code", LENGTH_128); + String linkedTaskGroupId = optionalText(textAt(item, "linked_task_group_id"), "linked_task_group_id", LENGTH_128); + String itemPayloadJson = nodeJson(item); + String itemPayloadSha256 = sha256(itemPayloadJson); + String itemIdempotencyKey = sha256(ITEM_KEY_PREFIX + + "|" + sourceMessageId + + "|" + sourceEventIndex + + "|" + arrayIndex + + "|" + catalogCode + + "|" + skillId + + "|" + resultType + + "|" + aiTaskType + + "|" + nullToEmpty(taskSubtype) + + "|" + itemPayloadSha256); + + Long transitionId = workflowRepository.insertTransition(new ReservationAiTransitionDraft( + hotelId, + batchId, + sourceMessageId, + sourceEventIndex, + arrayIndex, + arrayIndex, + catalogCode, + skillId, + resultType, + aiTaskType, + mapping.systemTaskType().name(), + mapping.taskCardType().name(), + taskSubtype, + currentOrHistory, + groupCode, + confirmationNumber, + itemPayloadSha256, + itemIdempotencyKey, + manualReasonCode, + integerAt(item, "parent_source_event_index"), + linkedTaskGroupId, + booleanAt(item, "blocked_until_parent_completed"), + itemPayloadJson, + nodeJson(item.get("case_keys")), + nodeJson(item.get("extracted_fields")), + nodeJson(item.get("manual_review")), + nodeJson(item.get("informational_message")), + nodeJson(item.get("attachments")), + nodeJson(item.get("context_used")), + now + )); + + ReservationOrderSnapshot order = resolveOrder( + hotelId, + sourceMessageId, + mapping.systemTaskType(), + groupCode, + confirmationNumber, + arrayIndex, + now); + boolean queueParticipation = mapping.systemTaskType() != ReservationSystemTaskType.INFORMATIONAL_MESSAGE; + ReservationTaskStatus taskStatus = queueParticipation + ? ReservationTaskStatus.PENDING_CONFIRM + : ReservationTaskStatus.COMPLETED; + LocalDateTime completedAt = taskStatus == ReservationTaskStatus.COMPLETED ? now : null; + TaskCreation taskCreation = insertTaskWithQueueRetry( + hotelId, + sourceMessageId, + transitionId, + resultType, + aiTaskType, + mapping, + taskSubtype, + taskStatus.name(), + queueParticipation, + integerAt(item, "parent_source_event_index"), + linkedTaskGroupId, + booleanAt(item, "blocked_until_parent_completed"), + completedAt, + order, + now + ); + workflowRepository.insertTaskCard(new ReservationTaskCardDraft( + hotelId, + taskCreation.taskId(), + mapping.taskCardType().name(), + FIELD_CONTRACT_VERSION, + itemPayloadJson, + now + )); + + return new SuperAgentTaskResultItemResponse( + sourceEventIndex, + arrayIndex, + transitionId.toString(), + order.id().toString(), + taskCreation.taskId().toString(), + mapping.systemTaskType().name(), + mapping.taskCardType().name(), + taskStatus.name(), + order.orderStatus(), + taskCreation.executionOrder() + ); + } + + /** + * 插入任务并处理并发队列序号冲突。数据库唯一约束兜底后,这里重新取号重试。 + */ + private TaskCreation insertTaskWithQueueRetry( + String hotelId, + Long sourceMessageId, + Long transitionId, + String resultType, + String aiTaskType, + TaskTypeMapping mapping, + String taskSubtype, + String taskStatus, + boolean queueParticipation, + Integer parentSourceEventIndex, + String linkedTaskGroupId, + Boolean blockedUntilParentCompleted, + LocalDateTime completedAt, + ReservationOrderSnapshot order, + LocalDateTime now) { + for (int attempt = 1; attempt <= MAX_QUEUE_ORDER_RETRY; attempt++) { + int executionOrder = workflowRepository.nextExecutionOrder(hotelId, order.id()); + try { + Long taskId = workflowRepository.insertTask(new ReservationTaskDraft( + hotelId, + order.id(), + sourceMessageId, + transitionId, + resultType, + aiTaskType, + mapping.systemTaskType().name(), + mapping.taskCardType().name(), + taskSubtype, + taskStatus, + queueParticipation, + executionOrder, + parentSourceEventIndex, + linkedTaskGroupId, + blockedUntilParentCompleted, + completedAt, + now + )); + return new TaskCreation(taskId, executionOrder); + } catch (DuplicateKeyException exception) { + if (attempt == MAX_QUEUE_ORDER_RETRY) { + throw error(HttpStatus.CONFLICT, "TASK_QUEUE_ORDER_CONFLICT", "同订单任务队列序号并发写入冲突。"); + } + } + } + throw error(HttpStatus.CONFLICT, "TASK_QUEUE_ORDER_CONFLICT", "同订单任务队列序号并发写入冲突。"); + } + + /** + * 根据系统主任务类型和候选业务号解析订单归属;CP1-3 只做自动匹配和临时订单。 + */ + private ReservationOrderSnapshot resolveOrder( + String hotelId, + Long sourceMessageId, + ReservationSystemTaskType systemTaskType, + String groupCode, + String confirmationNumber, + int arrayIndex, + LocalDateTime now) { + if (systemTaskType == ReservationSystemTaskType.MANUAL_REVIEW + || systemTaskType == ReservationSystemTaskType.INFORMATIONAL_MESSAGE) { + return createTemporaryOrder(hotelId, sourceMessageId, arrayIndex, now); + } + BusinessKey businessKey = selectBusinessKey(groupCode, confirmationNumber); + if (businessKey == null) { + return createTemporaryOrder(hotelId, sourceMessageId, arrayIndex, now); + } + return workflowRepository.findActiveOrderByBusinessKey(hotelId, businessKey.keyType().name(), businessKey.value()) + .orElseGet(() -> createActiveOrder(hotelId, sourceMessageId, businessKey, arrayIndex, now)); + } + + /** + * 选择订单业务号候选,Group Code 优先于 Confirmation Number。 + */ + private BusinessKey selectBusinessKey(String groupCode, String confirmationNumber) { + if (hasText(groupCode)) { + return new BusinessKey(ReservationOrderKeyType.GROUP_CODE, groupCode); + } + if (hasText(confirmationNumber)) { + return new BusinessKey(ReservationOrderKeyType.CONFIRMATION_NUMBER, confirmationNumber); + } + return null; + } + + /** + * 创建带业务号的有效订单。 + */ + private ReservationOrderSnapshot createActiveOrder( + String hotelId, + Long sourceMessageId, + BusinessKey businessKey, + int arrayIndex, + LocalDateTime now) { + try { + return workflowRepository.insertOrder(new ReservationOrderDraft( + hotelId, + businessKey.keyType().name(), + businessKey.value(), + businessKey.value(), + temporaryOrderCode(sourceMessageId, arrayIndex), + ReservationOrderStatus.ACTIVE.name(), + ReservationBusinessKeySource.AI_CANDIDATE.name(), + null, + businessKey.value(), + sourceMessageId, + now + )); + } catch (DuplicateKeyException exception) { + return workflowRepository + .findActiveOrderByBusinessKey(hotelId, businessKey.keyType().name(), businessKey.value()) + .orElseThrow(() -> error(HttpStatus.CONFLICT, "ORDER_ACTIVE_KEY_CONFLICT", "ACTIVE 订单业务号并发写入冲突。")); + } + } + + /** + * 创建临时订单,用作无业务号任务、人工复核和信息提醒的归档容器。 + */ + private ReservationOrderSnapshot createTemporaryOrder( + String hotelId, + Long sourceMessageId, + int arrayIndex, + LocalDateTime now) { + String temporaryCode = temporaryOrderCode(sourceMessageId, arrayIndex); + return workflowRepository.insertOrder(new ReservationOrderDraft( + hotelId, + ReservationOrderKeyType.TEMPORARY.name(), + null, + null, + temporaryCode, + ReservationOrderStatus.TEMPORARY.name(), + null, + null, + temporaryCode, + sourceMessageId, + now + )); + } + + /** + * 映射 AI 原始任务类型到系统主任务类型和任务卡类型。 + */ + private TaskTypeMapping mapTaskType(String resultType, String aiTaskType) { + if (AiResultType.MANUAL_REVIEW.code().equals(resultType) || "Fallback".equals(aiTaskType)) { + return new TaskTypeMapping(ReservationSystemTaskType.MANUAL_REVIEW, ReservationTaskCardType.FALLBACK_REVIEW); + } + if (AiResultType.INFORMATIONAL_MESSAGE.code().equals(resultType) || "Message Notification".equals(aiTaskType)) { + return new TaskTypeMapping( + ReservationSystemTaskType.INFORMATIONAL_MESSAGE, + ReservationTaskCardType.MESSAGE_NOTIFICATION); + } + if (!AiResultType.NORMAL_TASK.code().equals(resultType)) { + throw error(HttpStatus.BAD_REQUEST, "TASK_RESULT_UNSUPPORTED_TYPE", "result_type 不可识别。"); + } + return switch (aiTaskType) { + case "New Booking" -> new TaskTypeMapping( + ReservationSystemTaskType.NEW_BOOKING, + ReservationTaskCardType.NEW_BOOKING); + case "Cancel Booking" -> new TaskTypeMapping( + ReservationSystemTaskType.CANCEL_BOOKING, + ReservationTaskCardType.CANCEL_BOOKING); + case "Update Booking" -> new TaskTypeMapping( + ReservationSystemTaskType.UPDATE_BOOKING, + ReservationTaskCardType.UPDATE_BOOKING); + case "Voucher Received" -> new TaskTypeMapping( + ReservationSystemTaskType.UPDATE_BOOKING, + ReservationTaskCardType.VOUCHER_RECEIVED); + case "Rooming List" -> new TaskTypeMapping( + ReservationSystemTaskType.UPDATE_BOOKING, + ReservationTaskCardType.ROOMING_LIST); + case "Amend Group Code" -> new TaskTypeMapping( + ReservationSystemTaskType.UPDATE_BOOKING, + ReservationTaskCardType.AMEND_GROUP_CODE); + case "Trace / Reservation Notes" -> new TaskTypeMapping( + ReservationSystemTaskType.UPDATE_BOOKING, + ReservationTaskCardType.TRACE_RESERVATION_NOTES); + case "TA Recorder" -> new TaskTypeMapping( + ReservationSystemTaskType.UPDATE_BOOKING, + ReservationTaskCardType.TA_RECORDER); + default -> throw error(HttpStatus.BAD_REQUEST, "TASK_RESULT_UNSUPPORTED_TYPE", "task_type 不可识别。"); + }; + } + + /** + * 解析请求体 JSON,解析失败返回受控错误,不输出原始请求体。 + */ + private JsonNode parseJson(String rawBody) { + try { + return objectMapper.readTree(rawBody == null ? "" : rawBody); + } catch (Exception exception) { + throw error(HttpStatus.BAD_REQUEST, "INVALID_JSON", "JSON 不可解析。"); + } + } + + /** + * 解析 SourceMessage ID,接口一次只能处理一个来源消息。 + */ + private Long parseSourceMessageId(JsonNode root) { + String rawId = trimToNull(textAt(root, "source_message_id")); + if (rawId == null) { + throw error(HttpStatus.BAD_REQUEST, "SOURCE_MESSAGE_REQUIRED", "source_message_id 缺失。"); + } + try { + return Long.valueOf(rawId); + } catch (NumberFormatException exception) { + throw error(HttpStatus.BAD_REQUEST, "SOURCE_MESSAGE_REQUIRED", "source_message_id 格式无效。"); + } + } + + /** + * 从对象节点中读取文本字段,缺失或 null 时返回 null。 + */ + private String textAt(JsonNode node, String fieldName) { + if (node == null || node.isMissingNode() || node.get(fieldName) == null || node.get(fieldName).isNull()) { + return null; + } + return node.get(fieldName).asText(); + } + + /** + * 读取正整数,AI 事件序号必须存在且大于 0。 + */ + private Integer requirePositiveInt(JsonNode node, String fieldName) { + Integer value = integerAt(node, fieldName); + if (value == null || value <= 0) { + throw error(HttpStatus.BAD_REQUEST, "TASK_RESULT_UNSUPPORTED_TYPE", fieldName + " 无效。"); + } + return value; + } + + /** + * 读取可选整数字段。 + */ + private Integer integerAt(JsonNode node, String fieldName) { + if (node == null || node.get(fieldName) == null || node.get(fieldName).isNull()) { + return null; + } + return node.get(fieldName).asInt(); + } + + /** + * 读取可选布尔字段。 + */ + private Boolean booleanAt(JsonNode node, String fieldName) { + if (node == null || node.get(fieldName) == null || node.get(fieldName).isNull()) { + return Boolean.FALSE; + } + return node.get(fieldName).asBoolean(false); + } + + /** + * 将 JSON 节点序列化为字符串,缺失时返回 null。 + */ + private String nodeJson(JsonNode node) { + if (node == null || node.isMissingNode() || node.isNull()) { + return null; + } + try { + return objectMapper.writeValueAsString(node); + } catch (Exception exception) { + throw new IllegalStateException("JSON 节点序列化失败", exception); + } + } + + /** + * 计算 SHA-256,用于批次和 item 幂等键。 + */ + private String sha256(String value) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + return HexFormat.of().formatHex(digest.digest(value.getBytes(StandardCharsets.UTF_8))); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("当前 Java 运行时不支持 SHA-256", exception); + } + } + + /** + * 生成临时订单编号,保证同一 SourceMessage 多 item 不冲突。 + */ + private String temporaryOrderCode(Long sourceMessageId, int arrayIndex) { + return "TMP-SM-" + sourceMessageId + "-" + arrayIndex; + } + + /** + * 生成当前 UTC 时间。 + */ + private LocalDateTime nowUtc() { + return LocalDateTime.now(ZoneOffset.UTC); + } + + /** + * 校验必填文本字段。 + */ + private String requireText(String value, String fieldName, int maxLength) { + String trimmed = trimToNull(value); + if (trimmed == null) { + throw error(HttpStatus.BAD_REQUEST, "TASK_RESULT_UNSUPPORTED_TYPE", fieldName + " 缺失。"); + } + validateLength(trimmed, fieldName, maxLength); + return trimmed; + } + + /** + * 校验可选文本字段长度,防止外部输入直接触发数据库字段超长异常。 + */ + private String optionalText(String value, String fieldName, int maxLength) { + String trimmed = trimToNull(value); + if (trimmed == null) { + return null; + } + validateLength(trimmed, fieldName, maxLength); + return trimmed; + } + + /** + * 校验稳定字段长度,与第一版表结构保持一致。 + */ + private void validateLength(String value, String fieldName, int maxLength) { + if (value.length() > maxLength) { + throw error(HttpStatus.BAD_REQUEST, "TASK_RESULT_FIELD_TOO_LONG", fieldName + " 超过长度限制。"); + } + } + + /** + * 判断文本是否有效。 + */ + private boolean hasText(String value) { + return value != null && !value.trim().isEmpty(); + } + + /** + * 将空白字符串转换为 null。 + */ + private String trimToNull(String value) { + if (value == null) { + return null; + } + String trimmed = value.trim(); + return trimmed.isEmpty() ? null : trimmed; + } + + /** + * null 转为空字符串,保证幂等键拼接稳定。 + */ + private String nullToEmpty(String value) { + return value == null ? "" : value; + } + + /** + * 构建受控错误。 + */ + private ReservationAiTaskIntakeException error(HttpStatus status, String errorCode, String message) { + return new ReservationAiTaskIntakeException(status, errorCode, message); + } + + /** + * 任务类型映射结果。 + */ + private record TaskTypeMapping( + ReservationSystemTaskType systemTaskType, + ReservationTaskCardType taskCardType + ) { + } + + /** + * 订单业务号候选。 + */ + private record BusinessKey( + ReservationOrderKeyType keyType, + String value + ) { + } + + /** + * 任务创建结果,包含任务 ID 和最终写入的同订单执行序号。 + */ + private record TaskCreation( + Long taskId, + Integer executionOrder + ) { + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationTaskWorkflowException.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationTaskWorkflowException.java new file mode 100644 index 0000000..032c6d4 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationTaskWorkflowException.java @@ -0,0 +1,26 @@ +package cn.nianxx.thhotel.workflows.reservation.service.impl; + +import org.springframework.http.HttpStatus; + +/** + * Reservation 任务工作流受控异常。用于详情、队列状态和人工转换接口。 + */ +public class ReservationTaskWorkflowException extends RuntimeException { + + private final HttpStatus status; + private final String errorCode; + + public ReservationTaskWorkflowException(HttpStatus status, String errorCode, String message) { + super(message); + this.status = status; + this.errorCode = errorCode; + } + + public HttpStatus getStatus() { + return status; + } + + public String getErrorCode() { + return errorCode; + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationTaskWorkflowServiceImpl.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationTaskWorkflowServiceImpl.java new file mode 100644 index 0000000..43e2414 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationTaskWorkflowServiceImpl.java @@ -0,0 +1,491 @@ +package cn.nianxx.thhotel.workflows.reservation.service.impl; + +import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationAuditLogDraft; +import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationOrderSnapshot; +import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationTaskCardFieldDefinition; +import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationTaskCardSnapshot; +import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationTaskSnapshot; +import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationBusinessKeySource; +import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationOrderKeyType; +import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationOrderStatus; +import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationSystemTaskType; +import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationTaskCardType; +import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationTaskStatus; +import cn.nianxx.thhotel.workflows.reservation.common.request.ManualReviewConversionRequest; +import cn.nianxx.thhotel.workflows.reservation.common.result.ManualReviewConversionResult; +import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationTaskAvailabilityResult; +import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationTaskDetailResult; +import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationTaskFieldResult; +import cn.nianxx.thhotel.workflows.reservation.repository.ReservationAiWorkflowRepository; +import cn.nianxx.thhotel.workflows.reservation.service.ReservationTaskCardFieldDefinitionProvider; +import cn.nianxx.thhotel.workflows.reservation.service.ReservationTaskWorkflowService; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.List; +import java.util.Map; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * Reservation 任务工作流服务实现。负责任务详情、队列只读规则和人工复核转换。 + */ +@Service +public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkflowService { + + private static final String ACTION_MANUAL_REVIEW_CONVERT = "MANUAL_REVIEW_CONVERT"; + private static final String ACTOR_TYPE_USER = "USER"; + private static final String ACTOR_ID_LOCAL = "local-user"; + private static final int MAX_QUEUE_ORDER_RETRY = 5; + + private final ReservationAiWorkflowRepository workflowRepository; + private final ReservationTaskCardFieldDefinitionProvider fieldDefinitionProvider; + private final ObjectMapper objectMapper; + + /** + * 注入持久化边界、字段矩阵 Provider 和 JSON 解析器。 + */ + public ReservationTaskWorkflowServiceImpl( + ReservationAiWorkflowRepository workflowRepository, + ReservationTaskCardFieldDefinitionProvider fieldDefinitionProvider, + ObjectMapper objectMapper) { + this.workflowRepository = workflowRepository; + this.fieldDefinitionProvider = fieldDefinitionProvider; + this.objectMapper = objectMapper; + } + + /** + * 查询任务详情,按同订单前置任务状态实时计算只读和可处理状态。 + */ + @Override + @Transactional(readOnly = true) + public ReservationTaskDetailResult getTaskDetail(Long taskId) { + ReservationTaskSnapshot task = findTaskOrThrow(taskId); + ReservationTaskCardSnapshot taskCard = workflowRepository + .findTaskCardByTaskId(task.hotelId(), task.id()) + .orElseThrow(() -> error(HttpStatus.NOT_FOUND, "TASK_CARD_NOT_FOUND", "任务卡不存在。")); + ReservationTaskAvailabilityResult availability = calculateAvailability(task); + List fields = buildFieldResults(task, taskCard); + return new ReservationTaskDetailResult( + task.id().toString(), + task.orderId().toString(), + task.sourceMessageId().toString(), + task.systemTaskType(), + task.taskCardType(), + task.taskStatus(), + taskCard.fieldContractVersion(), + availability, + fields); + } + + /** + * 将 Fallback / manual_review 任务转换为用户选择的系统任务类型,并记录审计。 + */ + @Override + @Transactional + public ManualReviewConversionResult convertManualReviewTask(Long taskId, ManualReviewConversionRequest request) { + ReservationTaskSnapshot task = findTaskOrThrow(taskId); + if (!ReservationSystemTaskType.MANUAL_REVIEW.name().equals(task.systemTaskType())) { + throw error(HttpStatus.BAD_REQUEST, "TASK_NOT_MANUAL_REVIEW", "只有人工复核任务允许转换。"); + } + TargetTaskType targetTaskType = parseTargetTaskType(request == null ? null : request.targetTaskType()); + ReservationOrderSnapshot originalOrder = workflowRepository.findOrderById(task.hotelId(), task.orderId()) + .orElseThrow(() -> error(HttpStatus.NOT_FOUND, "ORDER_NOT_FOUND", "原订单不存在。")); + ReservationOrderSnapshot targetOrder = resolveTargetOrder(task, originalOrder, targetTaskType, request); + LocalDateTime now = nowUtc(); + updateTaskForManualConversionWithQueueRetry(task, targetOrder, targetTaskType, now); + boolean originalOrderLogicDeleted = logicDeleteOriginalTemporaryOrderIfEmpty(task, originalOrder, targetOrder, now); + writeManualConversionAudit(task, originalOrder, targetOrder, targetTaskType, request, originalOrderLogicDeleted, now); + return new ManualReviewConversionResult( + task.id().toString(), + targetOrder.id().toString(), + originalOrder.id().toString(), + targetTaskType.systemTaskType().name(), + targetTaskType.taskCardType().name(), + task.taskStatus(), + originalOrderLogicDeleted); + } + + /** + * 更新人工复核任务;迁移到其他订单时,如果并发撞到队列序号则重新取号重试。 + */ + private void updateTaskForManualConversionWithQueueRetry( + ReservationTaskSnapshot task, + ReservationOrderSnapshot targetOrder, + TargetTaskType targetTaskType, + LocalDateTime now) { + for (int attempt = 1; attempt <= MAX_QUEUE_ORDER_RETRY; attempt++) { + int targetExecutionOrder = targetOrder.id().equals(task.orderId()) + ? task.executionOrder() + : workflowRepository.nextExecutionOrder(task.hotelId(), targetOrder.id()); + try { + workflowRepository.updateTaskForManualConversion( + task.hotelId(), + task.id(), + targetOrder.id(), + targetTaskType.aiTaskType(), + targetTaskType.systemTaskType().name(), + targetTaskType.taskCardType().name(), + targetExecutionOrder, + now); + return; + } catch (DuplicateKeyException exception) { + if (targetOrder.id().equals(task.orderId()) || attempt == MAX_QUEUE_ORDER_RETRY) { + throw error(HttpStatus.CONFLICT, "TASK_QUEUE_ORDER_CONFLICT", "同订单任务队列序号并发写入冲突。"); + } + } + } + throw error(HttpStatus.CONFLICT, "TASK_QUEUE_ORDER_CONFLICT", "同订单任务队列序号并发写入冲突。"); + } + + /** + * 查询任务,不存在时抛出受控 404。 + */ + private ReservationTaskSnapshot findTaskOrThrow(Long taskId) { + if (taskId == null) { + throw error(HttpStatus.NOT_FOUND, "TASK_NOT_FOUND", "任务不存在。"); + } + return workflowRepository.findTaskById(taskId) + .orElseThrow(() -> error(HttpStatus.NOT_FOUND, "TASK_NOT_FOUND", "任务不存在。")); + } + + /** + * 计算任务可处理状态。FAILED 和 COMPLETED 视为前置任务结束,不阻塞后续任务。 + */ + private ReservationTaskAvailabilityResult calculateAvailability(ReservationTaskSnapshot task) { + if (!Boolean.TRUE.equals(task.queueParticipation())) { + return new ReservationTaskAvailabilityResult(false, true, false, false, false, null, null); + } + ReservationTaskSnapshot blockingTask = workflowRepository + .findQueueTasksBefore(task.hotelId(), task.orderId(), task.executionOrder()) + .stream() + .filter(this::isBlockingTask) + .findFirst() + .orElse(null); + if (blockingTask != null) { + return new ReservationTaskAvailabilityResult( + true, + true, + false, + false, + false, + blockingTask.id().toString(), + "同订单前置任务未完成。"); + } + boolean pendingConfirm = ReservationTaskStatus.PENDING_CONFIRM.name().equals(task.taskStatus()); + boolean ready = ReservationTaskStatus.READY.name().equals(task.taskStatus()); + return new ReservationTaskAvailabilityResult(false, false, pendingConfirm, pendingConfirm, ready, null, null); + } + + /** + * 判断前置任务是否仍阻塞后续任务。 + */ + private boolean isBlockingTask(ReservationTaskSnapshot task) { + return !ReservationTaskStatus.COMPLETED.name().equals(task.taskStatus()) + && !ReservationTaskStatus.FAILED.name().equals(task.taskStatus()); + } + + /** + * 根据任务卡矩阵和 AI 原始快照组装详情字段。 + */ + private List buildFieldResults( + ReservationTaskSnapshot task, + ReservationTaskCardSnapshot taskCard) { + JsonNode aiPayload = parseJson(taskCard.aiPayloadJson()); + return fieldDefinitionProvider.listDefinitions(task.taskCardType(), task.resultType()) + .stream() + .map(definition -> toFieldResult(definition, aiPayload)) + .toList(); + } + + /** + * 将字段定义转换为详情字段结果,并从 AI payload 中读取当前值。 + */ + private ReservationTaskFieldResult toFieldResult(ReservationTaskCardFieldDefinition definition, JsonNode aiPayload) { + return new ReservationTaskFieldResult( + definition.rowNumber(), + definition.cardName(), + definition.displayArea(), + definition.fieldPath(), + definition.displayName(), + definition.visible(), + definition.editable(), + definition.inputEditable(), + definition.selectEditable(), + definition.datePicker(), + definition.numberInput(), + definition.fileDisplay(), + definition.tableEditable(), + definition.enumOptions(), + definition.requiredRule(), + definition.displayCondition(), + definition.validationRule(), + definition.writePath(), + definition.operaWriteParticipation(), + definition.operaParameterMapping(), + definition.notes(), + valueAt(aiPayload, definition.fieldPath())); + } + + /** + * 从 JSON 中按简单点路径读取值,支持矩阵中的数组路径后缀。 + */ + private Object valueAt(JsonNode root, String fieldPath) { + if (root == null || fieldPath == null || fieldPath.isBlank()) { + return null; + } + JsonNode current = root; + for (String rawPart : fieldPath.split("\\.")) { + String part = rawPart.replace("[]", ""); + current = current.path(part); + if (current.isMissingNode() || current.isNull()) { + return null; + } + } + return objectMapper.convertValue(current, Object.class); + } + + /** + * 解析任务卡 AI JSON 快照。 + */ + private JsonNode parseJson(String json) { + try { + return objectMapper.readTree(json == null ? "{}" : json); + } catch (Exception exception) { + throw error(HttpStatus.INTERNAL_SERVER_ERROR, "TASK_CARD_JSON_INVALID", "任务卡 JSON 无效。"); + } + } + + /** + * 解析人工转换目标类型,只允许 New、Update 和 Cancel。 + */ + private TargetTaskType parseTargetTaskType(String rawTargetTaskType) { + String targetTaskType = trimToNull(rawTargetTaskType); + if (targetTaskType == null) { + throw error(HttpStatus.BAD_REQUEST, "TARGET_TASK_TYPE_REQUIRED", "target_task_type 必填。"); + } + return switch (targetTaskType) { + case "NEW_BOOKING" -> new TargetTaskType( + ReservationSystemTaskType.NEW_BOOKING, + ReservationTaskCardType.NEW_BOOKING, + "New Booking"); + case "UPDATE_BOOKING" -> new TargetTaskType( + ReservationSystemTaskType.UPDATE_BOOKING, + ReservationTaskCardType.UPDATE_BOOKING, + "Update Booking"); + case "CANCEL_BOOKING" -> new TargetTaskType( + ReservationSystemTaskType.CANCEL_BOOKING, + ReservationTaskCardType.CANCEL_BOOKING, + "Cancel Booking"); + default -> throw error(HttpStatus.BAD_REQUEST, "TARGET_TASK_TYPE_UNSUPPORTED", "target_task_type 不支持。"); + }; + } + + /** + * 解析转换后的目标订单。Update / Cancel 必须显式选择目标订单。 + */ + private ReservationOrderSnapshot resolveTargetOrder( + ReservationTaskSnapshot task, + ReservationOrderSnapshot originalOrder, + TargetTaskType targetTaskType, + ManualReviewConversionRequest request) { + if (targetTaskType.systemTaskType() == ReservationSystemTaskType.NEW_BOOKING) { + return resolveNewBookingTargetOrder(task, originalOrder); + } + String rawTargetOrderId = request == null ? null : trimToNull(request.targetOrderId()); + if (rawTargetOrderId == null) { + throw error(HttpStatus.BAD_REQUEST, "TARGET_ORDER_REQUIRED", "转为 Update 或 Cancel 时 target_order_id 必填。"); + } + Long targetOrderId = parseLong(rawTargetOrderId, "TARGET_ORDER_INVALID", "target_order_id 格式无效。"); + ReservationOrderSnapshot targetOrder = workflowRepository.findOrderById(task.hotelId(), targetOrderId) + .orElseThrow(() -> error(HttpStatus.NOT_FOUND, "TARGET_ORDER_NOT_FOUND", "目标订单不存在。")); + if (ReservationOrderStatus.LOGIC_DELETED.name().equals(targetOrder.orderStatus())) { + throw error(HttpStatus.BAD_REQUEST, "TARGET_ORDER_INVALID", "目标订单已逻辑删除。"); + } + return targetOrder; + } + + /** + * 解析 Fallback 转 New Booking 的目标订单。已有业务号时优先激活原临时订单,不等待 OPERA 模拟回填。 + */ + private ReservationOrderSnapshot resolveNewBookingTargetOrder( + ReservationTaskSnapshot task, + ReservationOrderSnapshot originalOrder) { + if (!ReservationOrderStatus.TEMPORARY.name().equals(originalOrder.orderStatus())) { + return originalOrder; + } + BusinessKey businessKey = selectBusinessKeyFromTaskCard(task); + if (businessKey == null) { + return originalOrder; + } + ReservationOrderSnapshot existingActiveOrder = workflowRepository + .findActiveOrderByBusinessKey(task.hotelId(), businessKey.keyType().name(), businessKey.value()) + .orElse(null); + if (existingActiveOrder != null && !existingActiveOrder.id().equals(originalOrder.id())) { + throw error(HttpStatus.CONFLICT, "ORDER_ACTIVE_KEY_CONFLICT", "业务号已存在 ACTIVE 订单,请选择 Update 或 Cancel。"); + } + try { + boolean updated = workflowRepository.activateTemporaryOrderWithBusinessKey( + task.hotelId(), + originalOrder.id(), + businessKey.keyType().name(), + businessKey.value(), + ReservationBusinessKeySource.AI_CANDIDATE.name(), + nowUtc()); + if (!updated) { + return workflowRepository.findOrderById(task.hotelId(), originalOrder.id()) + .orElseThrow(() -> error(HttpStatus.NOT_FOUND, "ORDER_NOT_FOUND", "原订单不存在。")); + } + return workflowRepository.findOrderById(task.hotelId(), originalOrder.id()) + .orElseThrow(() -> error(HttpStatus.NOT_FOUND, "ORDER_NOT_FOUND", "原订单不存在。")); + } catch (DuplicateKeyException exception) { + throw error(HttpStatus.CONFLICT, "ORDER_ACTIVE_KEY_CONFLICT", "业务号已存在 ACTIVE 订单,请选择 Update 或 Cancel。"); + } + } + + /** + * 从任务卡 AI 原始 JSON 中读取订单业务号候选,当前只使用 case_keys 中的稳定路径。 + */ + private BusinessKey selectBusinessKeyFromTaskCard(ReservationTaskSnapshot task) { + ReservationTaskCardSnapshot taskCard = workflowRepository + .findTaskCardByTaskId(task.hotelId(), task.id()) + .orElseThrow(() -> error(HttpStatus.NOT_FOUND, "TASK_CARD_NOT_FOUND", "任务卡不存在。")); + JsonNode aiPayload = parseJson(taskCard.aiPayloadJson()); + JsonNode caseKeys = aiPayload.path("case_keys"); + String groupCode = trimToNull(caseKeys.path("group_code").asText(null)); + if (groupCode != null) { + return new BusinessKey(ReservationOrderKeyType.GROUP_CODE, groupCode); + } + String confirmationNumber = trimToNull(caseKeys.path("confirmation_number").asText(null)); + if (confirmationNumber != null) { + return new BusinessKey(ReservationOrderKeyType.CONFIRMATION_NUMBER, confirmationNumber); + } + return null; + } + + /** + * 任务迁移后,如果原临时订单已经没有任务,则逻辑删除原订单。 + */ + private boolean logicDeleteOriginalTemporaryOrderIfEmpty( + ReservationTaskSnapshot task, + ReservationOrderSnapshot originalOrder, + ReservationOrderSnapshot targetOrder, + LocalDateTime now) { + if (originalOrder.id().equals(targetOrder.id())) { + return false; + } + if (!ReservationOrderStatus.TEMPORARY.name().equals(originalOrder.orderStatus())) { + return false; + } + if (workflowRepository.countTasksByOrder(task.hotelId(), originalOrder.id()) > 0) { + return false; + } + return workflowRepository.logicDeleteOrder( + task.hotelId(), + originalOrder.id(), + "人工复核任务已迁移到目标订单。", + now); + } + + /** + * 写入人工转换审计,记录前后摘要和可选原因。 + */ + private void writeManualConversionAudit( + ReservationTaskSnapshot task, + ReservationOrderSnapshot originalOrder, + ReservationOrderSnapshot targetOrder, + TargetTaskType targetTaskType, + ManualReviewConversionRequest request, + boolean originalOrderLogicDeleted, + LocalDateTime now) { + workflowRepository.insertAuditLog(new ReservationAuditLogDraft( + task.hotelId(), + targetOrder.id(), + task.id(), + null, + ACTOR_TYPE_USER, + ACTOR_ID_LOCAL, + ACTION_MANUAL_REVIEW_CONVERT, + request == null ? null : trimToNull(request.reason()), + toJson(Map.of( + "order_id", task.orderId().toString(), + "system_task_type", task.systemTaskType(), + "task_card_type", task.taskCardType())), + toJson(Map.of( + "order_id", targetOrder.id().toString(), + "system_task_type", targetTaskType.systemTaskType().name(), + "task_card_type", targetTaskType.taskCardType().name(), + "original_order_id", originalOrder.id().toString(), + "original_order_logic_deleted", originalOrderLogicDeleted)), + now)); + } + + /** + * 将审计摘要转换为 JSON 字符串。 + */ + private String toJson(Map snapshot) { + try { + return objectMapper.writeValueAsString(snapshot); + } catch (Exception exception) { + throw error(HttpStatus.INTERNAL_SERVER_ERROR, "AUDIT_JSON_INVALID", "审计摘要生成失败。"); + } + } + + /** + * 解析 Long ID,避免无效 ID 进入 Repository。 + */ + private Long parseLong(String rawValue, String errorCode, String message) { + try { + return Long.valueOf(rawValue); + } catch (NumberFormatException exception) { + throw error(HttpStatus.BAD_REQUEST, errorCode, message); + } + } + + /** + * 获取当前 UTC 时间。 + */ + private LocalDateTime nowUtc() { + return LocalDateTime.now(ZoneOffset.UTC); + } + + /** + * 将空白字符串转换为 null。 + */ + private String trimToNull(String value) { + if (value == null) { + return null; + } + String trimmed = value.trim(); + return trimmed.isEmpty() ? null : trimmed; + } + + /** + * 构建任务工作流受控异常。 + */ + private ReservationTaskWorkflowException error(HttpStatus status, String errorCode, String message) { + return new ReservationTaskWorkflowException(status, errorCode, message); + } + + /** + * 人工转换目标类型映射。 + */ + private record TargetTaskType( + ReservationSystemTaskType systemTaskType, + ReservationTaskCardType taskCardType, + String aiTaskType + ) { + } + + /** + * 订单业务号候选。 + */ + private record BusinessKey( + ReservationOrderKeyType keyType, + String value + ) { + } +} diff --git a/server/src/main/resources/application.yml b/server/src/main/resources/application.yml index d5b97a5..80da021 100644 --- a/server/src/main/resources/application.yml +++ b/server/src/main/resources/application.yml @@ -32,5 +32,12 @@ agentbus: enabled: ${AGENTBUS_CAPTURE_ENABLED:true} default-hotel-id: ${AGENTBUS_DEFAULT_HOTEL_ID:HOTEL-TEST} +superagent: + task-result: + hmac-secret: ${SUPERAGENT_TASK_RESULT_HMAC_SECRET:} + clock-skew-seconds: ${SUPERAGENT_TASK_RESULT_CLOCK_SKEW_SECONDS:300} + nonce-ttl-seconds: ${SUPERAGENT_TASK_RESULT_NONCE_TTL_SECONDS:600} + max-body-bytes: ${SUPERAGENT_TASK_RESULT_MAX_BODY_BYTES:1048576} + server: port: ${SERVER_PORT:8080} diff --git a/server/src/main/resources/db/migration/V3__create_reservation_ai_task_workflow.sql b/server/src/main/resources/db/migration/V3__create_reservation_ai_task_workflow.sql new file mode 100644 index 0000000..bd4a930 --- /dev/null +++ b/server/src/main/resources/db/migration/V3__create_reservation_ai_task_workflow.sql @@ -0,0 +1,142 @@ +-- M002 SuperAgent 入站防重放:记录已使用 nonce,避免签名请求被重复提交。 +CREATE TABLE integration_superagent_task_result_nonce ( + id BIGINT NOT NULL COMMENT 'Nonce 记录 ID', + client_id VARCHAR(128) NOT NULL COMMENT 'SuperAgent 调用方客户端 ID', + nonce VARCHAR(256) NOT NULL COMMENT '单次请求随机值,用于防重放', + expires_at DATETIME(6) NOT NULL COMMENT 'Nonce 过期 UTC 时间', + created_at DATETIME(6) NOT NULL COMMENT '记录创建 UTC 时间', + PRIMARY KEY (id), + UNIQUE KEY uk_superagent_nonce_client_nonce (client_id, nonce), + KEY idx_superagent_nonce_expires_at (expires_at) +) COMMENT='SuperAgent 任务结果入站 nonce 表,用于 HMAC 请求防重放'; + +-- M002 AI 任务结果接收批次:保存一次 SuperAgent 提交的顶层请求和幂等信息。 +CREATE TABLE workflow_reservation_ai_batch ( + id BIGINT NOT NULL COMMENT 'AI 任务结果接收批次 ID', + hotel_id VARCHAR(64) NOT NULL COMMENT '酒店或业务上下文 ID', + source_message_id BIGINT NOT NULL COMMENT '关联 SourceMessage Inbox ID', + request_payload_sha256 CHAR(64) NOT NULL COMMENT '原始请求体 SHA-256,用于幂等和排查', + batch_idempotency_key CHAR(64) NOT NULL COMMENT '系统生成批次幂等键', + client_id VARCHAR(128) NOT NULL COMMENT 'SuperAgent 调用方客户端 ID', + request_id VARCHAR(128) NULL COMMENT '调用方请求 ID,用于日志串联', + received_at DATETIME(6) NOT NULL COMMENT '本系统接收 UTC 时间', + item_count INT NOT NULL COMMENT '本批次 AI task result 数量', + extraction_warnings_json LONGTEXT NULL COMMENT 'AI 抽取警告 JSON', + created_at DATETIME(6) NOT NULL COMMENT '记录创建 UTC 时间', + updated_at DATETIME(6) NOT NULL COMMENT '记录更新 UTC 时间', + PRIMARY KEY (id), + UNIQUE KEY uk_reservation_ai_batch_idempotency (hotel_id, batch_idempotency_key), + UNIQUE KEY uk_reservation_ai_batch_source (hotel_id, source_message_id), + KEY idx_reservation_ai_batch_received_at (hotel_id, received_at) +) COMMENT='Reservation AI 任务结果接收批次表,保存 SuperAgent 入站请求的幂等和追溯信息'; + +-- M002 AI 过渡层:一条 ai_task_results item 对应一行,保留 AI 原始 JSON 和查询冗余列。 +CREATE TABLE workflow_reservation_ai_transition ( + id BIGINT NOT NULL COMMENT 'AI 过渡记录 ID', + hotel_id VARCHAR(64) NOT NULL COMMENT '酒店或业务上下文 ID', + batch_id BIGINT NOT NULL COMMENT 'AI 任务结果接收批次 ID', + source_message_id BIGINT NOT NULL COMMENT '关联 SourceMessage Inbox ID', + source_event_index INT NOT NULL COMMENT 'AI current 事件序号,按 AI 输出保存', + array_index INT NOT NULL COMMENT 'AI 返回列表中的顺序,从 1 开始', + execution_order INT NOT NULL COMMENT '映射到订单任务队列的初始顺序', + catalog_code VARCHAR(32) NOT NULL COMMENT 'Skill 目录代码,例如 S01、S02', + skill_id VARCHAR(128) NOT NULL COMMENT 'Skill 标识', + result_type VARCHAR(32) NOT NULL COMMENT 'AI 结果类型,例如 normal_task、manual_review、informational_message', + ai_task_type VARCHAR(64) NOT NULL COMMENT 'AI 原始任务类型,例如 New Booking、Voucher Received', + system_task_type VARCHAR(64) NOT NULL COMMENT '系统主任务类型,例如 NEW_BOOKING、UPDATE_BOOKING', + task_card_type VARCHAR(64) NOT NULL COMMENT '任务卡类型,例如 NEW_BOOKING、MESSAGE_NOTIFICATION', + task_subtype VARCHAR(128) NULL COMMENT '业务动作 subtype', + current_or_history VARCHAR(32) NULL COMMENT '当前或历史标识', + group_code VARCHAR(128) NULL COMMENT 'Group Code 候选,来源于 case_keys 或卡片字段', + confirmation_number VARCHAR(128) NULL COMMENT 'Confirmation Number 候选,来源于 case_keys 或卡片字段', + item_payload_sha256 CHAR(64) NOT NULL COMMENT '单个 AI item JSON SHA-256', + item_idempotency_key CHAR(64) NOT NULL COMMENT '系统生成 item 幂等键', + manual_reason_code VARCHAR(128) NULL COMMENT 'manual_review 原因码', + parent_source_event_index INT NULL COMMENT '父任务事件序号,用于联动任务', + linked_task_group_id VARCHAR(128) NULL COMMENT '联动任务组 ID', + blocked_until_parent_completed TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否等待父任务完成', + ai_payload_json LONGTEXT NOT NULL COMMENT 'AI 原始 item JSON,禁止被用户修改覆盖', + case_keys_json LONGTEXT NULL COMMENT '订单候选键 JSON', + extracted_fields_json LONGTEXT NULL COMMENT 'AI 抽取业务字段 JSON', + manual_review_json LONGTEXT NULL COMMENT '人工复核 JSON', + informational_message_json LONGTEXT NULL COMMENT '信息提醒 JSON', + attachments_json LONGTEXT NULL COMMENT '附件和文件引用 JSON', + context_used_json LONGTEXT NULL COMMENT 'AI 使用上下文 JSON', + created_at DATETIME(6) NOT NULL COMMENT '记录创建 UTC 时间', + updated_at DATETIME(6) NOT NULL COMMENT '记录更新 UTC 时间', + PRIMARY KEY (id), + UNIQUE KEY uk_reservation_ai_transition_item (hotel_id, item_idempotency_key), + KEY idx_reservation_ai_transition_source_event (hotel_id, source_message_id, source_event_index), + KEY idx_reservation_ai_transition_type (hotel_id, result_type, ai_task_type), + KEY idx_reservation_ai_transition_group_code (hotel_id, group_code), + KEY idx_reservation_ai_transition_confirmation (hotel_id, confirmation_number) +) COMMENT='Reservation AI 过渡表,保存每条 AI 任务结果的原始 JSON、映射类型和查询冗余列'; + +-- M002 订单:承载任务队列和临时订单归档容器。 +CREATE TABLE workflow_reservation_order ( + id BIGINT NOT NULL COMMENT '系统内部订单 ID', + hotel_id VARCHAR(64) NOT NULL COMMENT '酒店或业务上下文 ID', + order_key_type VARCHAR(32) NOT NULL COMMENT '业务号类型:GROUP_CODE、CONFIRMATION_NUMBER、TEMPORARY', + order_business_key VARCHAR(128) NULL COMMENT '真实业务号,例如 Group Code 或 Confirmation Number', + temporary_order_code VARCHAR(128) NOT NULL COMMENT '临时订单展示编号,即使有业务号也保留内部追踪编号', + order_status VARCHAR(32) NOT NULL COMMENT '订单状态:TEMPORARY、ACTIVE、ENDED、LOGIC_DELETED', + display_name VARCHAR(256) NOT NULL COMMENT '前端展示名称', + source_message_id BIGINT NOT NULL COMMENT '首次创建该订单的来源消息 ID', + created_from_task_id BIGINT NULL COMMENT '首次创建该订单的任务 ID,第一阶段允许为空', + ended_at DATETIME(6) NULL COMMENT '订单进入 ENDED 状态的 UTC 时间', + logic_deleted_at DATETIME(6) NULL COMMENT '逻辑删除 UTC 时间', + logic_deleted_reason VARCHAR(512) NULL COMMENT '逻辑删除原因', + version BIGINT NOT NULL DEFAULT 0 COMMENT '乐观锁版本', + created_at DATETIME(6) NOT NULL COMMENT '记录创建 UTC 时间', + updated_at DATETIME(6) NOT NULL COMMENT '记录更新 UTC 时间', + PRIMARY KEY (id), + UNIQUE KEY uk_reservation_order_temp_code (hotel_id, temporary_order_code), + KEY idx_reservation_order_status_time (hotel_id, order_status, updated_at), + KEY idx_reservation_order_business_key (hotel_id, order_key_type, order_business_key) +) COMMENT='Reservation 订单表,承载业务订单号、临时订单和任务队列归属'; + +-- M002 任务:保存 AI 映射后的系统任务、队列顺序和处理状态。 +CREATE TABLE workflow_reservation_task ( + id BIGINT NOT NULL COMMENT '系统任务 ID', + hotel_id VARCHAR(64) NOT NULL COMMENT '酒店或业务上下文 ID', + order_id BIGINT NOT NULL COMMENT '当前挂靠订单 ID', + source_message_id BIGINT NOT NULL COMMENT '来源消息 ID', + ai_transition_id BIGINT NOT NULL COMMENT 'AI 过渡记录 ID', + result_type VARCHAR(32) NOT NULL COMMENT 'AI 结果类型', + ai_task_type VARCHAR(64) NOT NULL COMMENT 'AI 原始任务类型', + system_task_type VARCHAR(64) NOT NULL COMMENT '系统主任务类型', + task_card_type VARCHAR(64) NOT NULL COMMENT '任务卡类型', + task_subtype VARCHAR(128) NULL COMMENT '业务动作 subtype', + task_status VARCHAR(32) NOT NULL COMMENT '任务状态:PENDING_CONFIRM、READY、EXECUTING、FAILED、COMPLETED', + queue_participation TINYINT(1) NOT NULL COMMENT '是否参与订单执行队列,Message Notification 为 0', + execution_order INT NOT NULL COMMENT '同订单执行顺序', + parent_task_id BIGINT NULL COMMENT '父任务 ID,用于联动任务', + parent_source_event_index INT NULL COMMENT '父任务 source event index', + linked_task_group_id VARCHAR(128) NULL COMMENT '联动任务组 ID', + blocked_until_parent_completed TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否等待父任务完成', + last_failure_reason VARCHAR(512) NULL COMMENT '最近失败原因摘要', + completed_at DATETIME(6) NULL COMMENT '任务完成 UTC 时间', + version BIGINT NOT NULL DEFAULT 0 COMMENT '乐观锁版本', + created_at DATETIME(6) NOT NULL COMMENT '记录创建 UTC 时间', + updated_at DATETIME(6) NOT NULL COMMENT '记录更新 UTC 时间', + PRIMARY KEY (id), + KEY idx_reservation_task_order_queue (hotel_id, order_id, queue_participation, execution_order), + KEY idx_reservation_task_status_time (hotel_id, task_status, updated_at), + KEY idx_reservation_task_source (hotel_id, source_message_id), + KEY idx_reservation_task_transition (hotel_id, ai_transition_id) +) COMMENT='Reservation 任务表,保存 AI 结果映射后的任务状态、任务卡类型和订单队列顺序'; + +-- M002 任务卡:CP1-3 只保存任务卡使用的 AI 原始 JSON 快照。 +CREATE TABLE workflow_reservation_task_card ( + id BIGINT NOT NULL COMMENT '任务卡 ID', + hotel_id VARCHAR(64) NOT NULL COMMENT '酒店或业务上下文 ID', + task_id BIGINT NOT NULL COMMENT '所属任务 ID', + task_card_type VARCHAR(64) NOT NULL COMMENT '任务卡类型', + field_contract_version VARCHAR(64) NOT NULL COMMENT '字段契约版本,第一版使用 code-v1', + ai_payload_json LONGTEXT NOT NULL COMMENT '任务卡使用的 AI 原始 JSON 快照', + created_at DATETIME(6) NOT NULL COMMENT '记录创建 UTC 时间', + updated_at DATETIME(6) NOT NULL COMMENT '记录更新 UTC 时间', + PRIMARY KEY (id), + UNIQUE KEY uk_reservation_task_card_task (hotel_id, task_id), + KEY idx_reservation_task_card_type (hotel_id, task_card_type) +) COMMENT='Reservation 任务卡表,CP1-3 保存任务卡原始 AI 快照'; diff --git a/server/src/main/resources/db/migration/V4__harden_reservation_task_queue_and_manual_conversion.sql b/server/src/main/resources/db/migration/V4__harden_reservation_task_queue_and_manual_conversion.sql new file mode 100644 index 0000000..52f5046 --- /dev/null +++ b/server/src/main/resources/db/migration/V4__harden_reservation_task_queue_and_manual_conversion.sql @@ -0,0 +1,44 @@ +-- M002 CP4:ACTIVE 业务订单唯一性和后续 New Booking 真实业务号回填设计。 +ALTER TABLE workflow_reservation_order + ADD COLUMN active_business_key VARCHAR(128) NULL COMMENT 'ACTIVE 订单唯一约束辅助键,ACTIVE 业务订单时等于真实业务号,其他状态为空'; + +ALTER TABLE workflow_reservation_order + ADD COLUMN business_key_source VARCHAR(64) NULL COMMENT '业务号来源:AI_CANDIDATE、USER_CONFIRMED、OPERA_SIMULATION_RESULT'; + +ALTER TABLE workflow_reservation_order + ADD COLUMN business_key_backfilled_at DATETIME(6) NULL COMMENT 'New Booking 成功后回填真实业务号的 UTC 时间'; + +UPDATE workflow_reservation_order +SET active_business_key = order_business_key, + business_key_source = 'AI_CANDIDATE' +WHERE order_status = 'ACTIVE' + AND order_key_type IN ('GROUP_CODE', 'CONFIRMATION_NUMBER') + AND order_business_key IS NOT NULL; + +CREATE UNIQUE INDEX uk_reservation_order_active_business_key + ON workflow_reservation_order (hotel_id, order_key_type, active_business_key); + +-- M002 CP4:同一订单、同一队列参与类型下执行序号必须唯一,避免并发创建任务绕过前置任务阻塞规则。 +CREATE UNIQUE INDEX uk_reservation_task_queue_order + ON workflow_reservation_task (hotel_id, order_id, queue_participation, execution_order); + +-- M002 CP4:人工复核转换、订单迁移和临时订单逻辑删除审计。 +CREATE TABLE workflow_reservation_audit_log ( + id BIGINT NOT NULL COMMENT '审计 ID', + hotel_id VARCHAR(64) NOT NULL COMMENT '酒店或业务上下文 ID', + order_id BIGINT NULL COMMENT '订单 ID,可为空', + task_id BIGINT NULL COMMENT '任务 ID,可为空', + operation_id BIGINT NULL COMMENT 'OPERA 模拟操作 ID,当前阶段为空', + actor_type VARCHAR(32) NOT NULL COMMENT '操作人类型:SYSTEM、USER、SUPERAGENT', + actor_id VARCHAR(128) NOT NULL COMMENT '操作人标识', + action VARCHAR(128) NOT NULL COMMENT '操作类型,例如 MANUAL_REVIEW_CONVERT', + reason VARCHAR(512) NULL COMMENT '操作原因,第一版允许为空', + before_snapshot_json LONGTEXT NULL COMMENT '变更前摘要 JSON,不保存 Secret 或完整原文', + after_snapshot_json LONGTEXT NULL COMMENT '变更后摘要 JSON,不保存 Secret 或完整原文', + occurred_at DATETIME(6) NOT NULL COMMENT '业务动作发生 UTC 时间', + created_at DATETIME(6) NOT NULL COMMENT '审计记录创建 UTC 时间', + PRIMARY KEY (id), + KEY idx_reservation_audit_order_time (hotel_id, order_id, occurred_at), + KEY idx_reservation_audit_task_time (hotel_id, task_id, occurred_at), + KEY idx_reservation_audit_action_time (hotel_id, action, occurred_at) +) COMMENT='Reservation 订单任务审计表,记录人工转换、迁移和后续 OPERA 模拟动作'; diff --git a/server/src/main/resources/reservation-task-card/field-matrix-v20260706.json b/server/src/main/resources/reservation-task-card/field-matrix-v20260706.json new file mode 100644 index 0000000..145d53f --- /dev/null +++ b/server/src/main/resources/reservation-task-card/field-matrix-v20260706.json @@ -0,0 +1,4589 @@ +{ + "version": "code-v1", + "source": "docs/import/20260706/任务卡展示编辑矩阵.xlsx", + "row_count": 158, + "fields": [ + { + "card_name": "New Booking 卡", + "result_type": "normal_task", + "task_type": "New Booking", + "task_subtype": "new_fit_reservation / new_group_block / new_allotment_control_block", + "applicable_scenario": "normal task", + "display_area": "证据区", + "field_path": "visible_reason", + "display_name": "生成原因", + "field_source": "AI输出", + "visible": "是", + "editable": "否", + "input_editable": "否", + "select_editable": "否", + "date_picker": "否", + "number_input": "否", + "file_display": "否", + "table_editable": "否", + "enum_options": "-", + "required_rule": "是", + "default_value_source": "AI输出", + "display_condition": "始终展示", + "validation_rule": "只读", + "write_path": "ai_payload_json.visible_reason", + "opera_write_participation": "否", + "opera_parameter_mapping": "-", + "notes": "解释为什么生成该任务卡。", + "row_number": 3 + }, + { + "card_name": "New Booking 卡", + "result_type": "normal_task", + "task_type": "New Booking", + "task_subtype": "new_fit_reservation / new_group_block / new_allotment_control_block", + "applicable_scenario": "normal task", + "display_area": "证据区", + "field_path": "relevant_message_excerpt", + "display_name": "邮件片段", + "field_source": "AI输出", + "visible": "是", + "editable": "否", + "input_editable": "否", + "select_editable": "否", + "date_picker": "否", + "number_input": "否", + "file_display": "否", + "table_editable": "否", + "enum_options": "-", + "required_rule": "是", + "default_value_source": "body_current/current附件OCR", + "display_condition": "始终展示", + "validation_rule": "只读,必须来自 current", + "write_path": "ai_payload_json.relevant_message_excerpt", + "opera_write_participation": "否", + "opera_parameter_mapping": "-", + "notes": "用于人工核对参数来源。", + "row_number": 4 + }, + { + "card_name": "New Booking 卡", + "result_type": "normal_task", + "task_type": "New Booking", + "task_subtype": "new_fit_reservation / new_group_block / new_allotment_control_block", + "applicable_scenario": "normal task", + "display_area": "证据区", + "field_path": "attachments", + "display_name": "相关附件", + "field_source": "AI输出", + "visible": "是", + "editable": "否", + "input_editable": "否", + "select_editable": "否", + "date_picker": "否", + "number_input": "否", + "file_display": "是", + "table_editable": "否", + "enum_options": "-", + "required_rule": "否", + "default_value_source": "current事件相关附件", + "display_condition": "有附件时展示", + "validation_rule": "只读", + "write_path": "ai_payload_json.attachments", + "opera_write_participation": "否", + "opera_parameter_mapping": "-", + "notes": "附件、图片、PDF、表格证据展示。", + "row_number": 5 + }, + { + "card_name": "New Booking 卡", + "result_type": "normal_task", + "task_type": "New Booking", + "task_subtype": "new_fit_reservation / new_group_block / new_allotment_control_block", + "applicable_scenario": "normal task", + "display_area": "基础信息", + "field_path": "case_keys.group_code", + "display_name": "Group Code", + "field_source": "AI输出/人工确认", + "visible": "是", + "editable": "是", + "input_editable": "是", + "select_editable": "否", + "date_picker": "否", + "number_input": "否", + "file_display": "否", + "table_editable": "否", + "enum_options": "-", + "required_rule": "Group/Allotment条件必填", + "default_value_source": "AI提取值", + "display_condition": "对象为Group Block或Allotment时展示", + "validation_rule": "非空;一条结果只能一个group_code", + "write_path": "confirmed_payload_json.case_keys.group_code", + "opera_write_participation": "是", + "opera_parameter_mapping": "Group/Block/Allotment key", + "notes": "FIT 可为空。", + "row_number": 6 + }, + { + "card_name": "New Booking 卡", + "result_type": "normal_task", + "task_type": "New Booking", + "task_subtype": "new_fit_reservation", + "applicable_scenario": "normal task", + "display_area": "基础信息", + "field_path": "case_keys.confirmation_number", + "display_name": "Confirmation No.", + "field_source": "AI输出/人工确认", + "visible": "是", + "editable": "是", + "input_editable": "是", + "select_editable": "否", + "date_picker": "否", + "number_input": "否", + "file_display": "否", + "table_editable": "否", + "enum_options": "-", + "required_rule": "FIT条件必填", + "default_value_source": "AI提取值", + "display_condition": "对象为FIT Reservation时展示", + "validation_rule": "非空或由确认后系统生成", + "write_path": "confirmed_payload_json.case_keys.confirmation_number", + "opera_write_participation": "是", + "opera_parameter_mapping": "FIT reservation identifier", + "notes": "新建FIT时若AI无法得知确认号,可确认后由Opera返回落底表。", + "row_number": 7 + }, + { + "card_name": "New Booking 卡", + "result_type": "normal_task", + "task_type": "New Booking", + "task_subtype": "new_fit_reservation / new_group_block / new_allotment_control_block", + "applicable_scenario": "normal task", + "display_area": "基础信息", + "field_path": "extracted_fields.booking_object_type", + "display_name": "订单对象类型", + "field_source": "AI输出/人工确认", + "visible": "是", + "editable": "是", + "input_editable": "否", + "select_editable": "是", + "date_picker": "否", + "number_input": "否", + "file_display": "否", + "table_editable": "否", + "enum_options": "FIT Reservation / Group Block / Allotment / Control Block", + "required_rule": "是", + "default_value_source": "AI判定", + "display_condition": "始终展示", + "validation_rule": "必须命中枚举值", + "write_path": "confirmed_payload_json.extracted_fields.booking_object_type", + "opera_write_participation": "是", + "opera_parameter_mapping": "决定调用FIT/Group/Allotment创建流程", + "notes": "S01 明确 parent-to-child 场景使用 booking_object_type;普通新建对象类型若未输出,可用 candidate_object_type 兼容。", + "row_number": 8 + }, + { + "card_name": "New Booking 卡", + "result_type": "normal_task", + "task_type": "New Booking", + "task_subtype": "new_fit_reservation / new_group_block / new_allotment_control_block", + "applicable_scenario": "normal task", + "display_area": "入住信息", + "field_path": "extracted_fields.arrival_date", + "display_name": "入住日期", + "field_source": "AI输出/人工确认", + "visible": "是", + "editable": "是", + "input_editable": "否", + "select_editable": "否", + "date_picker": "是", + "number_input": "否", + "file_display": "否", + "table_editable": "否", + "enum_options": "-", + "required_rule": "是", + "default_value_source": "AI提取值", + "display_condition": "始终展示", + "validation_rule": "合法日期;arrival < departure", + "write_path": "confirmed_payload_json.extracted_fields.arrival_date", + "opera_write_participation": "是", + "opera_parameter_mapping": "Opera arrivalDate", + "notes": "日期选择器。", + "row_number": 9 + }, + { + "card_name": "New Booking 卡", + "result_type": "normal_task", + "task_type": "New Booking", + "task_subtype": "new_fit_reservation / new_group_block / new_allotment_control_block", + "applicable_scenario": "normal task", + "display_area": "入住信息", + "field_path": "extracted_fields.departure_date", + "display_name": "离店日期", + "field_source": "AI输出/人工确认", + "visible": "是", + "editable": "是", + "input_editable": "否", + "select_editable": "否", + "date_picker": "是", + "number_input": "否", + "file_display": "否", + "table_editable": "否", + "enum_options": "-", + "required_rule": "是", + "default_value_source": "AI提取值", + "display_condition": "始终展示", + "validation_rule": "合法日期;departure > arrival", + "write_path": "confirmed_payload_json.extracted_fields.departure_date", + "opera_write_participation": "是", + "opera_parameter_mapping": "Opera departureDate", + "notes": "日期选择器。", + "row_number": 10 + }, + { + "card_name": "New Booking 卡", + "result_type": "normal_task", + "task_type": "New Booking", + "task_subtype": "new_fit_reservation / new_group_block / new_allotment_control_block", + "applicable_scenario": "normal task", + "display_area": "房量房型", + "field_path": "extracted_fields.room_quantity", + "display_name": "房量", + "field_source": "AI输出/人工确认", + "visible": "是", + "editable": "是", + "input_editable": "是", + "select_editable": "否", + "date_picker": "否", + "number_input": "是", + "file_display": "否", + "table_editable": "否", + "enum_options": "-", + "required_rule": "是", + "default_value_source": "AI提取值", + "display_condition": "始终展示", + "validation_rule": "正整数;extra bed 不计入房量", + "write_path": "confirmed_payload_json.extracted_fields.room_quantity", + "opera_write_participation": "是", + "opera_parameter_mapping": "Opera room quantity/block allocation quantity", + "notes": "数字输入。", + "row_number": 11 + }, + { + "card_name": "New Booking 卡", + "result_type": "normal_task", + "task_type": "New Booking", + "task_subtype": "new_fit_reservation / new_group_block / new_allotment_control_block", + "applicable_scenario": "normal task", + "display_area": "房量房型", + "field_path": "extracted_fields.room_type", + "display_name": "房型原文", + "field_source": "AI输出/人工确认", + "visible": "是", + "editable": "是", + "input_editable": "是", + "select_editable": "否", + "date_picker": "否", + "number_input": "否", + "file_display": "否", + "table_editable": "否", + "enum_options": "-", + "required_rule": "是", + "default_value_source": "AI提取值", + "display_condition": "始终展示", + "validation_rule": "文本非空;保留原始房型线索", + "write_path": "confirmed_payload_json.extracted_fields.room_type", + "opera_write_participation": "是", + "opera_parameter_mapping": "房型映射输入", + "notes": "可人工修正原文。", + "row_number": 12 + }, + { + "card_name": "New Booking 卡", + "result_type": "normal_task", + "task_type": "New Booking", + "task_subtype": "new_fit_reservation / new_group_block / new_allotment_control_block", + "applicable_scenario": "normal task", + "display_area": "房量房型", + "field_path": "extracted_fields.pms_room_type_code", + "display_name": "Opera房型代码", + "field_source": "AI输出/人工确认", + "visible": "是", + "editable": "是", + "input_editable": "否", + "select_editable": "是", + "date_picker": "否", + "number_input": "否", + "file_display": "否", + "table_editable": "否", + "enum_options": "RM2 / RM3 / RM4 / SU1 / SU3 / SU6", + "required_rule": "是", + "default_value_source": "room_type_mapping_rules唯一命中", + "display_condition": "涉及房型时展示", + "validation_rule": "必须命中房型映射规则或人工确认", + "write_path": "confirmed_payload_json.extracted_fields.pms_room_type_code", + "opera_write_participation": "是", + "opera_parameter_mapping": "Opera roomTypeCode", + "notes": "SUITE无床型时可能标记 requires_downstream_hard_validation。", + "row_number": 13 + }, + { + "card_name": "New Booking 卡", + "result_type": "normal_task", + "task_type": "New Booking", + "task_subtype": "new_fit_reservation / new_group_block / new_allotment_control_block", + "applicable_scenario": "normal task", + "display_area": "价格", + "field_path": "extracted_fields.rate_code_result.rate_code", + "display_name": "Rate Code", + "field_source": "AI输出/人工确认", + "visible": "是", + "editable": "是", + "input_editable": "否", + "select_editable": "是", + "date_picker": "否", + "number_input": "否", + "file_display": "否", + "table_editable": "否", + "enum_options": "GRPA1-900 / GRPA2-850UP / GRP1-900 / WHO1-850UP / WHO2-1000 / WHO3-1200 / GL2000B / GL2200B / GL2300B / GL2300KR / GL2400TW / GL2100KR / GL2200KR / GLSPCB / GGV / GDISPC / LBSM / LBMS / LDCH25 / WHKR2100B / WHKR2200B / LBAJ / LDWT / WHOG1 / WHOG2 / WHOG3 / WHOG4 / null(手工结算价)", + "required_rule": "条件必填", + "default_value_source": "rate_code_rules命中;手工价场景可为null", + "display_condition": "涉及Rate Code时展示", + "validation_rule": "命中枚举或为null且手工价必填", + "write_path": "confirmed_payload_json.extracted_fields.rate_code_result.rate_code", + "opera_write_participation": "是", + "opera_parameter_mapping": "Opera rateCode 或手工价格流程", + "notes": "不要只根据PMS房型反推Rate Code。", + "row_number": 14 + }, + { + "card_name": "New Booking 卡", + "result_type": "normal_task", + "task_type": "New Booking", + "task_subtype": "new_fit_reservation / new_group_block / new_allotment_control_block", + "applicable_scenario": "normal task", + "display_area": "价格", + "field_path": "extracted_fields.rate_code_result.settlement_price", + "display_name": "结算价", + "field_source": "AI输出/人工确认", + "visible": "是", + "editable": "是", + "input_editable": "是", + "select_editable": "否", + "date_picker": "否", + "number_input": "是", + "file_display": "否", + "table_editable": "否", + "enum_options": "-", + "required_rule": "条件必填", + "default_value_source": "AI提取/规则计算", + "display_condition": "需要价格确认时展示", + "validation_rule": "非负数;手工价场景必填", + "write_path": "confirmed_payload_json.extracted_fields.rate_code_result.settlement_price", + "opera_write_participation": "是", + "opera_parameter_mapping": "Opera price/手工结算价", + "notes": "数字输入。", + "row_number": 15 + }, + { + "card_name": "New Booking 卡", + "result_type": "normal_task", + "task_type": "New Booking", + "task_subtype": "new_fit_reservation / new_group_block / new_allotment_control_block", + "applicable_scenario": "normal task", + "display_area": "Fix Charge", + "field_path": "extracted_fields.fix_charge_required", + "display_name": "是否需要Fix Charge", + "field_source": "AI输出", + "visible": "是", + "editable": "否", + "input_editable": "否", + "select_editable": "否", + "date_picker": "否", + "number_input": "否", + "file_display": "否", + "table_editable": "否", + "enum_options": "true / false", + "required_rule": "否", + "default_value_source": "AI根据复合单价判断", + "display_condition": "有复合单价或fix_charge_items时展示", + "validation_rule": "只读", + "write_path": "confirmed_payload_json.extracted_fields.fix_charge_required", + "opera_write_participation": "条件参与", + "opera_parameter_mapping": "触发后续 create_fix_charge", + "notes": "AI不直接执行Fix Charge。", + "row_number": 16 + }, + { + "card_name": "New Booking 卡", + "result_type": "normal_task", + "task_type": "New Booking", + "task_subtype": "new_fit_reservation / new_group_block / new_allotment_control_block", + "applicable_scenario": "normal task", + "display_area": "Fix Charge", + "field_path": "extracted_fields.fix_charge_items[]", + "display_name": "Fix Charge明细", + "field_source": "AI输出/人工确认", + "visible": "是", + "editable": "是", + "input_editable": "否", + "select_editable": "否", + "date_picker": "否", + "number_input": "是", + "file_display": "否", + "table_editable": "是", + "enum_options": "charge_type=additional_charge;currency=THB;pricing_mode=unit_price", + "required_rule": "条件必填", + "default_value_source": "复合单价+后组件", + "display_condition": "fix_charge_required=true时展示", + "validation_rule": "amount为非负数;quantity/total_amount可空", + "write_path": "confirmed_payload_json.extracted_fields.fix_charge_items", + "opera_write_participation": "条件参与", + "opera_parameter_mapping": "后续Fix Charge API参数", + "notes": "表格编辑;主订单创建成功后执行。", + "row_number": 17 + }, + { + "card_name": "New Booking 卡", + "result_type": "normal_task", + "task_type": "New Booking", + "task_subtype": "new_allotment_control_block", + "applicable_scenario": "normal task", + "display_area": "配合房/控房", + "field_path": "extracted_fields.parent_group_code", + "display_name": "父Group Code", + "field_source": "AI输出", + "visible": "是", + "editable": "否", + "input_editable": "否", + "select_editable": "否", + "date_picker": "否", + "number_input": "否", + "file_display": "否", + "table_editable": "否", + "enum_options": "-", + "required_rule": "条件必填", + "default_value_source": "body_current", + "display_condition": "allocation_split_from_parent=true时展示", + "validation_rule": "只读;不作为本任务case_keys.group_code", + "write_path": "ai_payload_json.extracted_fields.parent_group_code", + "opera_write_participation": "否", + "opera_parameter_mapping": "后台硬校验", + "notes": "用于 parent-to-child allocation 证据。", + "row_number": 18 + }, + { + "card_name": "New Booking 卡", + "result_type": "normal_task", + "task_type": "New Booking", + "task_subtype": "new_allotment_control_block", + "applicable_scenario": "normal task", + "display_area": "配合房/控房", + "field_path": "extracted_fields.child_room_items[]", + "display_name": "子团房量明细", + "field_source": "AI输出/人工确认", + "visible": "是", + "editable": "是", + "input_editable": "否", + "select_editable": "否", + "date_picker": "否", + "number_input": "是", + "file_display": "否", + "table_editable": "是", + "enum_options": "-", + "required_rule": "条件必填", + "default_value_source": "body_current child line", + "display_condition": "allocation_split_from_parent=true时展示", + "validation_rule": "每行房型/房量需可确认", + "write_path": "confirmed_payload_json.extracted_fields.child_room_items", + "opera_write_participation": "是", + "opera_parameter_mapping": "Allotment/Control Block创建明细", + "notes": "表格编辑。", + "row_number": 19 + }, + { + "card_name": "New Booking 卡", + "result_type": "normal_task", + "task_type": "New Booking", + "task_subtype": "QBD/LianTai row", + "applicable_scenario": "normal task", + "display_area": "表格证据", + "field_path": "extracted_fields.table_evidence", + "display_name": "表格行证据", + "field_source": "AI输出", + "visible": "是", + "editable": "否", + "input_editable": "否", + "select_editable": "否", + "date_picker": "否", + "number_input": "否", + "file_display": "否", + "table_editable": "否", + "enum_options": "-", + "required_rule": "表格来源必填", + "default_value_source": "AI输出", + "display_condition": "来自QBD/LianTai表格时展示", + "validation_rule": "只读", + "write_path": "ai_payload_json.extracted_fields.table_evidence", + "opera_write_participation": "否", + "opera_parameter_mapping": "-", + "notes": "用于证明一行一个任务。", + "row_number": 20 + }, + { + "card_name": "New Booking 人工复核卡", + "result_type": "manual_review", + "task_type": "New Booking", + "task_subtype": "manual_review", + "applicable_scenario": "manual task", + "display_area": "复核原因", + "field_path": "manual_review.reason_code", + "display_name": "复核原因码", + "field_source": "AI输出", + "visible": "是", + "editable": "否", + "input_editable": "否", + "select_editable": "否", + "date_picker": "否", + "number_input": "否", + "file_display": "否", + "table_editable": "否", + "enum_options": "EXISTING_ORDER_RECORD_FOUND / ROOM_TYPE_MAPPING_UNCONFIRMED / ROOM_TYPE_MAPPING_MULTIPLE_CANDIDATES / RATE_CODE_UNCONFIRMED / SETTLEMENT_PRICE_REQUIRED / MANUAL_PRICE_UNCONFIRMED / RATE_CODE_RULE_NOT_COVERED / COMPOSITE_UNIT_PRICE_UNCONFIRMED / FIX_CHARGE_UNCONFIRMED", + "required_rule": "是", + "default_value_source": "AI输出", + "display_condition": "result_type = manual_review", + "validation_rule": "只读;必须命中对应 skill reason_code", + "write_path": "ai_payload_json.manual_review.reason_code", + "opera_write_participation": "否", + "opera_parameter_mapping": "-", + "notes": "用于前端标签和人工复核分类。", + "row_number": 21 + }, + { + "card_name": "New Booking 人工复核卡", + "result_type": "manual_review", + "task_type": "New Booking", + "task_subtype": "manual_review", + "applicable_scenario": "manual task", + "display_area": "复核原因", + "field_path": "manual_review.visible_reason", + "display_name": "复核说明", + "field_source": "AI输出", + "visible": "是", + "editable": "否", + "input_editable": "否", + "select_editable": "否", + "date_picker": "否", + "number_input": "否", + "file_display": "否", + "table_editable": "否", + "enum_options": "-", + "required_rule": "是", + "default_value_source": "AI输出", + "display_condition": "result_type = manual_review", + "validation_rule": "只读", + "write_path": "ai_payload_json.manual_review.visible_reason", + "opera_write_participation": "否", + "opera_parameter_mapping": "-", + "notes": "S04 精简结构未显式要求 visible_reason;若缺失则用 visible_reason 顶层字段展示。", + "row_number": 22 + }, + { + "card_name": "New Booking 人工复核卡", + "result_type": "manual_review", + "task_type": "New Booking", + "task_subtype": "manual_review", + "applicable_scenario": "manual task", + "display_area": "复核原因", + "field_path": "manual_review.review_record_type", + "display_name": "复核记录类型", + "field_source": "AI输出", + "visible": "是", + "editable": "否", + "input_editable": "否", + "select_editable": "否", + "date_picker": "否", + "number_input": "否", + "file_display": "否", + "table_editable": "否", + "enum_options": "manual_review_required / manual_processing_required", + "required_rule": "否", + "default_value_source": "AI输出", + "display_condition": "除S04精简结构外展示", + "validation_rule": "只读", + "write_path": "ai_payload_json.manual_review.review_record_type", + "opera_write_participation": "否", + "opera_parameter_mapping": "-", + "notes": "S04 manual_review 精简结构未包含该字段,显示时需兼容为空。", + "row_number": 23 + }, + { + "card_name": "New Booking 人工复核卡", + "result_type": "manual_review", + "task_type": "New Booking", + "task_subtype": "manual_review", + "applicable_scenario": "manual task", + "display_area": "复核内容", + "field_path": "manual_review.blocking_points[]", + "display_name": "阻断点", + "field_source": "AI输出", + "visible": "是", + "editable": "否", + "input_editable": "否", + "select_editable": "否", + "date_picker": "否", + "number_input": "否", + "file_display": "否", + "table_editable": "否", + "enum_options": "-", + "required_rule": "是", + "default_value_source": "AI输出", + "display_condition": "result_type = manual_review", + "validation_rule": "只读数组", + "write_path": "ai_payload_json.manual_review.blocking_points", + "opera_write_participation": "否", + "opera_parameter_mapping": "-", + "notes": "告诉人工为什么不能自动处理。", + "row_number": 24 + }, + { + "card_name": "New Booking 人工复核卡", + "result_type": "manual_review", + "task_type": "New Booking", + "task_subtype": "manual_review", + "applicable_scenario": "manual task", + "display_area": "复核内容", + "field_path": "manual_review.missing_fields[]", + "display_name": "缺失字段", + "field_source": "AI输出", + "visible": "是", + "editable": "否", + "input_editable": "否", + "select_editable": "否", + "date_picker": "否", + "number_input": "否", + "file_display": "否", + "table_editable": "否", + "enum_options": "-", + "required_rule": "是", + "default_value_source": "AI输出", + "display_condition": "result_type = manual_review", + "validation_rule": "只读数组", + "write_path": "ai_payload_json.manual_review.missing_fields", + "opera_write_participation": "否", + "opera_parameter_mapping": "-", + "notes": "提示人工需要补哪些参数。", + "row_number": 25 + }, + { + "card_name": "New Booking 人工复核卡", + "result_type": "manual_review", + "task_type": "New Booking", + "task_subtype": "manual_review", + "applicable_scenario": "manual task", + "display_area": "复核内容", + "field_path": "manual_review.conflicting_points[]", + "display_name": "冲突点", + "field_source": "AI输出", + "visible": "是", + "editable": "否", + "input_editable": "否", + "select_editable": "否", + "date_picker": "否", + "number_input": "否", + "file_display": "否", + "table_editable": "否", + "enum_options": "-", + "required_rule": "否", + "default_value_source": "AI输出", + "display_condition": "有冲突时展示", + "validation_rule": "只读数组", + "write_path": "ai_payload_json.manual_review.conflicting_points", + "opera_write_participation": "否", + "opera_parameter_mapping": "-", + "notes": "显示邮件/表格/系统上下文冲突。", + "row_number": 26 + }, + { + "card_name": "New Booking 人工复核卡", + "result_type": "manual_review", + "task_type": "New Booking", + "task_subtype": "manual_review", + "applicable_scenario": "manual task", + "display_area": "复核内容", + "field_path": "manual_review.evidence_to_check[]", + "display_name": "建议核对证据", + "field_source": "AI输出", + "visible": "是", + "editable": "否", + "input_editable": "否", + "select_editable": "否", + "date_picker": "否", + "number_input": "否", + "file_display": "否", + "table_editable": "否", + "enum_options": "-", + "required_rule": "否", + "default_value_source": "AI输出", + "display_condition": "有证据建议时展示", + "validation_rule": "只读数组", + "write_path": "ai_payload_json.manual_review.evidence_to_check", + "opera_write_participation": "否", + "opera_parameter_mapping": "-", + "notes": "S04 精简结构不包含 evidence_to_check。", + "row_number": 27 + }, + { + "card_name": "New Booking 人工复核卡", + "result_type": "manual_review", + "task_type": "New Booking", + "task_subtype": "manual_review", + "applicable_scenario": "manual task", + "display_area": "复核内容", + "field_path": "manual_review.suggested_human_actions[]", + "display_name": "建议人工动作", + "field_source": "AI输出", + "visible": "是", + "editable": "否", + "input_editable": "否", + "select_editable": "否", + "date_picker": "否", + "number_input": "否", + "file_display": "否", + "table_editable": "否", + "enum_options": "-", + "required_rule": "否", + "default_value_source": "AI输出", + "display_condition": "有建议时展示", + "validation_rule": "只读数组", + "write_path": "ai_payload_json.manual_review.suggested_human_actions", + "opera_write_participation": "否", + "opera_parameter_mapping": "-", + "notes": "S04/S05 精简结构不包含 suggested_human_actions。", + "row_number": 28 + }, + { + "card_name": "New Booking 人工复核卡", + "result_type": "manual_review", + "task_type": "New Booking", + "task_subtype": "manual_review", + "applicable_scenario": "manual task", + "display_area": "已知字段", + "field_path": "extracted_fields.candidate_object_type", + "display_name": "候选对象类型", + "field_source": "AI输出", + "visible": "是", + "editable": "是", + "input_editable": "否", + "select_editable": "是", + "date_picker": "否", + "number_input": "否", + "file_display": "否", + "table_editable": "否", + "enum_options": "FIT Reservation / Group Block / Allotment / Control Block", + "required_rule": "否", + "default_value_source": "AI已知字段", + "display_condition": "有值时展示", + "validation_rule": "必须命中枚举或为空", + "write_path": "manual_resolution_payload.extracted_fields.candidate_object_type", + "opera_write_participation": "否", + "opera_parameter_mapping": "-", + "notes": "人工复核阶段可修正,但不直接触发Opera写入。", + "row_number": 29 + }, + { + "card_name": "Update Booking 卡", + "result_type": "normal_task", + "task_type": "Update Booking", + "task_subtype": "update_stay_dates / update_nights / update_room_type_or_quantity / update_guest_count / update_trace_or_guest_request / update_group_block_linkage / update_allotment_control_block / manual_rate_code_or_settlement_price / update_fix_charge", + "applicable_scenario": "normal task", + "display_area": "证据区", + "field_path": "visible_reason", + "display_name": "生成原因", + "field_source": "AI输出", + "visible": "是", + "editable": "否", + "input_editable": "否", + "select_editable": "否", + "date_picker": "否", + "number_input": "否", + "file_display": "否", + "table_editable": "否", + "enum_options": "-", + "required_rule": "是", + "default_value_source": "AI输出", + "display_condition": "始终展示", + "validation_rule": "只读", + "write_path": "ai_payload_json.visible_reason", + "opera_write_participation": "否", + "opera_parameter_mapping": "-", + "notes": "解释为什么生成该任务卡。", + "row_number": 30 + }, + { + "card_name": "Update Booking 卡", + "result_type": "normal_task", + "task_type": "Update Booking", + "task_subtype": "update_stay_dates / update_nights / update_room_type_or_quantity / update_guest_count / update_trace_or_guest_request / update_group_block_linkage / update_allotment_control_block / manual_rate_code_or_settlement_price / update_fix_charge", + "applicable_scenario": "normal task", + "display_area": "证据区", + "field_path": "relevant_message_excerpt", + "display_name": "邮件片段", + "field_source": "AI输出", + "visible": "是", + "editable": "否", + "input_editable": "否", + "select_editable": "否", + "date_picker": "否", + "number_input": "否", + "file_display": "否", + "table_editable": "否", + "enum_options": "-", + "required_rule": "是", + "default_value_source": "body_current/current附件OCR", + "display_condition": "始终展示", + "validation_rule": "只读,必须来自 current", + "write_path": "ai_payload_json.relevant_message_excerpt", + "opera_write_participation": "否", + "opera_parameter_mapping": "-", + "notes": "用于人工核对参数来源。", + "row_number": 31 + }, + { + "card_name": "Update Booking 卡", + "result_type": "normal_task", + "task_type": "Update Booking", + "task_subtype": "update_stay_dates / update_nights / update_room_type_or_quantity / update_guest_count / update_trace_or_guest_request / update_group_block_linkage / update_allotment_control_block / manual_rate_code_or_settlement_price / update_fix_charge", + "applicable_scenario": "normal task", + "display_area": "证据区", + "field_path": "attachments", + "display_name": "相关附件", + "field_source": "AI输出", + "visible": "是", + "editable": "否", + "input_editable": "否", + "select_editable": "否", + "date_picker": "否", + "number_input": "否", + "file_display": "是", + "table_editable": "否", + "enum_options": "-", + "required_rule": "否", + "default_value_source": "current事件相关附件", + "display_condition": "有附件时展示", + "validation_rule": "只读", + "write_path": "ai_payload_json.attachments", + "opera_write_participation": "否", + "opera_parameter_mapping": "-", + "notes": "附件、图片、PDF、表格证据展示。", + "row_number": 32 + }, + { + "card_name": "Update Booking 卡", + "result_type": "normal_task", + "task_type": "Update Booking", + "task_subtype": "update_stay_dates / update_nights / update_room_type_or_quantity / update_guest_count / update_trace_or_guest_request / update_group_block_linkage / update_allotment_control_block / manual_rate_code_or_settlement_price / update_fix_charge", + "applicable_scenario": "normal task", + "display_area": "基础信息", + "field_path": "case_keys.group_code", + "display_name": "Group Code", + "field_source": "AI输出/人工确认", + "visible": "是", + "editable": "是", + "input_editable": "是", + "select_editable": "否", + "date_picker": "否", + "number_input": "否", + "file_display": "否", + "table_editable": "否", + "enum_options": "-", + "required_rule": "Group类条件必填", + "default_value_source": "AI提取值", + "display_condition": "有Group目标时展示", + "validation_rule": "非空或使用confirmation_number", + "write_path": "confirmed_payload_json.case_keys.group_code", + "opera_write_participation": "是", + "opera_parameter_mapping": "目标对象key", + "notes": "用于查找既有对象。", + "row_number": 33 + }, + { + "card_name": "Update Booking 卡", + "result_type": "normal_task", + "task_type": "Update Booking", + "task_subtype": "update_stay_dates / update_nights / update_room_type_or_quantity / update_guest_count / update_trace_or_guest_request / update_group_block_linkage / update_allotment_control_block / manual_rate_code_or_settlement_price / update_fix_charge", + "applicable_scenario": "normal task", + "display_area": "基础信息", + "field_path": "case_keys.confirmation_number", + "display_name": "Confirmation No.", + "field_source": "AI输出/人工确认", + "visible": "是", + "editable": "是", + "input_editable": "是", + "select_editable": "否", + "date_picker": "否", + "number_input": "否", + "file_display": "否", + "table_editable": "否", + "enum_options": "-", + "required_rule": "FIT条件必填", + "default_value_source": "AI提取值", + "display_condition": "有FIT目标时展示", + "validation_rule": "非空或使用group_code", + "write_path": "confirmed_payload_json.case_keys.confirmation_number", + "opera_write_participation": "是", + "opera_parameter_mapping": "目标Reservation key", + "notes": "用于查找既有对象。", + "row_number": 34 + }, + { + "card_name": "Update Booking 卡", + "result_type": "normal_task", + "task_type": "Update Booking", + "task_subtype": "update_stay_dates / update_nights / update_room_type_or_quantity / update_guest_count / update_trace_or_guest_request / update_group_block_linkage / update_allotment_control_block / manual_rate_code_or_settlement_price / update_fix_charge", + "applicable_scenario": "normal task", + "display_area": "修改内容", + "field_path": "extracted_fields.update_subtypes[]", + "display_name": "修改类型", + "field_source": "AI输出/人工确认", + "visible": "是", + "editable": "是", + "input_editable": "否", + "select_editable": "是", + "date_picker": "否", + "number_input": "否", + "file_display": "否", + "table_editable": "否", + "enum_options": "update_stay_dates / update_nights / update_room_type_or_quantity / update_guest_count / update_trace_or_guest_request / update_group_block_linkage / update_allotment_control_block / manual_rate_code_or_settlement_price / update_fix_charge", + "required_rule": "是", + "default_value_source": "AI识别", + "display_condition": "始终展示", + "validation_rule": "至少一个 subtype", + "write_path": "confirmed_payload_json.extracted_fields.update_subtypes", + "opera_write_participation": "是", + "opera_parameter_mapping": "决定后续更新参数集合", + "notes": "多选下拉;subtype不拆Tool。", + "row_number": 35 + }, + { + "card_name": "Update Booking 卡", + "result_type": "normal_task", + "task_type": "Update Booking", + "task_subtype": "update_stay_dates / update_nights / update_room_type_or_quantity / update_guest_count / update_trace_or_guest_request / update_group_block_linkage / update_allotment_control_block / manual_rate_code_or_settlement_price / update_fix_charge", + "applicable_scenario": "normal task", + "display_area": "修改内容", + "field_path": "extracted_fields.before_after", + "display_name": "修改前/后", + "field_source": "AI输出/人工确认", + "visible": "是", + "editable": "是", + "input_editable": "否", + "select_editable": "否", + "date_picker": "否", + "number_input": "否", + "file_display": "否", + "table_editable": "是", + "enum_options": "-", + "required_rule": "条件必填", + "default_value_source": "AI建立before/after", + "display_condition": "有before/after修改时展示", + "validation_rule": "每项after不能为空;before可为空但需说明", + "write_path": "confirmed_payload_json.extracted_fields.before_after", + "opera_write_participation": "是", + "opera_parameter_mapping": "Opera update API参数来源", + "notes": "表格编辑,适配日期/晚数/房型/房量/人数等。", + "row_number": 36 + }, + { + "card_name": "Update Booking 卡", + "result_type": "normal_task", + "task_type": "Update Booking", + "task_subtype": "manual_rate_code_or_settlement_price / update_fix_charge", + "applicable_scenario": "normal task", + "display_area": "价格", + "field_path": "extracted_fields.rate_code_result", + "display_name": "Rate/结算价结果", + "field_source": "AI输出/人工确认", + "visible": "是", + "editable": "是", + "input_editable": "否", + "select_editable": "否", + "date_picker": "否", + "number_input": "否", + "file_display": "否", + "table_editable": "是", + "enum_options": "GRPA1-900 / GRPA2-850UP / GRP1-900 / WHO1-850UP / WHO2-1000 / WHO3-1200 / GL2000B / GL2200B / GL2300B / GL2300KR / GL2400TW / GL2100KR / GL2200KR / GLSPCB / GGV / GDISPC / LBSM / LBMS / LDCH25 / WHKR2100B / WHKR2200B / LBAJ / LDWT / WHOG1 / WHOG2 / WHOG3 / WHOG4 / null(手工结算价)", + "required_rule": "条件必填", + "default_value_source": "rate_code_rules", + "display_condition": "涉及价格/Rate Code时展示", + "validation_rule": "rate_code或settlement_price至少满足规则", + "write_path": "confirmed_payload_json.extracted_fields.rate_code_result", + "opera_write_participation": "是", + "opera_parameter_mapping": "Opera rate/price update", + "notes": "可拆成子字段展示:rate_code、settlement_price等。", + "row_number": 37 + }, + { + "card_name": "Update Booking 卡", + "result_type": "normal_task", + "task_type": "Update Booking", + "task_subtype": "update_fix_charge", + "applicable_scenario": "normal task", + "display_area": "Fix Charge", + "field_path": "extracted_fields.fix_charge_items[]", + "display_name": "Fix Charge明细", + "field_source": "AI输出/人工确认", + "visible": "是", + "editable": "是", + "input_editable": "否", + "select_editable": "否", + "date_picker": "否", + "number_input": "是", + "file_display": "否", + "table_editable": "是", + "enum_options": "charge_type=additional_charge;currency=THB;pricing_mode=unit_price", + "required_rule": "条件必填", + "default_value_source": "AI识别复合单价或明确Fix Charge维护", + "display_condition": "update_fix_charge或fix_charge_required=true时展示", + "validation_rule": "amount非负;动作类型需明确", + "write_path": "confirmed_payload_json.extracted_fields.fix_charge_items", + "opera_write_participation": "条件参与", + "opera_parameter_mapping": "后续Fix Charge维护API", + "notes": "S02 additional_operations.status=pending_target_order_id。", + "row_number": 38 + }, + { + "card_name": "Update Booking 卡", + "result_type": "normal_task", + "task_type": "Update Booking", + "task_subtype": "QBD/LianTai row", + "applicable_scenario": "normal task", + "display_area": "表格证据", + "field_path": "extracted_fields.table_evidence", + "display_name": "表格行证据", + "field_source": "AI输出", + "visible": "是", + "editable": "否", + "input_editable": "否", + "select_editable": "否", + "date_picker": "否", + "number_input": "否", + "file_display": "否", + "table_editable": "否", + "enum_options": "-", + "required_rule": "表格来源必填", + "default_value_source": "AI输出", + "display_condition": "来自QBD/LianTai表格时展示", + "validation_rule": "只读", + "write_path": "ai_payload_json.extracted_fields.table_evidence", + "opera_write_participation": "否", + "opera_parameter_mapping": "-", + "notes": "含before/after证据。", + "row_number": 39 + }, + { + "card_name": "Update Booking 人工复核卡", + "result_type": "manual_review", + "task_type": "Update Booking", + "task_subtype": "manual_review", + "applicable_scenario": "manual task", + "display_area": "复核原因", + "field_path": "manual_review.reason_code", + "display_name": "复核原因码", + "field_source": "AI输出", + "visible": "是", + "editable": "否", + "input_editable": "否", + "select_editable": "否", + "date_picker": "否", + "number_input": "否", + "file_display": "否", + "table_editable": "否", + "enum_options": "BEFORE_VALUE_UNCONFIRMED / ROOM_TYPE_MAPPING_UNCONFIRMED / ROOM_TYPE_MAPPING_MULTIPLE_CANDIDATES / RATE_CODE_UNCONFIRMED / SETTLEMENT_PRICE_REQUIRED / MANUAL_PRICE_UNCONFIRMED / RATE_CODE_RULE_NOT_COVERED / COMPOSITE_UNIT_PRICE_UNCONFIRMED / FIX_CHARGE_UNCONFIRMED", + "required_rule": "是", + "default_value_source": "AI输出", + "display_condition": "result_type = manual_review", + "validation_rule": "只读;必须命中对应 skill reason_code", + "write_path": "ai_payload_json.manual_review.reason_code", + "opera_write_participation": "否", + "opera_parameter_mapping": "-", + "notes": "用于前端标签和人工复核分类。", + "row_number": 40 + }, + { + "card_name": "Update Booking 人工复核卡", + "result_type": "manual_review", + "task_type": "Update Booking", + "task_subtype": "manual_review", + "applicable_scenario": "manual task", + "display_area": "复核原因", + "field_path": "manual_review.visible_reason", + "display_name": "复核说明", + "field_source": "AI输出", + "visible": "是", + "editable": "否", + "input_editable": "否", + "select_editable": "否", + "date_picker": "否", + "number_input": "否", + "file_display": "否", + "table_editable": "否", + "enum_options": "-", + "required_rule": "是", + "default_value_source": "AI输出", + "display_condition": "result_type = manual_review", + "validation_rule": "只读", + "write_path": "ai_payload_json.manual_review.visible_reason", + "opera_write_participation": "否", + "opera_parameter_mapping": "-", + "notes": "S04 精简结构未显式要求 visible_reason;若缺失则用 visible_reason 顶层字段展示。", + "row_number": 41 + }, + { + "card_name": "Update Booking 人工复核卡", + "result_type": "manual_review", + "task_type": "Update Booking", + "task_subtype": "manual_review", + "applicable_scenario": "manual task", + "display_area": "复核原因", + "field_path": "manual_review.review_record_type", + "display_name": "复核记录类型", + "field_source": "AI输出", + "visible": "是", + "editable": "否", + "input_editable": "否", + "select_editable": "否", + "date_picker": "否", + "number_input": "否", + "file_display": "否", + "table_editable": "否", + "enum_options": "manual_review_required / manual_processing_required", + "required_rule": "否", + "default_value_source": "AI输出", + "display_condition": "除S04精简结构外展示", + "validation_rule": "只读", + "write_path": "ai_payload_json.manual_review.review_record_type", + "opera_write_participation": "否", + "opera_parameter_mapping": "-", + "notes": "S04 manual_review 精简结构未包含该字段,显示时需兼容为空。", + "row_number": 42 + }, + { + "card_name": "Update Booking 人工复核卡", + "result_type": "manual_review", + "task_type": "Update Booking", + "task_subtype": "manual_review", + "applicable_scenario": "manual task", + "display_area": "复核内容", + "field_path": "manual_review.blocking_points[]", + "display_name": "阻断点", + "field_source": "AI输出", + "visible": "是", + "editable": "否", + "input_editable": "否", + "select_editable": "否", + "date_picker": "否", + "number_input": "否", + "file_display": "否", + "table_editable": "否", + "enum_options": "-", + "required_rule": "是", + "default_value_source": "AI输出", + "display_condition": "result_type = manual_review", + "validation_rule": "只读数组", + "write_path": "ai_payload_json.manual_review.blocking_points", + "opera_write_participation": "否", + "opera_parameter_mapping": "-", + "notes": "告诉人工为什么不能自动处理。", + "row_number": 43 + }, + { + "card_name": "Update Booking 人工复核卡", + "result_type": "manual_review", + "task_type": "Update Booking", + "task_subtype": "manual_review", + "applicable_scenario": "manual task", + "display_area": "复核内容", + "field_path": "manual_review.missing_fields[]", + "display_name": "缺失字段", + "field_source": "AI输出", + "visible": "是", + "editable": "否", + "input_editable": "否", + "select_editable": "否", + "date_picker": "否", + "number_input": "否", + "file_display": "否", + "table_editable": "否", + "enum_options": "-", + "required_rule": "是", + "default_value_source": "AI输出", + "display_condition": "result_type = manual_review", + "validation_rule": "只读数组", + "write_path": "ai_payload_json.manual_review.missing_fields", + "opera_write_participation": "否", + "opera_parameter_mapping": "-", + "notes": "提示人工需要补哪些参数。", + "row_number": 44 + }, + { + "card_name": "Update Booking 人工复核卡", + "result_type": "manual_review", + "task_type": "Update Booking", + "task_subtype": "manual_review", + "applicable_scenario": "manual task", + "display_area": "复核内容", + "field_path": "manual_review.conflicting_points[]", + "display_name": "冲突点", + "field_source": "AI输出", + "visible": "是", + "editable": "否", + "input_editable": "否", + "select_editable": "否", + "date_picker": "否", + "number_input": "否", + "file_display": "否", + "table_editable": "否", + "enum_options": "-", + "required_rule": "否", + "default_value_source": "AI输出", + "display_condition": "有冲突时展示", + "validation_rule": "只读数组", + "write_path": "ai_payload_json.manual_review.conflicting_points", + "opera_write_participation": "否", + "opera_parameter_mapping": "-", + "notes": "显示邮件/表格/系统上下文冲突。", + "row_number": 45 + }, + { + "card_name": "Update Booking 人工复核卡", + "result_type": "manual_review", + "task_type": "Update Booking", + "task_subtype": "manual_review", + "applicable_scenario": "manual task", + "display_area": "复核内容", + "field_path": "manual_review.evidence_to_check[]", + "display_name": "建议核对证据", + "field_source": "AI输出", + "visible": "是", + "editable": "否", + "input_editable": "否", + "select_editable": "否", + "date_picker": "否", + "number_input": "否", + "file_display": "否", + "table_editable": "否", + "enum_options": "-", + "required_rule": "否", + "default_value_source": "AI输出", + "display_condition": "有证据建议时展示", + "validation_rule": "只读数组", + "write_path": "ai_payload_json.manual_review.evidence_to_check", + "opera_write_participation": "否", + "opera_parameter_mapping": "-", + "notes": "S04 精简结构不包含 evidence_to_check。", + "row_number": 46 + }, + { + "card_name": "Update Booking 人工复核卡", + "result_type": "manual_review", + "task_type": "Update Booking", + "task_subtype": "manual_review", + "applicable_scenario": "manual task", + "display_area": "复核内容", + "field_path": "manual_review.suggested_human_actions[]", + "display_name": "建议人工动作", + "field_source": "AI输出", + "visible": "是", + "editable": "否", + "input_editable": "否", + "select_editable": "否", + "date_picker": "否", + "number_input": "否", + "file_display": "否", + "table_editable": "否", + "enum_options": "-", + "required_rule": "否", + "default_value_source": "AI输出", + "display_condition": "有建议时展示", + "validation_rule": "只读数组", + "write_path": "ai_payload_json.manual_review.suggested_human_actions", + "opera_write_participation": "否", + "opera_parameter_mapping": "-", + "notes": "S04/S05 精简结构不包含 suggested_human_actions。", + "row_number": 47 + }, + { + "card_name": "Cancel Booking 卡", + "result_type": "normal_task", + "task_type": "Cancel Booking", + "task_subtype": "cancel_fit_reservation / cancel_group_block / cancel_allotment_control_block", + "applicable_scenario": "normal task", + "display_area": "证据区", + "field_path": "visible_reason", + "display_name": "生成原因", + "field_source": "AI输出", + "visible": "是", + "editable": "否", + "input_editable": "否", + "select_editable": "否", + "date_picker": "否", + "number_input": "否", + "file_display": "否", + "table_editable": "否", + "enum_options": "-", + "required_rule": "是", + "default_value_source": "AI输出", + "display_condition": "始终展示", + "validation_rule": "只读", + "write_path": "ai_payload_json.visible_reason", + "opera_write_participation": "否", + "opera_parameter_mapping": "-", + "notes": "解释为什么生成该任务卡。", + "row_number": 48 + }, + { + "card_name": "Cancel Booking 卡", + "result_type": "normal_task", + "task_type": "Cancel Booking", + "task_subtype": "cancel_fit_reservation / cancel_group_block / cancel_allotment_control_block", + "applicable_scenario": "normal task", + "display_area": "证据区", + "field_path": "relevant_message_excerpt", + "display_name": "邮件片段", + "field_source": "AI输出", + "visible": "是", + "editable": "否", + "input_editable": "否", + "select_editable": "否", + "date_picker": "否", + "number_input": "否", + "file_display": "否", + "table_editable": "否", + "enum_options": "-", + "required_rule": "是", + "default_value_source": "body_current/current附件OCR", + "display_condition": "始终展示", + "validation_rule": "只读,必须来自 current", + "write_path": "ai_payload_json.relevant_message_excerpt", + "opera_write_participation": "否", + "opera_parameter_mapping": "-", + "notes": "用于人工核对参数来源。", + "row_number": 49 + }, + { + "card_name": "Cancel Booking 卡", + "result_type": "normal_task", + "task_type": "Cancel Booking", + "task_subtype": "cancel_fit_reservation / cancel_group_block / cancel_allotment_control_block", + "applicable_scenario": "normal task", + "display_area": "证据区", + "field_path": "attachments", + "display_name": "相关附件", + "field_source": "AI输出", + "visible": "是", + "editable": "否", + "input_editable": "否", + "select_editable": "否", + "date_picker": "否", + "number_input": "否", + "file_display": "是", + "table_editable": "否", + "enum_options": "-", + "required_rule": "否", + "default_value_source": "current事件相关附件", + "display_condition": "有附件时展示", + "validation_rule": "只读", + "write_path": "ai_payload_json.attachments", + "opera_write_participation": "否", + "opera_parameter_mapping": "-", + "notes": "附件、图片、PDF、表格证据展示。", + "row_number": 50 + }, + { + "card_name": "Cancel Booking 卡", + "result_type": "normal_task", + "task_type": "Cancel Booking", + "task_subtype": "cancel_fit_reservation / cancel_group_block / cancel_allotment_control_block", + "applicable_scenario": "normal task", + "display_area": "基础信息", + "field_path": "case_keys.group_code", + "display_name": "Group Code", + "field_source": "AI输出/人工确认", + "visible": "是", + "editable": "是", + "input_editable": "是", + "select_editable": "否", + "date_picker": "否", + "number_input": "否", + "file_display": "否", + "table_editable": "否", + "enum_options": "-", + "required_rule": "Group类条件必填", + "default_value_source": "AI提取值", + "display_condition": "有Group目标时展示", + "validation_rule": "非空或使用confirmation_number", + "write_path": "confirmed_payload_json.case_keys.group_code", + "opera_write_participation": "是", + "opera_parameter_mapping": "取消目标key", + "notes": "用于查找目标对象。", + "row_number": 51 + }, + { + "card_name": "Cancel Booking 卡", + "result_type": "normal_task", + "task_type": "Cancel Booking", + "task_subtype": "cancel_fit_reservation", + "applicable_scenario": "normal task", + "display_area": "基础信息", + "field_path": "case_keys.confirmation_number", + "display_name": "Confirmation No.", + "field_source": "AI输出/人工确认", + "visible": "是", + "editable": "是", + "input_editable": "是", + "select_editable": "否", + "date_picker": "否", + "number_input": "否", + "file_display": "否", + "table_editable": "否", + "enum_options": "-", + "required_rule": "FIT条件必填", + "default_value_source": "AI提取值", + "display_condition": "FIT取消时展示", + "validation_rule": "非空或使用group_code", + "write_path": "confirmed_payload_json.case_keys.confirmation_number", + "opera_write_participation": "是", + "opera_parameter_mapping": "取消目标reservation", + "notes": "用于查找目标对象。", + "row_number": 52 + }, + { + "card_name": "Cancel Booking 卡", + "result_type": "normal_task", + "task_type": "Cancel Booking", + "task_subtype": "cancel_fit_reservation / cancel_group_block / cancel_allotment_control_block", + "applicable_scenario": "normal task", + "display_area": "取消参数", + "field_path": "extracted_fields.cancel_object_type", + "display_name": "取消对象类型", + "field_source": "AI输出/人工确认", + "visible": "是", + "editable": "是", + "input_editable": "否", + "select_editable": "是", + "date_picker": "否", + "number_input": "否", + "file_display": "否", + "table_editable": "否", + "enum_options": "fit_reservation / group_block / allotment_control_block", + "required_rule": "是", + "default_value_source": "AI判定", + "display_condition": "始终展示", + "validation_rule": "必须命中枚举", + "write_path": "confirmed_payload_json.extracted_fields.cancel_object_type", + "opera_write_participation": "是", + "opera_parameter_mapping": "决定取消接口对象类型", + "notes": "下拉修正。", + "row_number": 53 + }, + { + "card_name": "Cancel Booking 卡", + "result_type": "normal_task", + "task_type": "Cancel Booking", + "task_subtype": "cancel_fit_reservation / cancel_group_block / cancel_allotment_control_block", + "applicable_scenario": "normal task", + "display_area": "取消参数", + "field_path": "extracted_fields.cancel_scope", + "display_name": "取消范围", + "field_source": "AI输出/人工确认", + "visible": "是", + "editable": "是", + "input_editable": "是", + "select_editable": "否", + "date_picker": "否", + "number_input": "否", + "file_display": "否", + "table_editable": "否", + "enum_options": "full_cancel(文档示例;其他枚举未定义)", + "required_rule": "是", + "default_value_source": "AI提取值", + "display_condition": "始终展示", + "validation_rule": "不确定:需确认完整枚举;当前至少支持full_cancel", + "write_path": "confirmed_payload_json.extracted_fields.cancel_scope", + "opera_write_participation": "是", + "opera_parameter_mapping": "取消范围参数", + "notes": "不确定项,建议后续和业务确认。", + "row_number": 54 + }, + { + "card_name": "Cancel Booking 卡", + "result_type": "normal_task", + "task_type": "Cancel Booking", + "task_subtype": "cancel_fit_reservation / cancel_group_block / cancel_allotment_control_block", + "applicable_scenario": "normal task", + "display_area": "取消参数", + "field_path": "extracted_fields.requested_cancel_effective_date", + "display_name": "取消生效日期", + "field_source": "AI输出/人工确认", + "visible": "是", + "editable": "是", + "input_editable": "否", + "select_editable": "否", + "date_picker": "是", + "number_input": "否", + "file_display": "否", + "table_editable": "否", + "enum_options": "-", + "required_rule": "否", + "default_value_source": "AI提取值", + "display_condition": "有值时展示", + "validation_rule": "合法日期", + "write_path": "confirmed_payload_json.extracted_fields.requested_cancel_effective_date", + "opera_write_participation": "条件参与", + "opera_parameter_mapping": "取消API备注/生效日期", + "notes": "日期选择器。", + "row_number": 55 + }, + { + "card_name": "Cancel Booking 卡", + "result_type": "normal_task", + "task_type": "Cancel Booking", + "task_subtype": "cancel_fit_reservation / cancel_group_block / cancel_allotment_control_block", + "applicable_scenario": "normal task", + "display_area": "取消参数", + "field_path": "extracted_fields.cancellation_note", + "display_name": "取消备注", + "field_source": "AI输出/人工确认", + "visible": "是", + "editable": "是", + "input_editable": "是", + "select_editable": "否", + "date_picker": "否", + "number_input": "否", + "file_display": "否", + "table_editable": "否", + "enum_options": "-", + "required_rule": "否", + "default_value_source": "body_current", + "display_condition": "始终展示", + "validation_rule": "文本", + "write_path": "confirmed_payload_json.extracted_fields.cancellation_note", + "opera_write_participation": "条件参与", + "opera_parameter_mapping": "取消备注/原因", + "notes": "可人工修正。", + "row_number": 56 + }, + { + "card_name": "Cancel Booking 人工复核卡", + "result_type": "manual_review", + "task_type": "Cancel Booking", + "task_subtype": "manual_review", + "applicable_scenario": "manual task", + "display_area": "复核原因", + "field_path": "manual_review.reason_code", + "display_name": "复核原因码", + "field_source": "AI输出", + "visible": "是", + "editable": "否", + "input_editable": "否", + "select_editable": "否", + "date_picker": "否", + "number_input": "否", + "file_display": "否", + "table_editable": "否", + "enum_options": "TARGET_OBJECT_AMBIGUOUS / ORDER_RECORD_NOT_FOUND / CANCEL_ALREADY_PROCESSED / CANCEL_SCOPE_UNCLEAR / CANCEL_OBJECT_TYPE_UNCLEAR / CURRENT_ACTION_NOT_CONFIRMED", + "required_rule": "是", + "default_value_source": "AI输出", + "display_condition": "result_type = manual_review", + "validation_rule": "只读;必须命中对应 skill reason_code", + "write_path": "ai_payload_json.manual_review.reason_code", + "opera_write_participation": "否", + "opera_parameter_mapping": "-", + "notes": "用于前端标签和人工复核分类。", + "row_number": 57 + }, + { + "card_name": "Cancel Booking 人工复核卡", + "result_type": "manual_review", + "task_type": "Cancel Booking", + "task_subtype": "manual_review", + "applicable_scenario": "manual task", + "display_area": "复核原因", + "field_path": "manual_review.visible_reason", + "display_name": "复核说明", + "field_source": "AI输出", + "visible": "是", + "editable": "否", + "input_editable": "否", + "select_editable": "否", + "date_picker": "否", + "number_input": "否", + "file_display": "否", + "table_editable": "否", + "enum_options": "-", + "required_rule": "是", + "default_value_source": "AI输出", + "display_condition": "result_type = manual_review", + "validation_rule": "只读", + "write_path": "ai_payload_json.manual_review.visible_reason", + "opera_write_participation": "否", + "opera_parameter_mapping": "-", + "notes": "S04 精简结构未显式要求 visible_reason;若缺失则用 visible_reason 顶层字段展示。", + "row_number": 58 + }, + { + "card_name": "Cancel Booking 人工复核卡", + "result_type": "manual_review", + "task_type": "Cancel Booking", + "task_subtype": "manual_review", + "applicable_scenario": "manual task", + "display_area": "复核原因", + "field_path": "manual_review.review_record_type", + "display_name": "复核记录类型", + "field_source": "AI输出", + "visible": "是", + "editable": "否", + "input_editable": "否", + "select_editable": "否", + "date_picker": "否", + "number_input": "否", + "file_display": "否", + "table_editable": "否", + "enum_options": "manual_review_required / manual_processing_required", + "required_rule": "否", + "default_value_source": "AI输出", + "display_condition": "除S04精简结构外展示", + "validation_rule": "只读", + "write_path": "ai_payload_json.manual_review.review_record_type", + "opera_write_participation": "否", + "opera_parameter_mapping": "-", + "notes": "S04 manual_review 精简结构未包含该字段,显示时需兼容为空。", + "row_number": 59 + }, + { + "card_name": "Cancel Booking 人工复核卡", + "result_type": "manual_review", + "task_type": "Cancel Booking", + "task_subtype": "manual_review", + "applicable_scenario": "manual task", + "display_area": "复核内容", + "field_path": "manual_review.blocking_points[]", + "display_name": "阻断点", + "field_source": "AI输出", + "visible": "是", + "editable": "否", + "input_editable": "否", + "select_editable": "否", + "date_picker": "否", + "number_input": "否", + "file_display": "否", + "table_editable": "否", + "enum_options": "-", + "required_rule": "是", + "default_value_source": "AI输出", + "display_condition": "result_type = manual_review", + "validation_rule": "只读数组", + "write_path": "ai_payload_json.manual_review.blocking_points", + "opera_write_participation": "否", + "opera_parameter_mapping": "-", + "notes": "告诉人工为什么不能自动处理。", + "row_number": 60 + }, + { + "card_name": "Cancel Booking 人工复核卡", + "result_type": "manual_review", + "task_type": "Cancel Booking", + "task_subtype": "manual_review", + "applicable_scenario": "manual task", + "display_area": "复核内容", + "field_path": "manual_review.missing_fields[]", + "display_name": "缺失字段", + "field_source": "AI输出", + "visible": "是", + "editable": "否", + "input_editable": "否", + "select_editable": "否", + "date_picker": "否", + "number_input": "否", + "file_display": "否", + "table_editable": "否", + "enum_options": "-", + "required_rule": "是", + "default_value_source": "AI输出", + "display_condition": "result_type = manual_review", + "validation_rule": "只读数组", + "write_path": "ai_payload_json.manual_review.missing_fields", + "opera_write_participation": "否", + "opera_parameter_mapping": "-", + "notes": "提示人工需要补哪些参数。", + "row_number": 61 + }, + { + "card_name": "Cancel Booking 人工复核卡", + "result_type": "manual_review", + "task_type": "Cancel Booking", + "task_subtype": "manual_review", + "applicable_scenario": "manual task", + "display_area": "复核内容", + "field_path": "manual_review.conflicting_points[]", + "display_name": "冲突点", + "field_source": "AI输出", + "visible": "是", + "editable": "否", + "input_editable": "否", + "select_editable": "否", + "date_picker": "否", + "number_input": "否", + "file_display": "否", + "table_editable": "否", + "enum_options": "-", + "required_rule": "否", + "default_value_source": "AI输出", + "display_condition": "有冲突时展示", + "validation_rule": "只读数组", + "write_path": "ai_payload_json.manual_review.conflicting_points", + "opera_write_participation": "否", + "opera_parameter_mapping": "-", + "notes": "显示邮件/表格/系统上下文冲突。", + "row_number": 62 + }, + { + "card_name": "Cancel Booking 人工复核卡", + "result_type": "manual_review", + "task_type": "Cancel Booking", + "task_subtype": "manual_review", + "applicable_scenario": "manual task", + "display_area": "复核内容", + "field_path": "manual_review.evidence_to_check[]", + "display_name": "建议核对证据", + "field_source": "AI输出", + "visible": "是", + "editable": "否", + "input_editable": "否", + "select_editable": "否", + "date_picker": "否", + "number_input": "否", + "file_display": "否", + "table_editable": "否", + "enum_options": "-", + "required_rule": "否", + "default_value_source": "AI输出", + "display_condition": "有证据建议时展示", + "validation_rule": "只读数组", + "write_path": "ai_payload_json.manual_review.evidence_to_check", + "opera_write_participation": "否", + "opera_parameter_mapping": "-", + "notes": "S04 精简结构不包含 evidence_to_check。", + "row_number": 63 + }, + { + "card_name": "Cancel Booking 人工复核卡", + "result_type": "manual_review", + "task_type": "Cancel Booking", + "task_subtype": "manual_review", + "applicable_scenario": "manual task", + "display_area": "复核内容", + "field_path": "manual_review.suggested_human_actions[]", + "display_name": "建议人工动作", + "field_source": "AI输出", + "visible": "是", + "editable": "否", + "input_editable": "否", + "select_editable": "否", + "date_picker": "否", + "number_input": "否", + "file_display": "否", + "table_editable": "否", + "enum_options": "-", + "required_rule": "否", + "default_value_source": "AI输出", + "display_condition": "有建议时展示", + "validation_rule": "只读数组", + "write_path": "ai_payload_json.manual_review.suggested_human_actions", + "opera_write_participation": "否", + "opera_parameter_mapping": "-", + "notes": "S04/S05 精简结构不包含 suggested_human_actions。", + "row_number": 64 + }, + { + "card_name": "Voucher Received 卡", + "result_type": "normal_task", + "task_type": "Voucher Received", + "task_subtype": "lian_tai_credit_voucher / bank_transfer_slip", + "applicable_scenario": "normal task", + "display_area": "证据区", + "field_path": "visible_reason", + "display_name": "生成原因", + "field_source": "AI输出", + "visible": "是", + "editable": "否", + "input_editable": "否", + "select_editable": "否", + "date_picker": "否", + "number_input": "否", + "file_display": "否", + "table_editable": "否", + "enum_options": "-", + "required_rule": "是", + "default_value_source": "AI输出", + "display_condition": "始终展示", + "validation_rule": "只读", + "write_path": "ai_payload_json.visible_reason", + "opera_write_participation": "否", + "opera_parameter_mapping": "-", + "notes": "解释为什么生成该任务卡。", + "row_number": 65 + }, + { + "card_name": "Voucher Received 卡", + "result_type": "normal_task", + "task_type": "Voucher Received", + "task_subtype": "lian_tai_credit_voucher / bank_transfer_slip", + "applicable_scenario": "normal task", + "display_area": "证据区", + "field_path": "relevant_message_excerpt", + "display_name": "邮件片段", + "field_source": "AI输出", + "visible": "是", + "editable": "否", + "input_editable": "否", + "select_editable": "否", + "date_picker": "否", + "number_input": "否", + "file_display": "否", + "table_editable": "否", + "enum_options": "-", + "required_rule": "是", + "default_value_source": "body_current/current附件OCR", + "display_condition": "始终展示", + "validation_rule": "只读,必须来自 current", + "write_path": "ai_payload_json.relevant_message_excerpt", + "opera_write_participation": "否", + "opera_parameter_mapping": "-", + "notes": "用于人工核对参数来源。", + "row_number": 66 + }, + { + "card_name": "Voucher Received 卡", + "result_type": "normal_task", + "task_type": "Voucher Received", + "task_subtype": "lian_tai_credit_voucher / bank_transfer_slip", + "applicable_scenario": "normal task", + "display_area": "证据区", + "field_path": "attachments", + "display_name": "相关附件", + "field_source": "AI输出", + "visible": "是", + "editable": "否", + "input_editable": "否", + "select_editable": "否", + "date_picker": "否", + "number_input": "否", + "file_display": "是", + "table_editable": "否", + "enum_options": "-", + "required_rule": "否", + "default_value_source": "current事件相关附件", + "display_condition": "有附件时展示", + "validation_rule": "只读", + "write_path": "ai_payload_json.attachments", + "opera_write_participation": "否", + "opera_parameter_mapping": "-", + "notes": "附件、图片、PDF、表格证据展示。", + "row_number": 67 + }, + { + "card_name": "Voucher Received 卡", + "result_type": "normal_task", + "task_type": "Voucher Received", + "task_subtype": "lian_tai_credit_voucher / bank_transfer_slip", + "applicable_scenario": "normal task", + "display_area": "凭证", + "field_path": "voucher_attachment.file_reference", + "display_name": "原始凭证", + "field_source": "AI输出", + "visible": "是", + "editable": "否", + "input_editable": "否", + "select_editable": "否", + "date_picker": "否", + "number_input": "否", + "file_display": "是", + "table_editable": "否", + "enum_options": "-", + "required_rule": "是", + "default_value_source": "current voucher image/PDF", + "display_condition": "始终展示", + "validation_rule": "必须可展示", + "write_path": "ai_payload_json.voucher_attachment.file_reference", + "opera_write_participation": "否", + "opera_parameter_mapping": "-", + "notes": "S04 任务卡必须优先展示原始图片/PDF。", + "row_number": 68 + }, + { + "card_name": "Voucher Received 卡", + "result_type": "normal_task", + "task_type": "Voucher Received", + "task_subtype": "lian_tai_credit_voucher / bank_transfer_slip", + "applicable_scenario": "normal task", + "display_area": "凭证", + "field_path": "voucher_subtype", + "display_name": "凭证类型", + "field_source": "AI输出/人工确认", + "visible": "是", + "editable": "是", + "input_editable": "否", + "select_editable": "是", + "date_picker": "否", + "number_input": "否", + "file_display": "否", + "table_editable": "否", + "enum_options": "lian_tai_credit_voucher / bank_transfer_slip", + "required_rule": "是", + "default_value_source": "AI分类", + "display_condition": "始终展示", + "validation_rule": "必须命中枚举", + "write_path": "confirmed_payload_json.voucher_subtype", + "opera_write_participation": "条件参与", + "opera_parameter_mapping": "决定Finance流转和Group Code来源规则", + "notes": "下拉修正需谨慎;不同subtype绑定规则不同。", + "row_number": 69 + }, + { + "card_name": "Voucher Received 卡", + "result_type": "normal_task", + "task_type": "Voucher Received", + "task_subtype": "lian_tai_credit_voucher / bank_transfer_slip", + "applicable_scenario": "normal task", + "display_area": "目标", + "field_path": "case_keys.group_code", + "display_name": "Group Code", + "field_source": "AI输出/人工确认", + "visible": "是", + "editable": "是", + "input_editable": "是", + "select_editable": "否", + "date_picker": "否", + "number_input": "否", + "file_display": "否", + "table_editable": "否", + "enum_options": "-", + "required_rule": "是", + "default_value_source": "CODE/團號或current文本", + "display_condition": "始终展示", + "validation_rule": "非空;一条Voucher结果只能一个group_code", + "write_path": "confirmed_payload_json.case_keys.group_code", + "opera_write_participation": "是", + "opera_parameter_mapping": "确认后绑定目标对象;后续Reservation Type=PD", + "notes": "bank slip多group时拆多条结果,related_group_codes仅展示。", + "row_number": 70 + }, + { + "card_name": "Voucher Received 卡", + "result_type": "normal_task", + "task_type": "Voucher Received", + "task_subtype": "lian_tai_credit_voucher", + "applicable_scenario": "normal task", + "display_area": "LianTai展示", + "field_path": "voucher_display_fields", + "display_name": "凭证展示字段", + "field_source": "AI输出", + "visible": "是", + "editable": "否", + "input_editable": "否", + "select_editable": "否", + "date_picker": "否", + "number_input": "否", + "file_display": "否", + "table_editable": "否", + "enum_options": "-", + "required_rule": "否", + "default_value_source": "OCR可读字段", + "display_condition": "voucher_subtype=lian_tai_credit_voucher且可读时展示", + "validation_rule": "只读;不得驱动业务更新", + "write_path": "ai_payload_json.voucher_display_fields", + "opera_write_participation": "否", + "opera_parameter_mapping": "-", + "notes": "包含voucher_date/check_in_date/check_out_date/room_type_text。", + "row_number": 71 + }, + { + "card_name": "Voucher Received 卡", + "result_type": "normal_task", + "task_type": "Voucher Received", + "task_subtype": "bank_transfer_slip", + "applicable_scenario": "normal task", + "display_area": "部门流转", + "field_path": "department_routing[]", + "display_name": "部门流转", + "field_source": "AI输出/系统流程", + "visible": "是", + "editable": "否", + "input_editable": "否", + "select_editable": "否", + "date_picker": "否", + "number_input": "否", + "file_display": "否", + "table_editable": "否", + "enum_options": "department=Finance;purpose=confirm_payment_received;status=pending", + "required_rule": "bank slip必填", + "default_value_source": "AI输出默认Finance pending", + "display_condition": "requires_department_routing=true时展示", + "validation_rule": "只读,状态由流程更新", + "write_path": "ai_payload_json.department_routing", + "opera_write_participation": "否", + "opera_parameter_mapping": "Finance确认后才执行post_confirm_action", + "notes": "未来可追加FO等部门。", + "row_number": 72 + }, + { + "card_name": "Voucher Received 卡", + "result_type": "normal_task", + "task_type": "Voucher Received", + "task_subtype": "bank_transfer_slip", + "applicable_scenario": "normal task", + "display_area": "关联说明", + "field_path": "related_group_codes[]", + "display_name": "关联Group Codes", + "field_source": "AI输出", + "visible": "是", + "editable": "否", + "input_editable": "否", + "select_editable": "否", + "date_picker": "否", + "number_input": "否", + "file_display": "否", + "table_editable": "否", + "enum_options": "-", + "required_rule": "否", + "default_value_source": "AI拆分时保留", + "display_condition": "一张bank slip覆盖多个group时展示", + "validation_rule": "只读;不得作为case_keys数组", + "write_path": "ai_payload_json.related_group_codes", + "opera_write_participation": "否", + "opera_parameter_mapping": "-", + "notes": "每个任务仍只绑定一个case_keys.group_code。", + "row_number": 73 + }, + { + "card_name": "Voucher Received 卡", + "result_type": "normal_task", + "task_type": "Voucher Received", + "task_subtype": "lian_tai_credit_voucher / bank_transfer_slip", + "applicable_scenario": "normal task", + "display_area": "确认后动作", + "field_path": "post_confirm_action", + "display_name": "确认后动作", + "field_source": "AI输出", + "visible": "是", + "editable": "否", + "input_editable": "否", + "select_editable": "否", + "date_picker": "否", + "number_input": "否", + "file_display": "否", + "table_editable": "否", + "enum_options": "action=update_reservation_type;reservation_type=PD;execute_by=information_system_after_confirmation", + "required_rule": "是", + "default_value_source": "AI输出", + "display_condition": "始终展示或后台保留", + "validation_rule": "只读", + "write_path": "confirmed_payload_json.post_confirm_action", + "opera_write_participation": "是", + "opera_parameter_mapping": "Finance/用户确认后调用Reservation Type更新为PD", + "notes": "AI不直接调用API。", + "row_number": 74 + }, + { + "card_name": "Voucher Received 人工复核卡", + "result_type": "manual_review", + "task_type": "Voucher Received", + "task_subtype": "manual_review", + "applicable_scenario": "manual task", + "display_area": "复核原因", + "field_path": "manual_review.reason_code", + "display_name": "复核原因码", + "field_source": "AI输出", + "visible": "是", + "editable": "否", + "input_editable": "否", + "select_editable": "否", + "date_picker": "否", + "number_input": "否", + "file_display": "否", + "table_editable": "否", + "enum_options": "voucher_attachment_unreadable / voucher_subtype_unclear / lian_tai_code_field_unreadable / lian_tai_code_field_conflicting / bank_transfer_group_code_missing_in_email_text / ambiguous_voucher_group_code_mapping / current_history_boundary_unclear / missing_group_code / only_reservation_number_found / unclear_group_code_reservation_link / target_object_not_found / multiple_target_objects_found", + "required_rule": "是", + "default_value_source": "AI输出", + "display_condition": "result_type = manual_review", + "validation_rule": "只读;必须命中对应 skill reason_code", + "write_path": "ai_payload_json.manual_review.reason_code", + "opera_write_participation": "否", + "opera_parameter_mapping": "-", + "notes": "用于前端标签和人工复核分类。", + "row_number": 75 + }, + { + "card_name": "Voucher Received 人工复核卡", + "result_type": "manual_review", + "task_type": "Voucher Received", + "task_subtype": "manual_review", + "applicable_scenario": "manual task", + "display_area": "复核原因", + "field_path": "manual_review.visible_reason", + "display_name": "复核说明", + "field_source": "AI输出", + "visible": "是", + "editable": "否", + "input_editable": "否", + "select_editable": "否", + "date_picker": "否", + "number_input": "否", + "file_display": "否", + "table_editable": "否", + "enum_options": "-", + "required_rule": "是", + "default_value_source": "AI输出", + "display_condition": "result_type = manual_review", + "validation_rule": "只读", + "write_path": "ai_payload_json.manual_review.visible_reason", + "opera_write_participation": "否", + "opera_parameter_mapping": "-", + "notes": "S04 精简结构未显式要求 visible_reason;若缺失则用 visible_reason 顶层字段展示。", + "row_number": 76 + }, + { + "card_name": "Voucher Received 人工复核卡", + "result_type": "manual_review", + "task_type": "Voucher Received", + "task_subtype": "manual_review", + "applicable_scenario": "manual task", + "display_area": "复核原因", + "field_path": "manual_review.review_record_type", + "display_name": "复核记录类型", + "field_source": "AI输出", + "visible": "是", + "editable": "否", + "input_editable": "否", + "select_editable": "否", + "date_picker": "否", + "number_input": "否", + "file_display": "否", + "table_editable": "否", + "enum_options": "manual_review_required / manual_processing_required / rooming_list_manual_review / amend_group_code_manual_review / trace_reservation_notes_manual_review / ta_recorder_manual_review", + "required_rule": "否", + "default_value_source": "AI输出", + "display_condition": "除S04精简结构外展示", + "validation_rule": "只读", + "write_path": "ai_payload_json.manual_review.review_record_type", + "opera_write_participation": "否", + "opera_parameter_mapping": "-", + "notes": "S04 manual_review 精简结构未包含该字段,显示时需兼容为空。", + "row_number": 77 + }, + { + "card_name": "Voucher Received 人工复核卡", + "result_type": "manual_review", + "task_type": "Voucher Received", + "task_subtype": "manual_review", + "applicable_scenario": "manual task", + "display_area": "复核内容", + "field_path": "manual_review.blocking_points[]", + "display_name": "阻断点", + "field_source": "AI输出", + "visible": "是", + "editable": "否", + "input_editable": "否", + "select_editable": "否", + "date_picker": "否", + "number_input": "否", + "file_display": "否", + "table_editable": "否", + "enum_options": "-", + "required_rule": "是", + "default_value_source": "AI输出", + "display_condition": "result_type = manual_review", + "validation_rule": "只读数组", + "write_path": "ai_payload_json.manual_review.blocking_points", + "opera_write_participation": "否", + "opera_parameter_mapping": "-", + "notes": "告诉人工为什么不能自动处理。", + "row_number": 78 + }, + { + "card_name": "Voucher Received 人工复核卡", + "result_type": "manual_review", + "task_type": "Voucher Received", + "task_subtype": "manual_review", + "applicable_scenario": "manual task", + "display_area": "复核内容", + "field_path": "manual_review.missing_fields[]", + "display_name": "缺失字段", + "field_source": "AI输出", + "visible": "是", + "editable": "否", + "input_editable": "否", + "select_editable": "否", + "date_picker": "否", + "number_input": "否", + "file_display": "否", + "table_editable": "否", + "enum_options": "-", + "required_rule": "是", + "default_value_source": "AI输出", + "display_condition": "result_type = manual_review", + "validation_rule": "只读数组", + "write_path": "ai_payload_json.manual_review.missing_fields", + "opera_write_participation": "否", + "opera_parameter_mapping": "-", + "notes": "提示人工需要补哪些参数。", + "row_number": 79 + }, + { + "card_name": "Voucher Received 人工复核卡", + "result_type": "manual_review", + "task_type": "Voucher Received", + "task_subtype": "manual_review", + "applicable_scenario": "manual task", + "display_area": "复核内容", + "field_path": "manual_review.conflicting_points[]", + "display_name": "冲突点", + "field_source": "AI输出", + "visible": "是", + "editable": "否", + "input_editable": "否", + "select_editable": "否", + "date_picker": "否", + "number_input": "否", + "file_display": "否", + "table_editable": "否", + "enum_options": "-", + "required_rule": "否", + "default_value_source": "AI输出", + "display_condition": "有冲突时展示", + "validation_rule": "只读数组", + "write_path": "ai_payload_json.manual_review.conflicting_points", + "opera_write_participation": "否", + "opera_parameter_mapping": "-", + "notes": "显示邮件/表格/系统上下文冲突。", + "row_number": 80 + }, + { + "card_name": "Voucher Received 人工复核卡", + "result_type": "manual_review", + "task_type": "Voucher Received", + "task_subtype": "manual_review", + "applicable_scenario": "manual task", + "display_area": "已知字段", + "field_path": "manual_review.known_fields", + "display_name": "已知凭证信息", + "field_source": "AI输出", + "visible": "是", + "editable": "否", + "input_editable": "否", + "select_editable": "否", + "date_picker": "否", + "number_input": "否", + "file_display": "否", + "table_editable": "否", + "enum_options": "-", + "required_rule": "否", + "default_value_source": "AI已知字段", + "display_condition": "有值时展示", + "validation_rule": "只读", + "write_path": "ai_payload_json.manual_review.known_fields", + "opera_write_participation": "否", + "opera_parameter_mapping": "-", + "notes": "S04 精简结构字段。", + "row_number": 81 + }, + { + "card_name": "Rooming List 卡", + "result_type": "normal_task", + "task_type": "Rooming List", + "task_subtype": "rooming_list / name_list / guest_list / room_assignment_list", + "applicable_scenario": "normal task", + "display_area": "证据区", + "field_path": "visible_reason", + "display_name": "生成原因", + "field_source": "AI输出", + "visible": "是", + "editable": "否", + "input_editable": "否", + "select_editable": "否", + "date_picker": "否", + "number_input": "否", + "file_display": "否", + "table_editable": "否", + "enum_options": "-", + "required_rule": "是", + "default_value_source": "AI输出", + "display_condition": "始终展示", + "validation_rule": "只读", + "write_path": "ai_payload_json.visible_reason", + "opera_write_participation": "否", + "opera_parameter_mapping": "-", + "notes": "解释为什么生成该任务卡。", + "row_number": 82 + }, + { + "card_name": "Rooming List 卡", + "result_type": "normal_task", + "task_type": "Rooming List", + "task_subtype": "rooming_list / name_list / guest_list / room_assignment_list", + "applicable_scenario": "normal task", + "display_area": "证据区", + "field_path": "relevant_message_excerpt", + "display_name": "邮件片段", + "field_source": "AI输出", + "visible": "是", + "editable": "否", + "input_editable": "否", + "select_editable": "否", + "date_picker": "否", + "number_input": "否", + "file_display": "否", + "table_editable": "否", + "enum_options": "-", + "required_rule": "是", + "default_value_source": "body_current/current附件OCR", + "display_condition": "始终展示", + "validation_rule": "只读,必须来自 current", + "write_path": "ai_payload_json.relevant_message_excerpt", + "opera_write_participation": "否", + "opera_parameter_mapping": "-", + "notes": "用于人工核对参数来源。", + "row_number": 83 + }, + { + "card_name": "Rooming List 卡", + "result_type": "normal_task", + "task_type": "Rooming List", + "task_subtype": "rooming_list / name_list / guest_list / room_assignment_list", + "applicable_scenario": "normal task", + "display_area": "证据区", + "field_path": "attachments", + "display_name": "相关附件", + "field_source": "AI输出", + "visible": "是", + "editable": "否", + "input_editable": "否", + "select_editable": "否", + "date_picker": "否", + "number_input": "否", + "file_display": "是", + "table_editable": "否", + "enum_options": "-", + "required_rule": "否", + "default_value_source": "current事件相关附件", + "display_condition": "有附件时展示", + "validation_rule": "只读", + "write_path": "ai_payload_json.attachments", + "opera_write_participation": "否", + "opera_parameter_mapping": "-", + "notes": "附件、图片、PDF、表格证据展示。", + "row_number": 84 + }, + { + "card_name": "Rooming List 卡", + "result_type": "normal_task", + "task_type": "Rooming List", + "task_subtype": "rooming_list / name_list / guest_list / room_assignment_list", + "applicable_scenario": "normal task", + "display_area": "文件", + "field_path": "file_references", + "display_name": "名单文件", + "field_source": "AI输出", + "visible": "是", + "editable": "否", + "input_editable": "否", + "select_editable": "否", + "date_picker": "否", + "number_input": "否", + "file_display": "是", + "table_editable": "否", + "enum_options": "-", + "required_rule": "是", + "default_value_source": "current附件/链接/下载文件", + "display_condition": "始终展示", + "validation_rule": "必须可展示或可下载", + "write_path": "ai_payload_json.file_references", + "opera_write_participation": "否", + "opera_parameter_mapping": "-", + "notes": "Rooming List 文件展示。", + "row_number": 85 + }, + { + "card_name": "Rooming List 卡", + "result_type": "normal_task", + "task_type": "Rooming List", + "task_subtype": "rooming_list / name_list / guest_list / room_assignment_list", + "applicable_scenario": "normal task", + "display_area": "基础信息", + "field_path": "extracted_fields.list_evidence_type", + "display_name": "名单类型", + "field_source": "AI输出/人工确认", + "visible": "是", + "editable": "是", + "input_editable": "否", + "select_editable": "是", + "date_picker": "否", + "number_input": "否", + "file_display": "否", + "table_editable": "否", + "enum_options": "rooming_list / name_list / guest_list / room_assignment_list", + "required_rule": "是", + "default_value_source": "AI识别", + "display_condition": "始终展示", + "validation_rule": "必须命中枚举", + "write_path": "confirmed_payload_json.extracted_fields.list_evidence_type", + "opera_write_participation": "条件参与", + "opera_parameter_mapping": "名单处理流程", + "notes": "下拉修正。", + "row_number": 86 + }, + { + "card_name": "Rooming List 卡", + "result_type": "normal_task", + "task_type": "Rooming List", + "task_subtype": "rooming_list / name_list / guest_list / room_assignment_list", + "applicable_scenario": "normal task", + "display_area": "目标", + "field_path": "extracted_fields.target_key", + "display_name": "目标Key", + "field_source": "AI输出/人工确认", + "visible": "是", + "editable": "是", + "input_editable": "是", + "select_editable": "否", + "date_picker": "否", + "number_input": "否", + "file_display": "否", + "table_editable": "否", + "enum_options": "-", + "required_rule": "是", + "default_value_source": "current/sheet/history evidence", + "display_condition": "始终展示", + "validation_rule": "必须绑定单个group_code或reservation", + "write_path": "confirmed_payload_json.extracted_fields.target_key", + "opera_write_participation": "是", + "opera_parameter_mapping": "Rooming List导入目标对象", + "notes": "若后端更偏好case_keys,应确认 target_key 与 case_keys 的映射。", + "row_number": 87 + }, + { + "card_name": "Rooming List 卡", + "result_type": "normal_task", + "task_type": "Rooming List", + "task_subtype": "rooming_list / name_list / guest_list / room_assignment_list", + "applicable_scenario": "normal task", + "display_area": "文件校验", + "field_path": "extracted_fields.file_type_validation", + "display_name": "文件类型判断", + "field_source": "AI输出", + "visible": "是", + "editable": "否", + "input_editable": "否", + "select_editable": "否", + "date_picker": "否", + "number_input": "否", + "file_display": "否", + "table_editable": "否", + "enum_options": "-", + "required_rule": "是", + "default_value_source": "AI输出", + "display_condition": "始终展示", + "validation_rule": "只读", + "write_path": "ai_payload_json.extracted_fields.file_type_validation", + "opera_write_participation": "否", + "opera_parameter_mapping": "-", + "notes": "说明为什么这是名单而不是booking update表。", + "row_number": 88 + }, + { + "card_name": "Rooming List 卡", + "result_type": "normal_task", + "task_type": "Rooming List", + "task_subtype": "rooming_list / name_list / guest_list / room_assignment_list", + "applicable_scenario": "normal task", + "display_area": "文件校验", + "field_path": "extracted_fields.attachment_binding", + "display_name": "附件/Sheet绑定", + "field_source": "AI输出", + "visible": "是", + "editable": "否", + "input_editable": "否", + "select_editable": "否", + "date_picker": "否", + "number_input": "否", + "file_display": "否", + "table_editable": "否", + "enum_options": "-", + "required_rule": "是", + "default_value_source": "AI输出", + "display_condition": "始终展示", + "validation_rule": "只读", + "write_path": "ai_payload_json.extracted_fields.attachment_binding", + "opera_write_participation": "否", + "opera_parameter_mapping": "-", + "notes": "说明文件如何绑定目标对象。", + "row_number": 89 + }, + { + "card_name": "Rooming List 卡", + "result_type": "normal_task", + "task_type": "Rooming List", + "task_subtype": "rooming_list / name_list / guest_list / room_assignment_list", + "applicable_scenario": "normal task", + "display_area": "后续动作", + "field_path": "extracted_fields.downstream_action_intents[]", + "display_name": "后续动作意图", + "field_source": "AI输出", + "visible": "是", + "editable": "否", + "input_editable": "否", + "select_editable": "否", + "date_picker": "否", + "number_input": "否", + "file_display": "否", + "table_editable": "否", + "enum_options": "import_rooming_list / pm_room_confirmation / routing_instruction / routing_target=Master Group / ta_recorder_task_candidate / note_task_candidate / block_status_update_intent", + "required_rule": "否", + "default_value_source": "AI输出", + "display_condition": "有值时展示", + "validation_rule": "只读;不直接执行", + "write_path": "ai_payload_json.extracted_fields.downstream_action_intents", + "opera_write_participation": "条件参与", + "opera_parameter_mapping": "信息系统后续流程", + "notes": "S05不直接导入Opera。", + "row_number": 90 + }, + { + "card_name": "Rooming List 卡", + "result_type": "normal_task", + "task_type": "Rooming List", + "task_subtype": "rooming_list / name_list / guest_list / room_assignment_list", + "applicable_scenario": "normal task", + "display_area": "派生任务", + "field_path": "extracted_fields.derived_task_candidates[]", + "display_name": "派生任务候选", + "field_source": "AI输出", + "visible": "是", + "editable": "否", + "input_editable": "否", + "select_editable": "否", + "date_picker": "否", + "number_input": "否", + "file_display": "否", + "table_editable": "否", + "enum_options": "TA Recorder / Note", + "required_rule": "否", + "default_value_source": "AI输出", + "display_condition": "有派生任务时展示", + "validation_rule": "只读", + "write_path": "ai_payload_json.extracted_fields.derived_task_candidates", + "opera_write_participation": "否", + "opera_parameter_mapping": "派生S08候选", + "notes": "Note Skill尚未落地,标注为后续占位。", + "row_number": 91 + }, + { + "card_name": "Rooming List 人工复核卡", + "result_type": "manual_review", + "task_type": "Rooming List", + "task_subtype": "manual_review", + "applicable_scenario": "manual task", + "display_area": "复核原因", + "field_path": "manual_review.reason_code", + "display_name": "复核原因码", + "field_source": "AI输出", + "visible": "是", + "editable": "否", + "input_editable": "否", + "select_editable": "否", + "date_picker": "否", + "number_input": "否", + "file_display": "否", + "table_editable": "否", + "enum_options": "file_type_unconfirmed / external_file_unreadable / external_file_permission_denied / file_download_failed / file_parse_failed / missing_rooming_file / rooming_context_without_readable_file / booking_update_table_detected / target_key_missing / target_object_not_found / multiple_target_objects_found / sheet_group_binding_unclear / multiple_group_scope_unclear / current_history_boundary_unclear / mixed_update_and_rooming_not_splittable", + "required_rule": "是", + "default_value_source": "AI输出", + "display_condition": "result_type = manual_review", + "validation_rule": "只读;必须命中对应 skill reason_code", + "write_path": "ai_payload_json.manual_review.reason_code", + "opera_write_participation": "否", + "opera_parameter_mapping": "-", + "notes": "用于前端标签和人工复核分类。", + "row_number": 92 + }, + { + "card_name": "Rooming List 人工复核卡", + "result_type": "manual_review", + "task_type": "Rooming List", + "task_subtype": "manual_review", + "applicable_scenario": "manual task", + "display_area": "复核原因", + "field_path": "manual_review.visible_reason", + "display_name": "复核说明", + "field_source": "AI输出", + "visible": "是", + "editable": "否", + "input_editable": "否", + "select_editable": "否", + "date_picker": "否", + "number_input": "否", + "file_display": "否", + "table_editable": "否", + "enum_options": "-", + "required_rule": "是", + "default_value_source": "AI输出", + "display_condition": "result_type = manual_review", + "validation_rule": "只读", + "write_path": "ai_payload_json.manual_review.visible_reason", + "opera_write_participation": "否", + "opera_parameter_mapping": "-", + "notes": "S04 精简结构未显式要求 visible_reason;若缺失则用 visible_reason 顶层字段展示。", + "row_number": 93 + }, + { + "card_name": "Rooming List 人工复核卡", + "result_type": "manual_review", + "task_type": "Rooming List", + "task_subtype": "manual_review", + "applicable_scenario": "manual task", + "display_area": "复核原因", + "field_path": "manual_review.review_record_type", + "display_name": "复核记录类型", + "field_source": "AI输出", + "visible": "是", + "editable": "否", + "input_editable": "否", + "select_editable": "否", + "date_picker": "否", + "number_input": "否", + "file_display": "否", + "table_editable": "否", + "enum_options": "rooming_list_manual_review", + "required_rule": "否", + "default_value_source": "AI输出", + "display_condition": "除S04精简结构外展示", + "validation_rule": "只读", + "write_path": "ai_payload_json.manual_review.review_record_type", + "opera_write_participation": "否", + "opera_parameter_mapping": "-", + "notes": "S04 manual_review 精简结构未包含该字段,显示时需兼容为空。", + "row_number": 94 + }, + { + "card_name": "Rooming List 人工复核卡", + "result_type": "manual_review", + "task_type": "Rooming List", + "task_subtype": "manual_review", + "applicable_scenario": "manual task", + "display_area": "复核内容", + "field_path": "manual_review.blocking_points[]", + "display_name": "阻断点", + "field_source": "AI输出", + "visible": "是", + "editable": "否", + "input_editable": "否", + "select_editable": "否", + "date_picker": "否", + "number_input": "否", + "file_display": "否", + "table_editable": "否", + "enum_options": "-", + "required_rule": "是", + "default_value_source": "AI输出", + "display_condition": "result_type = manual_review", + "validation_rule": "只读数组", + "write_path": "ai_payload_json.manual_review.blocking_points", + "opera_write_participation": "否", + "opera_parameter_mapping": "-", + "notes": "告诉人工为什么不能自动处理。", + "row_number": 95 + }, + { + "card_name": "Rooming List 人工复核卡", + "result_type": "manual_review", + "task_type": "Rooming List", + "task_subtype": "manual_review", + "applicable_scenario": "manual task", + "display_area": "复核内容", + "field_path": "manual_review.missing_fields[]", + "display_name": "缺失字段", + "field_source": "AI输出", + "visible": "是", + "editable": "否", + "input_editable": "否", + "select_editable": "否", + "date_picker": "否", + "number_input": "否", + "file_display": "否", + "table_editable": "否", + "enum_options": "-", + "required_rule": "是", + "default_value_source": "AI输出", + "display_condition": "result_type = manual_review", + "validation_rule": "只读数组", + "write_path": "ai_payload_json.manual_review.missing_fields", + "opera_write_participation": "否", + "opera_parameter_mapping": "-", + "notes": "提示人工需要补哪些参数。", + "row_number": 96 + }, + { + "card_name": "Rooming List 人工复核卡", + "result_type": "manual_review", + "task_type": "Rooming List", + "task_subtype": "manual_review", + "applicable_scenario": "manual task", + "display_area": "复核内容", + "field_path": "manual_review.conflicting_points[]", + "display_name": "冲突点", + "field_source": "AI输出", + "visible": "是", + "editable": "否", + "input_editable": "否", + "select_editable": "否", + "date_picker": "否", + "number_input": "否", + "file_display": "否", + "table_editable": "否", + "enum_options": "-", + "required_rule": "否", + "default_value_source": "AI输出", + "display_condition": "有冲突时展示", + "validation_rule": "只读数组", + "write_path": "ai_payload_json.manual_review.conflicting_points", + "opera_write_participation": "否", + "opera_parameter_mapping": "-", + "notes": "显示邮件/表格/系统上下文冲突。", + "row_number": 97 + }, + { + "card_name": "Rooming List 人工复核卡", + "result_type": "manual_review", + "task_type": "Rooming List", + "task_subtype": "manual_review", + "applicable_scenario": "manual task", + "display_area": "复核内容", + "field_path": "manual_review.evidence_to_check[]", + "display_name": "建议核对证据", + "field_source": "AI输出", + "visible": "是", + "editable": "否", + "input_editable": "否", + "select_editable": "否", + "date_picker": "否", + "number_input": "否", + "file_display": "否", + "table_editable": "否", + "enum_options": "-", + "required_rule": "否", + "default_value_source": "AI输出", + "display_condition": "有证据建议时展示", + "validation_rule": "只读数组", + "write_path": "ai_payload_json.manual_review.evidence_to_check", + "opera_write_participation": "否", + "opera_parameter_mapping": "-", + "notes": "S04 精简结构不包含 evidence_to_check。", + "row_number": 98 + }, + { + "card_name": "Rooming List 人工复核卡", + "result_type": "manual_review", + "task_type": "Rooming List", + "task_subtype": "manual_review", + "applicable_scenario": "manual task", + "display_area": "锁定后续", + "field_path": "extracted_fields.locked_downstream_actions[]", + "display_name": "锁定后续动作", + "field_source": "AI输出", + "visible": "是", + "editable": "否", + "input_editable": "否", + "select_editable": "否", + "date_picker": "否", + "number_input": "否", + "file_display": "否", + "table_editable": "否", + "enum_options": "Import / TA Recorder / Note / Routing", + "required_rule": "否", + "default_value_source": "AI输出", + "display_condition": "有值时展示", + "validation_rule": "只读", + "write_path": "ai_payload_json.extracted_fields.locked_downstream_actions", + "opera_write_participation": "否", + "opera_parameter_mapping": "-", + "notes": "父任务人工确认前下游不执行。", + "row_number": 99 + }, + { + "card_name": "Amend Group Code 卡", + "result_type": "normal_task", + "task_type": "Amend Group Code", + "task_subtype": "amend_group_code", + "applicable_scenario": "normal task", + "display_area": "证据区", + "field_path": "visible_reason", + "display_name": "生成原因", + "field_source": "AI输出", + "visible": "是", + "editable": "否", + "input_editable": "否", + "select_editable": "否", + "date_picker": "否", + "number_input": "否", + "file_display": "否", + "table_editable": "否", + "enum_options": "-", + "required_rule": "是", + "default_value_source": "AI输出", + "display_condition": "始终展示", + "validation_rule": "只读", + "write_path": "ai_payload_json.visible_reason", + "opera_write_participation": "否", + "opera_parameter_mapping": "-", + "notes": "解释为什么生成该任务卡。", + "row_number": 100 + }, + { + "card_name": "Amend Group Code 卡", + "result_type": "normal_task", + "task_type": "Amend Group Code", + "task_subtype": "amend_group_code", + "applicable_scenario": "normal task", + "display_area": "证据区", + "field_path": "relevant_message_excerpt", + "display_name": "邮件片段", + "field_source": "AI输出", + "visible": "是", + "editable": "否", + "input_editable": "否", + "select_editable": "否", + "date_picker": "否", + "number_input": "否", + "file_display": "否", + "table_editable": "否", + "enum_options": "-", + "required_rule": "是", + "default_value_source": "body_current/current附件OCR", + "display_condition": "始终展示", + "validation_rule": "只读,必须来自 current", + "write_path": "ai_payload_json.relevant_message_excerpt", + "opera_write_participation": "否", + "opera_parameter_mapping": "-", + "notes": "用于人工核对参数来源。", + "row_number": 101 + }, + { + "card_name": "Amend Group Code 卡", + "result_type": "normal_task", + "task_type": "Amend Group Code", + "task_subtype": "amend_group_code", + "applicable_scenario": "normal task", + "display_area": "证据区", + "field_path": "attachments", + "display_name": "相关附件", + "field_source": "AI输出", + "visible": "是", + "editable": "否", + "input_editable": "否", + "select_editable": "否", + "date_picker": "否", + "number_input": "否", + "file_display": "是", + "table_editable": "否", + "enum_options": "-", + "required_rule": "否", + "default_value_source": "current事件相关附件", + "display_condition": "有附件时展示", + "validation_rule": "只读", + "write_path": "ai_payload_json.attachments", + "opera_write_participation": "否", + "opera_parameter_mapping": "-", + "notes": "附件、图片、PDF、表格证据展示。", + "row_number": 102 + }, + { + "card_name": "Amend Group Code 卡", + "result_type": "normal_task", + "task_type": "Amend Group Code", + "task_subtype": "amend_group_code", + "applicable_scenario": "normal task", + "display_area": "改团号", + "field_path": "extracted_fields.old_group_code", + "display_name": "旧Group Code", + "field_source": "AI输出/人工确认", + "visible": "是", + "editable": "是", + "input_editable": "是", + "select_editable": "否", + "date_picker": "否", + "number_input": "否", + "file_display": "否", + "table_editable": "否", + "enum_options": "-", + "required_rule": "是", + "default_value_source": "AI提取值", + "display_condition": "始终展示", + "validation_rule": "非空;方向必须清楚", + "write_path": "confirmed_payload_json.extracted_fields.old_group_code", + "opera_write_participation": "是", + "opera_parameter_mapping": "Group Code更新API old value/target lookup", + "notes": "S06 前端只展示old/new。", + "row_number": 103 + }, + { + "card_name": "Amend Group Code 卡", + "result_type": "normal_task", + "task_type": "Amend Group Code", + "task_subtype": "amend_group_code", + "applicable_scenario": "normal task", + "display_area": "改团号", + "field_path": "extracted_fields.new_group_code", + "display_name": "新Group Code", + "field_source": "AI输出/人工确认", + "visible": "是", + "editable": "是", + "input_editable": "是", + "select_editable": "否", + "date_picker": "否", + "number_input": "否", + "file_display": "否", + "table_editable": "否", + "enum_options": "-", + "required_rule": "是", + "default_value_source": "AI提取值", + "display_condition": "始终展示", + "validation_rule": "非空;不得与旧值相同", + "write_path": "confirmed_payload_json.extracted_fields.new_group_code", + "opera_write_participation": "是", + "opera_parameter_mapping": "Group Code更新API new value", + "notes": "S06 不展示同行房型/房量/日期。", + "row_number": 104 + }, + { + "card_name": "Amend Group Code 人工复核卡", + "result_type": "manual_review", + "task_type": "Amend Group Code", + "task_subtype": "manual_review", + "applicable_scenario": "manual task", + "display_area": "复核原因", + "field_path": "manual_review.reason_code", + "display_name": "复核原因码", + "field_source": "AI输出", + "visible": "是", + "editable": "否", + "input_editable": "否", + "select_editable": "否", + "date_picker": "否", + "number_input": "否", + "file_display": "否", + "table_editable": "否", + "enum_options": "group_code_change_direction_unclear / new_group_code_unreadable", + "required_rule": "是", + "default_value_source": "AI输出", + "display_condition": "result_type = manual_review", + "validation_rule": "只读;必须命中对应 skill reason_code", + "write_path": "ai_payload_json.manual_review.reason_code", + "opera_write_participation": "否", + "opera_parameter_mapping": "-", + "notes": "用于前端标签和人工复核分类。", + "row_number": 105 + }, + { + "card_name": "Amend Group Code 人工复核卡", + "result_type": "manual_review", + "task_type": "Amend Group Code", + "task_subtype": "manual_review", + "applicable_scenario": "manual task", + "display_area": "复核原因", + "field_path": "manual_review.visible_reason", + "display_name": "复核说明", + "field_source": "AI输出", + "visible": "是", + "editable": "否", + "input_editable": "否", + "select_editable": "否", + "date_picker": "否", + "number_input": "否", + "file_display": "否", + "table_editable": "否", + "enum_options": "-", + "required_rule": "是", + "default_value_source": "AI输出", + "display_condition": "result_type = manual_review", + "validation_rule": "只读", + "write_path": "ai_payload_json.manual_review.visible_reason", + "opera_write_participation": "否", + "opera_parameter_mapping": "-", + "notes": "S04 精简结构未显式要求 visible_reason;若缺失则用 visible_reason 顶层字段展示。", + "row_number": 106 + }, + { + "card_name": "Amend Group Code 人工复核卡", + "result_type": "manual_review", + "task_type": "Amend Group Code", + "task_subtype": "manual_review", + "applicable_scenario": "manual task", + "display_area": "复核原因", + "field_path": "manual_review.review_record_type", + "display_name": "复核记录类型", + "field_source": "AI输出", + "visible": "是", + "editable": "否", + "input_editable": "否", + "select_editable": "否", + "date_picker": "否", + "number_input": "否", + "file_display": "否", + "table_editable": "否", + "enum_options": "amend_group_code_manual_review", + "required_rule": "否", + "default_value_source": "AI输出", + "display_condition": "除S04精简结构外展示", + "validation_rule": "只读", + "write_path": "ai_payload_json.manual_review.review_record_type", + "opera_write_participation": "否", + "opera_parameter_mapping": "-", + "notes": "S04 manual_review 精简结构未包含该字段,显示时需兼容为空。", + "row_number": 107 + }, + { + "card_name": "Amend Group Code 人工复核卡", + "result_type": "manual_review", + "task_type": "Amend Group Code", + "task_subtype": "manual_review", + "applicable_scenario": "manual task", + "display_area": "复核内容", + "field_path": "manual_review.blocking_points[]", + "display_name": "阻断点", + "field_source": "AI输出", + "visible": "是", + "editable": "否", + "input_editable": "否", + "select_editable": "否", + "date_picker": "否", + "number_input": "否", + "file_display": "否", + "table_editable": "否", + "enum_options": "-", + "required_rule": "是", + "default_value_source": "AI输出", + "display_condition": "result_type = manual_review", + "validation_rule": "只读数组", + "write_path": "ai_payload_json.manual_review.blocking_points", + "opera_write_participation": "否", + "opera_parameter_mapping": "-", + "notes": "告诉人工为什么不能自动处理。", + "row_number": 108 + }, + { + "card_name": "Amend Group Code 人工复核卡", + "result_type": "manual_review", + "task_type": "Amend Group Code", + "task_subtype": "manual_review", + "applicable_scenario": "manual task", + "display_area": "复核内容", + "field_path": "manual_review.missing_fields[]", + "display_name": "缺失字段", + "field_source": "AI输出", + "visible": "是", + "editable": "否", + "input_editable": "否", + "select_editable": "否", + "date_picker": "否", + "number_input": "否", + "file_display": "否", + "table_editable": "否", + "enum_options": "-", + "required_rule": "是", + "default_value_source": "AI输出", + "display_condition": "result_type = manual_review", + "validation_rule": "只读数组", + "write_path": "ai_payload_json.manual_review.missing_fields", + "opera_write_participation": "否", + "opera_parameter_mapping": "-", + "notes": "提示人工需要补哪些参数。", + "row_number": 109 + }, + { + "card_name": "Amend Group Code 人工复核卡", + "result_type": "manual_review", + "task_type": "Amend Group Code", + "task_subtype": "manual_review", + "applicable_scenario": "manual task", + "display_area": "复核内容", + "field_path": "manual_review.conflicting_points[]", + "display_name": "冲突点", + "field_source": "AI输出", + "visible": "是", + "editable": "否", + "input_editable": "否", + "select_editable": "否", + "date_picker": "否", + "number_input": "否", + "file_display": "否", + "table_editable": "否", + "enum_options": "-", + "required_rule": "否", + "default_value_source": "AI输出", + "display_condition": "有冲突时展示", + "validation_rule": "只读数组", + "write_path": "ai_payload_json.manual_review.conflicting_points", + "opera_write_participation": "否", + "opera_parameter_mapping": "-", + "notes": "显示邮件/表格/系统上下文冲突。", + "row_number": 110 + }, + { + "card_name": "Amend Group Code 人工复核卡", + "result_type": "manual_review", + "task_type": "Amend Group Code", + "task_subtype": "manual_review", + "applicable_scenario": "manual task", + "display_area": "复核内容", + "field_path": "manual_review.evidence_to_check[]", + "display_name": "建议核对证据", + "field_source": "AI输出", + "visible": "是", + "editable": "否", + "input_editable": "否", + "select_editable": "否", + "date_picker": "否", + "number_input": "否", + "file_display": "否", + "table_editable": "否", + "enum_options": "-", + "required_rule": "否", + "default_value_source": "AI输出", + "display_condition": "有证据建议时展示", + "validation_rule": "只读数组", + "write_path": "ai_payload_json.manual_review.evidence_to_check", + "opera_write_participation": "否", + "opera_parameter_mapping": "-", + "notes": "S04 精简结构不包含 evidence_to_check。", + "row_number": 111 + }, + { + "card_name": "Amend Group Code 人工复核卡", + "result_type": "manual_review", + "task_type": "Amend Group Code", + "task_subtype": "manual_review", + "applicable_scenario": "manual task", + "display_area": "复核内容", + "field_path": "manual_review.suggested_human_actions[]", + "display_name": "建议人工动作", + "field_source": "AI输出", + "visible": "是", + "editable": "否", + "input_editable": "否", + "select_editable": "否", + "date_picker": "否", + "number_input": "否", + "file_display": "否", + "table_editable": "否", + "enum_options": "-", + "required_rule": "否", + "default_value_source": "AI输出", + "display_condition": "有建议时展示", + "validation_rule": "只读数组", + "write_path": "ai_payload_json.manual_review.suggested_human_actions", + "opera_write_participation": "否", + "opera_parameter_mapping": "-", + "notes": "S04/S05 精简结构不包含 suggested_human_actions。", + "row_number": 112 + }, + { + "card_name": "Trace / Reservation Notes 卡", + "result_type": "normal_task", + "task_type": "Trace / Reservation Notes", + "task_subtype": "extra_bed / general_request", + "applicable_scenario": "normal task / linked task", + "display_area": "证据区", + "field_path": "visible_reason", + "display_name": "生成原因", + "field_source": "AI输出", + "visible": "是", + "editable": "否", + "input_editable": "否", + "select_editable": "否", + "date_picker": "否", + "number_input": "否", + "file_display": "否", + "table_editable": "否", + "enum_options": "-", + "required_rule": "是", + "default_value_source": "AI输出", + "display_condition": "始终展示", + "validation_rule": "只读", + "write_path": "ai_payload_json.visible_reason", + "opera_write_participation": "否", + "opera_parameter_mapping": "-", + "notes": "解释为什么生成该任务卡。", + "row_number": 113 + }, + { + "card_name": "Trace / Reservation Notes 卡", + "result_type": "normal_task", + "task_type": "Trace / Reservation Notes", + "task_subtype": "extra_bed / general_request", + "applicable_scenario": "normal task / linked task", + "display_area": "证据区", + "field_path": "relevant_message_excerpt", + "display_name": "邮件片段", + "field_source": "AI输出", + "visible": "是", + "editable": "否", + "input_editable": "否", + "select_editable": "否", + "date_picker": "否", + "number_input": "否", + "file_display": "否", + "table_editable": "否", + "enum_options": "-", + "required_rule": "是", + "default_value_source": "body_current/current附件OCR", + "display_condition": "始终展示", + "validation_rule": "只读,必须来自 current", + "write_path": "ai_payload_json.relevant_message_excerpt", + "opera_write_participation": "否", + "opera_parameter_mapping": "-", + "notes": "用于人工核对参数来源。", + "row_number": 114 + }, + { + "card_name": "Trace / Reservation Notes 卡", + "result_type": "normal_task", + "task_type": "Trace / Reservation Notes", + "task_subtype": "extra_bed / general_request", + "applicable_scenario": "normal task / linked task", + "display_area": "证据区", + "field_path": "attachments", + "display_name": "相关附件", + "field_source": "AI输出", + "visible": "是", + "editable": "否", + "input_editable": "否", + "select_editable": "否", + "date_picker": "否", + "number_input": "否", + "file_display": "是", + "table_editable": "否", + "enum_options": "-", + "required_rule": "否", + "default_value_source": "current事件相关附件", + "display_condition": "有附件时展示", + "validation_rule": "只读", + "write_path": "ai_payload_json.attachments", + "opera_write_participation": "否", + "opera_parameter_mapping": "-", + "notes": "附件、图片、PDF、表格证据展示。", + "row_number": 115 + }, + { + "card_name": "Trace / Reservation Notes 卡", + "result_type": "normal_task", + "task_type": "Trace / Reservation Notes", + "task_subtype": "extra_bed / general_request", + "applicable_scenario": "normal task / linked task", + "display_area": "目标", + "field_path": "case_keys.group_code", + "display_name": "Group Code", + "field_source": "AI输出/人工确认", + "visible": "是", + "editable": "是", + "input_editable": "是", + "select_editable": "否", + "date_picker": "否", + "number_input": "否", + "file_display": "否", + "table_editable": "否", + "enum_options": "-", + "required_rule": "条件必填", + "default_value_source": "body_current或history evidence", + "display_condition": "目标为group时展示", + "validation_rule": "非空或使用confirmation_number/父任务", + "write_path": "confirmed_payload_json.case_keys.group_code", + "opera_write_participation": "是", + "opera_parameter_mapping": "Trace目标key", + "notes": "若来自history,必须保留target_key_source。", + "row_number": 116 + }, + { + "card_name": "Trace / Reservation Notes 卡", + "result_type": "normal_task", + "task_type": "Trace / Reservation Notes", + "task_subtype": "extra_bed / general_request", + "applicable_scenario": "normal task / linked task", + "display_area": "目标", + "field_path": "case_keys.confirmation_number", + "display_name": "Confirmation No.", + "field_source": "AI输出/人工确认", + "visible": "是", + "editable": "是", + "input_editable": "是", + "select_editable": "否", + "date_picker": "否", + "number_input": "否", + "file_display": "否", + "table_editable": "否", + "enum_options": "-", + "required_rule": "条件必填", + "default_value_source": "body_current或history evidence", + "display_condition": "目标为FIT时展示", + "validation_rule": "非空或使用group_code/父任务", + "write_path": "confirmed_payload_json.case_keys.confirmation_number", + "opera_write_participation": "是", + "opera_parameter_mapping": "Trace目标reservation", + "notes": "目标也可来自父任务。", + "row_number": 117 + }, + { + "card_name": "Trace / Reservation Notes 卡", + "result_type": "normal_task", + "task_type": "Trace / Reservation Notes", + "task_subtype": "extra_bed / general_request", + "applicable_scenario": "normal task / linked task", + "display_area": "Trace内容", + "field_path": "extracted_fields.trace_subtype", + "display_name": "Trace类型", + "field_source": "AI输出/人工确认", + "visible": "是", + "editable": "是", + "input_editable": "否", + "select_editable": "是", + "date_picker": "否", + "number_input": "否", + "file_display": "否", + "table_editable": "否", + "enum_options": "extra_bed / general_request", + "required_rule": "是", + "default_value_source": "AI识别", + "display_condition": "始终展示", + "validation_rule": "必须命中枚举", + "write_path": "confirmed_payload_json.extracted_fields.trace_subtype", + "opera_write_participation": "是", + "opera_parameter_mapping": "Trace/notes写入分支", + "notes": "extra_bed固定Set Extrabed。", + "row_number": 118 + }, + { + "card_name": "Trace / Reservation Notes 卡", + "result_type": "normal_task", + "task_type": "Trace / Reservation Notes", + "task_subtype": "任务卡展示编辑矩阵", + "applicable_scenario": "normal task / linked task", + "display_area": "Trace内容", + "field_path": "extracted_fields.trace_text", + "display_name": "Trace Text", + "field_source": "AI输出/人工确认", + "visible": "是", + "editable": "是", + "input_editable": "是", + "select_editable": "否", + "date_picker": "否", + "number_input": "否", + "file_display": "否", + "table_editable": "否", + "enum_options": "-", + "required_rule": "是", + "default_value_source": "AI标准化或原文", + "display_condition": "始终展示", + "validation_rule": "非空;extra_bed固定Set Extrabed", + "write_path": "confirmed_payload_json.extracted_fields.trace_text", + "opera_write_participation": "是", + "opera_parameter_mapping": "Trace/Reservation Notes文本", + "notes": "文本输入。", + "row_number": 119 + }, + { + "card_name": "Trace / Reservation Notes 卡", + "result_type": "normal_task", + "task_type": "Trace / Reservation Notes", + "task_subtype": "extra_bed / general_request", + "applicable_scenario": "normal task / linked task", + "display_area": "部门", + "field_path": "extracted_fields.notify_departments[]", + "display_name": "通知部门", + "field_source": "AI输出/人工确认", + "visible": "是", + "editable": "是", + "input_editable": "否", + "select_editable": "是", + "date_picker": "否", + "number_input": "否", + "file_display": "否", + "table_editable": "否", + "enum_options": "FO / HSK;常用组合:[FO]、[FO, HSK]", + "required_rule": "是", + "default_value_source": "AI规则", + "display_condition": "始终展示", + "validation_rule": "至少一个部门", + "write_path": "confirmed_payload_json.extracted_fields.notify_departments", + "opera_write_participation": "条件参与", + "opera_parameter_mapping": "部门同步/任务通知", + "notes": "多选下拉。", + "row_number": 120 + }, + { + "card_name": "Trace / Reservation Notes 卡", + "result_type": "normal_task", + "task_type": "Trace / Reservation Notes", + "task_subtype": "extra_bed", + "applicable_scenario": "normal task / linked task", + "display_area": "Extra Bed", + "field_path": "extracted_fields.occupancy_update", + "display_name": "入住人数更新", + "field_source": "AI输出", + "visible": "是", + "editable": "否", + "input_editable": "否", + "select_editable": "否", + "date_picker": "否", + "number_input": "否", + "file_display": "否", + "table_editable": "否", + "enum_options": "3adult", + "required_rule": "extra_bed必填", + "default_value_source": "固定值", + "display_condition": "trace_subtype=extra_bed时展示", + "validation_rule": "只读", + "write_path": "confirmed_payload_json.extracted_fields.occupancy_update", + "opera_write_participation": "是", + "opera_parameter_mapping": "入住人数更新意图", + "notes": "真实写入由信息系统确认后执行。", + "row_number": 121 + }, + { + "card_name": "Trace / Reservation Notes 卡", + "result_type": "normal_task", + "task_type": "Trace / Reservation Notes", + "task_subtype": "extra_bed", + "applicable_scenario": "normal task / linked task", + "display_area": "Extra Bed", + "field_path": "extracted_fields.rate_adjustment_formula", + "display_name": "改价公式", + "field_source": "AI输出", + "visible": "是", + "editable": "否", + "input_editable": "否", + "select_editable": "否", + "date_picker": "否", + "number_input": "否", + "file_display": "否", + "table_editable": "否", + "enum_options": "rate_code_price / 2 * 3", + "required_rule": "extra_bed必填", + "default_value_source": "固定值", + "display_condition": "trace_subtype=extra_bed时展示", + "validation_rule": "只读;AI不计算最终房价", + "write_path": "confirmed_payload_json.extracted_fields.rate_adjustment_formula", + "opera_write_participation": "是", + "opera_parameter_mapping": "改价操作意图", + "notes": "S07不猜rate_code_price。", + "row_number": 122 + }, + { + "card_name": "Trace / Reservation Notes 卡", + "result_type": "normal_task", + "task_type": "Trace / Reservation Notes", + "task_subtype": "linked task", + "applicable_scenario": "linked task", + "display_area": "父任务", + "field_path": "parent_source_event_index", + "display_name": "父任务事件序号", + "field_source": "AI输出", + "visible": "是", + "editable": "否", + "input_editable": "否", + "select_editable": "否", + "date_picker": "否", + "number_input": "否", + "file_display": "否", + "table_editable": "否", + "enum_options": "-", + "required_rule": "linked task必填", + "default_value_source": "AI输出", + "display_condition": "S01/S02/S06派生时展示", + "validation_rule": "只读", + "write_path": "ai_payload_json.parent_source_event_index", + "opera_write_participation": "否", + "opera_parameter_mapping": "任务编排", + "notes": "用于等待父任务完成。", + "row_number": 123 + }, + { + "card_name": "Trace / Reservation Notes 人工复核卡", + "result_type": "manual_review", + "task_type": "Trace / Reservation Notes", + "task_subtype": "manual_review", + "applicable_scenario": "manual task", + "display_area": "复核原因", + "field_path": "manual_review.reason_code", + "display_name": "复核原因码", + "field_source": "AI输出", + "visible": "是", + "editable": "否", + "input_editable": "否", + "select_editable": "否", + "date_picker": "否", + "number_input": "否", + "file_display": "否", + "table_editable": "否", + "enum_options": "trace_content_unreadable / trace_subtype_unclear / notify_department_unclear / target_object_unclear / parent_task_unclear / extra_bed_target_unclear / rate_code_price_unavailable / multiple_guest_requests_unclear / current_history_boundary_unclear / attachment_or_ocr_unreadable", + "required_rule": "是", + "default_value_source": "AI输出", + "display_condition": "result_type = manual_review", + "validation_rule": "只读;必须命中对应 skill reason_code", + "write_path": "ai_payload_json.manual_review.reason_code", + "opera_write_participation": "否", + "opera_parameter_mapping": "-", + "notes": "用于前端标签和人工复核分类。", + "row_number": 124 + }, + { + "card_name": "Trace / Reservation Notes 人工复核卡", + "result_type": "manual_review", + "task_type": "Trace / Reservation Notes", + "task_subtype": "manual_review", + "applicable_scenario": "manual task", + "display_area": "复核原因", + "field_path": "manual_review.visible_reason", + "display_name": "复核说明", + "field_source": "AI输出", + "visible": "是", + "editable": "否", + "input_editable": "否", + "select_editable": "否", + "date_picker": "否", + "number_input": "否", + "file_display": "否", + "table_editable": "否", + "enum_options": "-", + "required_rule": "是", + "default_value_source": "AI输出", + "display_condition": "result_type = manual_review", + "validation_rule": "只读", + "write_path": "ai_payload_json.manual_review.visible_reason", + "opera_write_participation": "否", + "opera_parameter_mapping": "-", + "notes": "S04 精简结构未显式要求 visible_reason;若缺失则用 visible_reason 顶层字段展示。", + "row_number": 125 + }, + { + "card_name": "Trace / Reservation Notes 人工复核卡", + "result_type": "manual_review", + "task_type": "Trace / Reservation Notes", + "task_subtype": "manual_review", + "applicable_scenario": "manual task", + "display_area": "复核原因", + "field_path": "manual_review.review_record_type", + "display_name": "复核记录类型", + "field_source": "AI输出", + "visible": "是", + "editable": "否", + "input_editable": "否", + "select_editable": "否", + "date_picker": "否", + "number_input": "否", + "file_display": "否", + "table_editable": "否", + "enum_options": "trace_reservation_notes_manual_review", + "required_rule": "否", + "default_value_source": "AI输出", + "display_condition": "除S04精简结构外展示", + "validation_rule": "只读", + "write_path": "ai_payload_json.manual_review.review_record_type", + "opera_write_participation": "否", + "opera_parameter_mapping": "-", + "notes": "S04 manual_review 精简结构未包含该字段,显示时需兼容为空。", + "row_number": 126 + }, + { + "card_name": "Trace / Reservation Notes 人工复核卡", + "result_type": "manual_review", + "task_type": "Trace / Reservation Notes", + "task_subtype": "manual_review", + "applicable_scenario": "manual task", + "display_area": "复核内容", + "field_path": "manual_review.blocking_points[]", + "display_name": "阻断点", + "field_source": "AI输出", + "visible": "是", + "editable": "否", + "input_editable": "否", + "select_editable": "否", + "date_picker": "否", + "number_input": "否", + "file_display": "否", + "table_editable": "否", + "enum_options": "-", + "required_rule": "是", + "default_value_source": "AI输出", + "display_condition": "result_type = manual_review", + "validation_rule": "只读数组", + "write_path": "ai_payload_json.manual_review.blocking_points", + "opera_write_participation": "否", + "opera_parameter_mapping": "-", + "notes": "告诉人工为什么不能自动处理。", + "row_number": 127 + }, + { + "card_name": "Trace / Reservation Notes 人工复核卡", + "result_type": "manual_review", + "task_type": "Trace / Reservation Notes", + "task_subtype": "manual_review", + "applicable_scenario": "manual task", + "display_area": "复核内容", + "field_path": "manual_review.missing_fields[]", + "display_name": "缺失字段", + "field_source": "AI输出", + "visible": "是", + "editable": "否", + "input_editable": "否", + "select_editable": "否", + "date_picker": "否", + "number_input": "否", + "file_display": "否", + "table_editable": "否", + "enum_options": "-", + "required_rule": "是", + "default_value_source": "AI输出", + "display_condition": "result_type = manual_review", + "validation_rule": "只读数组", + "write_path": "ai_payload_json.manual_review.missing_fields", + "opera_write_participation": "否", + "opera_parameter_mapping": "-", + "notes": "提示人工需要补哪些参数。", + "row_number": 128 + }, + { + "card_name": "Trace / Reservation Notes 人工复核卡", + "result_type": "manual_review", + "task_type": "Trace / Reservation Notes", + "task_subtype": "manual_review", + "applicable_scenario": "manual task", + "display_area": "复核内容", + "field_path": "manual_review.conflicting_points[]", + "display_name": "冲突点", + "field_source": "AI输出", + "visible": "是", + "editable": "否", + "input_editable": "否", + "select_editable": "否", + "date_picker": "否", + "number_input": "否", + "file_display": "否", + "table_editable": "否", + "enum_options": "-", + "required_rule": "否", + "default_value_source": "AI输出", + "display_condition": "有冲突时展示", + "validation_rule": "只读数组", + "write_path": "ai_payload_json.manual_review.conflicting_points", + "opera_write_participation": "否", + "opera_parameter_mapping": "-", + "notes": "显示邮件/表格/系统上下文冲突。", + "row_number": 129 + }, + { + "card_name": "Trace / Reservation Notes 人工复核卡", + "result_type": "manual_review", + "task_type": "Trace / Reservation Notes", + "task_subtype": "manual_review", + "applicable_scenario": "manual task", + "display_area": "复核内容", + "field_path": "manual_review.evidence_to_check[]", + "display_name": "建议核对证据", + "field_source": "AI输出", + "visible": "是", + "editable": "否", + "input_editable": "否", + "select_editable": "否", + "date_picker": "否", + "number_input": "否", + "file_display": "否", + "table_editable": "否", + "enum_options": "-", + "required_rule": "否", + "default_value_source": "AI输出", + "display_condition": "有证据建议时展示", + "validation_rule": "只读数组", + "write_path": "ai_payload_json.manual_review.evidence_to_check", + "opera_write_participation": "否", + "opera_parameter_mapping": "-", + "notes": "S04 精简结构不包含 evidence_to_check。", + "row_number": 130 + }, + { + "card_name": "Trace / Reservation Notes 人工复核卡", + "result_type": "manual_review", + "task_type": "Trace / Reservation Notes", + "task_subtype": "manual_review", + "applicable_scenario": "manual task", + "display_area": "复核内容", + "field_path": "manual_review.suggested_human_actions[]", + "display_name": "建议人工动作", + "field_source": "AI输出", + "visible": "是", + "editable": "否", + "input_editable": "否", + "select_editable": "否", + "date_picker": "否", + "number_input": "否", + "file_display": "否", + "table_editable": "否", + "enum_options": "-", + "required_rule": "否", + "default_value_source": "AI输出", + "display_condition": "有建议时展示", + "validation_rule": "只读数组", + "write_path": "ai_payload_json.manual_review.suggested_human_actions", + "opera_write_participation": "否", + "opera_parameter_mapping": "-", + "notes": "S04/S05 精简结构不包含 suggested_human_actions。", + "row_number": 131 + }, + { + "card_name": "TA Recorder 卡", + "result_type": "normal_task", + "task_type": "TA Recorder", + "task_subtype": "maintain_ta_recorder", + "applicable_scenario": "normal task / linked task", + "display_area": "证据区", + "field_path": "visible_reason", + "display_name": "生成原因", + "field_source": "AI输出", + "visible": "是", + "editable": "否", + "input_editable": "否", + "select_editable": "否", + "date_picker": "否", + "number_input": "否", + "file_display": "否", + "table_editable": "否", + "enum_options": "-", + "required_rule": "是", + "default_value_source": "AI输出", + "display_condition": "始终展示", + "validation_rule": "只读", + "write_path": "ai_payload_json.visible_reason", + "opera_write_participation": "否", + "opera_parameter_mapping": "-", + "notes": "解释为什么生成该任务卡。", + "row_number": 132 + }, + { + "card_name": "TA Recorder 卡", + "result_type": "normal_task", + "task_type": "TA Recorder", + "task_subtype": "maintain_ta_recorder", + "applicable_scenario": "normal task / linked task", + "display_area": "证据区", + "field_path": "relevant_message_excerpt", + "display_name": "邮件片段", + "field_source": "AI输出", + "visible": "是", + "editable": "否", + "input_editable": "否", + "select_editable": "否", + "date_picker": "否", + "number_input": "否", + "file_display": "否", + "table_editable": "否", + "enum_options": "-", + "required_rule": "是", + "default_value_source": "body_current/current附件OCR", + "display_condition": "始终展示", + "validation_rule": "只读,必须来自 current", + "write_path": "ai_payload_json.relevant_message_excerpt", + "opera_write_participation": "否", + "opera_parameter_mapping": "-", + "notes": "用于人工核对参数来源。", + "row_number": 133 + }, + { + "card_name": "TA Recorder 卡", + "result_type": "normal_task", + "task_type": "TA Recorder", + "task_subtype": "maintain_ta_recorder", + "applicable_scenario": "normal task / linked task", + "display_area": "证据区", + "field_path": "attachments", + "display_name": "相关附件", + "field_source": "AI输出", + "visible": "是", + "editable": "否", + "input_editable": "否", + "select_editable": "否", + "date_picker": "否", + "number_input": "否", + "file_display": "是", + "table_editable": "否", + "enum_options": "-", + "required_rule": "否", + "default_value_source": "current事件相关附件", + "display_condition": "有附件时展示", + "validation_rule": "只读", + "write_path": "ai_payload_json.attachments", + "opera_write_participation": "否", + "opera_parameter_mapping": "-", + "notes": "附件、图片、PDF、表格证据展示。", + "row_number": 134 + }, + { + "card_name": "TA Recorder 卡", + "result_type": "normal_task", + "task_type": "TA Recorder", + "task_subtype": "maintain_ta_recorder", + "applicable_scenario": "normal task / linked task", + "display_area": "基础信息", + "field_path": "extracted_fields.group_code", + "display_name": "Group Code", + "field_source": "AI输出/人工确认", + "visible": "是", + "editable": "是", + "input_editable": "是", + "select_editable": "否", + "date_picker": "否", + "number_input": "否", + "file_display": "否", + "table_editable": "否", + "enum_options": "-", + "required_rule": "是", + "default_value_source": "S05目标group_code", + "display_condition": "始终展示", + "validation_rule": "非空;一张卡一个group_code", + "write_path": "confirmed_payload_json.extracted_fields.group_code", + "opera_write_participation": "是", + "opera_parameter_mapping": "TA Recorder写入目标", + "notes": "S08明确允许用户人工修改group_code。", + "row_number": 135 + }, + { + "card_name": "TA Recorder 卡", + "result_type": "normal_task", + "task_type": "TA Recorder", + "task_subtype": "maintain_ta_recorder", + "applicable_scenario": "normal task / linked task", + "display_area": "文件", + "field_path": "extracted_fields.attachment", + "display_name": "名单/分房附件", + "field_source": "AI输出", + "visible": "是", + "editable": "否", + "input_editable": "否", + "select_editable": "否", + "date_picker": "否", + "number_input": "否", + "file_display": "是", + "table_editable": "否", + "enum_options": "-", + "required_rule": "是", + "default_value_source": "S05绑定文件", + "display_condition": "始终展示", + "validation_rule": "必须可展示或有file_reference", + "write_path": "ai_payload_json.extracted_fields.attachment", + "opera_write_participation": "条件参与", + "opera_parameter_mapping": "TA Recorder文件依据", + "notes": "文件展示。", + "row_number": 136 + }, + { + "card_name": "TA Recorder 卡", + "result_type": "normal_task", + "task_type": "TA Recorder", + "task_subtype": "maintain_ta_recorder", + "applicable_scenario": "normal task / linked task", + "display_area": "状态", + "field_path": "extracted_fields.ta_recorder_status", + "display_name": "TA Recorder状态", + "field_source": "AI输出/系统回写", + "visible": "是", + "editable": "否", + "input_editable": "否", + "select_editable": "否", + "date_picker": "否", + "number_input": "否", + "file_display": "否", + "table_editable": "否", + "enum_options": "未维护 / 已维护(已维护由信息系统后续更新;AI 默认未维护)", + "required_rule": "是", + "default_value_source": "AI默认未维护", + "display_condition": "始终展示", + "validation_rule": "AI阶段只读", + "write_path": "confirmed_payload_json.extracted_fields.ta_recorder_status", + "opera_write_participation": "条件参与", + "opera_parameter_mapping": "TA Recorder维护状态", + "notes": "不确定是否允许前端改状态;建议AI任务卡阶段只读,后续系统更新。", + "row_number": 137 + }, + { + "card_name": "TA Recorder 卡", + "result_type": "normal_task", + "task_type": "TA Recorder", + "task_subtype": "maintain_ta_recorder", + "applicable_scenario": "linked task", + "display_area": "父任务", + "field_path": "source_rooming_list_task_id", + "display_name": "Rooming List父任务", + "field_source": "AI输出/系统生成", + "visible": "是", + "editable": "否", + "input_editable": "否", + "select_editable": "否", + "date_picker": "否", + "number_input": "否", + "file_display": "否", + "table_editable": "否", + "enum_options": "-", + "required_rule": "linked task条件必填", + "default_value_source": "S05任务上下文", + "display_condition": "S05派生时展示", + "validation_rule": "只读", + "write_path": "ai_payload_json.source_rooming_list_task_id", + "opera_write_participation": "否", + "opera_parameter_mapping": "任务编排", + "notes": "等待Rooming List完成。", + "row_number": 138 + }, + { + "card_name": "TA Recorder 人工复核卡", + "result_type": "manual_review", + "task_type": "TA Recorder", + "task_subtype": "manual_review", + "applicable_scenario": "manual task", + "display_area": "复核原因", + "field_path": "manual_review.reason_code", + "display_name": "复核原因码", + "field_source": "AI输出", + "visible": "是", + "editable": "否", + "input_editable": "否", + "select_editable": "否", + "date_picker": "否", + "number_input": "否", + "file_display": "否", + "table_editable": "否", + "enum_options": "target_group_code_unclear / attachment_binding_unclear / multiple_group_binding_unclear / sheet_name_not_group_code / file_unreadable_or_no_permission / parent_rooming_list_not_confirmed / direct_ta_recorder_context_insufficient", + "required_rule": "是", + "default_value_source": "AI输出", + "display_condition": "result_type = manual_review", + "validation_rule": "只读;必须命中对应 skill reason_code", + "write_path": "ai_payload_json.manual_review.reason_code", + "opera_write_participation": "否", + "opera_parameter_mapping": "-", + "notes": "用于前端标签和人工复核分类。", + "row_number": 139 + }, + { + "card_name": "TA Recorder 人工复核卡", + "result_type": "manual_review", + "task_type": "TA Recorder", + "task_subtype": "manual_review", + "applicable_scenario": "manual task", + "display_area": "复核原因", + "field_path": "manual_review.visible_reason", + "display_name": "复核说明", + "field_source": "AI输出", + "visible": "是", + "editable": "否", + "input_editable": "否", + "select_editable": "否", + "date_picker": "否", + "number_input": "否", + "file_display": "否", + "table_editable": "否", + "enum_options": "-", + "required_rule": "是", + "default_value_source": "AI输出", + "display_condition": "result_type = manual_review", + "validation_rule": "只读", + "write_path": "ai_payload_json.manual_review.visible_reason", + "opera_write_participation": "否", + "opera_parameter_mapping": "-", + "notes": "S04 精简结构未显式要求 visible_reason;若缺失则用 visible_reason 顶层字段展示。", + "row_number": 140 + }, + { + "card_name": "TA Recorder 人工复核卡", + "result_type": "manual_review", + "task_type": "TA Recorder", + "task_subtype": "manual_review", + "applicable_scenario": "manual task", + "display_area": "复核原因", + "field_path": "manual_review.review_record_type", + "display_name": "复核记录类型", + "field_source": "AI输出", + "visible": "是", + "editable": "否", + "input_editable": "否", + "select_editable": "否", + "date_picker": "否", + "number_input": "否", + "file_display": "否", + "table_editable": "否", + "enum_options": "ta_recorder_manual_review", + "required_rule": "否", + "default_value_source": "AI输出", + "display_condition": "除S04精简结构外展示", + "validation_rule": "只读", + "write_path": "ai_payload_json.manual_review.review_record_type", + "opera_write_participation": "否", + "opera_parameter_mapping": "-", + "notes": "S04 manual_review 精简结构未包含该字段,显示时需兼容为空。", + "row_number": 141 + }, + { + "card_name": "TA Recorder 人工复核卡", + "result_type": "manual_review", + "task_type": "TA Recorder", + "task_subtype": "manual_review", + "applicable_scenario": "manual task", + "display_area": "复核内容", + "field_path": "manual_review.blocking_points[]", + "display_name": "阻断点", + "field_source": "AI输出", + "visible": "是", + "editable": "否", + "input_editable": "否", + "select_editable": "否", + "date_picker": "否", + "number_input": "否", + "file_display": "否", + "table_editable": "否", + "enum_options": "-", + "required_rule": "是", + "default_value_source": "AI输出", + "display_condition": "result_type = manual_review", + "validation_rule": "只读数组", + "write_path": "ai_payload_json.manual_review.blocking_points", + "opera_write_participation": "否", + "opera_parameter_mapping": "-", + "notes": "告诉人工为什么不能自动处理。", + "row_number": 142 + }, + { + "card_name": "TA Recorder 人工复核卡", + "result_type": "manual_review", + "task_type": "TA Recorder", + "task_subtype": "manual_review", + "applicable_scenario": "manual task", + "display_area": "复核内容", + "field_path": "manual_review.missing_fields[]", + "display_name": "缺失字段", + "field_source": "AI输出", + "visible": "是", + "editable": "否", + "input_editable": "否", + "select_editable": "否", + "date_picker": "否", + "number_input": "否", + "file_display": "否", + "table_editable": "否", + "enum_options": "-", + "required_rule": "是", + "default_value_source": "AI输出", + "display_condition": "result_type = manual_review", + "validation_rule": "只读数组", + "write_path": "ai_payload_json.manual_review.missing_fields", + "opera_write_participation": "否", + "opera_parameter_mapping": "-", + "notes": "提示人工需要补哪些参数。", + "row_number": 143 + }, + { + "card_name": "TA Recorder 人工复核卡", + "result_type": "manual_review", + "task_type": "TA Recorder", + "task_subtype": "manual_review", + "applicable_scenario": "manual task", + "display_area": "复核内容", + "field_path": "manual_review.conflicting_points[]", + "display_name": "冲突点", + "field_source": "AI输出", + "visible": "是", + "editable": "否", + "input_editable": "否", + "select_editable": "否", + "date_picker": "否", + "number_input": "否", + "file_display": "否", + "table_editable": "否", + "enum_options": "-", + "required_rule": "否", + "default_value_source": "AI输出", + "display_condition": "有冲突时展示", + "validation_rule": "只读数组", + "write_path": "ai_payload_json.manual_review.conflicting_points", + "opera_write_participation": "否", + "opera_parameter_mapping": "-", + "notes": "显示邮件/表格/系统上下文冲突。", + "row_number": 144 + }, + { + "card_name": "TA Recorder 人工复核卡", + "result_type": "manual_review", + "task_type": "TA Recorder", + "task_subtype": "manual_review", + "applicable_scenario": "manual task", + "display_area": "复核内容", + "field_path": "manual_review.evidence_to_check[]", + "display_name": "建议核对证据", + "field_source": "AI输出", + "visible": "是", + "editable": "否", + "input_editable": "否", + "select_editable": "否", + "date_picker": "否", + "number_input": "否", + "file_display": "否", + "table_editable": "否", + "enum_options": "-", + "required_rule": "否", + "default_value_source": "AI输出", + "display_condition": "有证据建议时展示", + "validation_rule": "只读数组", + "write_path": "ai_payload_json.manual_review.evidence_to_check", + "opera_write_participation": "否", + "opera_parameter_mapping": "-", + "notes": "S04 精简结构不包含 evidence_to_check。", + "row_number": 145 + }, + { + "card_name": "TA Recorder 人工复核卡", + "result_type": "manual_review", + "task_type": "TA Recorder", + "task_subtype": "manual_review", + "applicable_scenario": "manual task", + "display_area": "复核内容", + "field_path": "manual_review.suggested_human_actions[]", + "display_name": "建议人工动作", + "field_source": "AI输出", + "visible": "是", + "editable": "否", + "input_editable": "否", + "select_editable": "否", + "date_picker": "否", + "number_input": "否", + "file_display": "否", + "table_editable": "否", + "enum_options": "-", + "required_rule": "否", + "default_value_source": "AI输出", + "display_condition": "有建议时展示", + "validation_rule": "只读数组", + "write_path": "ai_payload_json.manual_review.suggested_human_actions", + "opera_write_participation": "否", + "opera_parameter_mapping": "-", + "notes": "S04/S05 精简结构不包含 suggested_human_actions。", + "row_number": 146 + }, + { + "card_name": "Message Notification 信息提醒卡", + "result_type": "informational_message", + "task_type": "Message Notification", + "task_subtype": "thank_you / fyi / acknowledgement / noted / received / confirmed_receipt / forwarding_note / information_sync", + "applicable_scenario": "informational message", + "display_area": "证据区", + "field_path": "visible_reason", + "display_name": "生成原因", + "field_source": "AI输出", + "visible": "是", + "editable": "否", + "input_editable": "否", + "select_editable": "否", + "date_picker": "否", + "number_input": "否", + "file_display": "否", + "table_editable": "否", + "enum_options": "-", + "required_rule": "是", + "default_value_source": "AI输出", + "display_condition": "始终展示", + "validation_rule": "只读", + "write_path": "ai_payload_json.visible_reason", + "opera_write_participation": "否", + "opera_parameter_mapping": "-", + "notes": "解释为什么生成该任务卡。", + "row_number": 147 + }, + { + "card_name": "Message Notification 信息提醒卡", + "result_type": "informational_message", + "task_type": "Message Notification", + "task_subtype": "thank_you / fyi / acknowledgement / noted / received / confirmed_receipt / forwarding_note / information_sync", + "applicable_scenario": "informational message", + "display_area": "证据区", + "field_path": "relevant_message_excerpt", + "display_name": "邮件片段", + "field_source": "AI输出", + "visible": "是", + "editable": "否", + "input_editable": "否", + "select_editable": "否", + "date_picker": "否", + "number_input": "否", + "file_display": "否", + "table_editable": "否", + "enum_options": "-", + "required_rule": "是", + "default_value_source": "body_current/current附件OCR", + "display_condition": "始终展示", + "validation_rule": "只读,必须来自 current", + "write_path": "ai_payload_json.relevant_message_excerpt", + "opera_write_participation": "否", + "opera_parameter_mapping": "-", + "notes": "用于人工核对参数来源。", + "row_number": 148 + }, + { + "card_name": "Message Notification 信息提醒卡", + "result_type": "informational_message", + "task_type": "Message Notification", + "task_subtype": "thank_you / fyi / acknowledgement / noted / received / confirmed_receipt / forwarding_note / information_sync", + "applicable_scenario": "informational message", + "display_area": "证据区", + "field_path": "attachments", + "display_name": "相关附件", + "field_source": "AI输出", + "visible": "是", + "editable": "否", + "input_editable": "否", + "select_editable": "否", + "date_picker": "否", + "number_input": "否", + "file_display": "是", + "table_editable": "否", + "enum_options": "-", + "required_rule": "否", + "default_value_source": "current事件相关附件", + "display_condition": "有附件时展示", + "validation_rule": "只读", + "write_path": "ai_payload_json.attachments", + "opera_write_participation": "否", + "opera_parameter_mapping": "-", + "notes": "附件、图片、PDF、表格证据展示。", + "row_number": 149 + }, + { + "card_name": "Message Notification 信息提醒卡", + "result_type": "informational_message", + "task_type": "Message Notification", + "task_subtype": "thank_you / fyi / acknowledgement / noted / received / confirmed_receipt / forwarding_note / information_sync", + "applicable_scenario": "informational message", + "display_area": "信息内容", + "field_path": "informational_message", + "display_name": "信息提醒内容", + "field_source": "AI输出", + "visible": "是", + "editable": "否", + "input_editable": "否", + "select_editable": "否", + "date_picker": "否", + "number_input": "否", + "file_display": "否", + "table_editable": "否", + "enum_options": "内部schema未定义", + "required_rule": "是", + "default_value_source": "AI输出", + "display_condition": "result_type=informational_message", + "validation_rule": "只读", + "write_path": "ai_payload_json.informational_message", + "opera_write_participation": "否", + "opera_parameter_mapping": "-", + "notes": "不确定:S10 catalog 未定义 informational_message 内部字段,当前只约束对象整体。", + "row_number": 150 + }, + { + "card_name": "Fallback 人工复核卡", + "result_type": "manual_review", + "task_type": "Fallback", + "task_subtype": "manual_review", + "applicable_scenario": "manual task", + "display_area": "复核原因", + "field_path": "manual_review.reason_code", + "display_name": "复核原因码", + "field_source": "AI输出", + "visible": "是", + "editable": "否", + "input_editable": "否", + "select_editable": "否", + "date_picker": "否", + "number_input": "否", + "file_display": "否", + "table_editable": "否", + "enum_options": "route_conflict / current_history_boundary_unclear / insufficient_routing_context / business_boundary_unclear / message_or_business_unclear", + "required_rule": "是", + "default_value_source": "AI输出", + "display_condition": "result_type = manual_review", + "validation_rule": "只读;必须命中对应 skill reason_code", + "write_path": "ai_payload_json.manual_review.reason_code", + "opera_write_participation": "否", + "opera_parameter_mapping": "-", + "notes": "用于前端标签和人工复核分类。", + "row_number": 151 + }, + { + "card_name": "Fallback 人工复核卡", + "result_type": "manual_review", + "task_type": "Fallback", + "task_subtype": "manual_review", + "applicable_scenario": "manual task", + "display_area": "复核原因", + "field_path": "manual_review.visible_reason", + "display_name": "复核说明", + "field_source": "AI输出", + "visible": "是", + "editable": "否", + "input_editable": "否", + "select_editable": "否", + "date_picker": "否", + "number_input": "否", + "file_display": "否", + "table_editable": "否", + "enum_options": "-", + "required_rule": "是", + "default_value_source": "AI输出", + "display_condition": "result_type = manual_review", + "validation_rule": "只读", + "write_path": "ai_payload_json.manual_review.visible_reason", + "opera_write_participation": "否", + "opera_parameter_mapping": "-", + "notes": "S04 精简结构未显式要求 visible_reason;若缺失则用 visible_reason 顶层字段展示。", + "row_number": 152 + }, + { + "card_name": "Fallback 人工复核卡", + "result_type": "manual_review", + "task_type": "Fallback", + "task_subtype": "manual_review", + "applicable_scenario": "manual task", + "display_area": "复核原因", + "field_path": "manual_review.review_record_type", + "display_name": "复核记录类型", + "field_source": "AI输出", + "visible": "是", + "editable": "否", + "input_editable": "否", + "select_editable": "否", + "date_picker": "否", + "number_input": "否", + "file_display": "否", + "table_editable": "否", + "enum_options": "manual_review_required / manual_processing_required", + "required_rule": "否", + "default_value_source": "AI输出", + "display_condition": "除S04精简结构外展示", + "validation_rule": "只读", + "write_path": "ai_payload_json.manual_review.review_record_type", + "opera_write_participation": "否", + "opera_parameter_mapping": "-", + "notes": "S04 manual_review 精简结构未包含该字段,显示时需兼容为空。", + "row_number": 153 + }, + { + "card_name": "Fallback 人工复核卡", + "result_type": "manual_review", + "task_type": "Fallback", + "task_subtype": "manual_review", + "applicable_scenario": "manual task", + "display_area": "复核内容", + "field_path": "manual_review.blocking_points[]", + "display_name": "阻断点", + "field_source": "AI输出", + "visible": "是", + "editable": "否", + "input_editable": "否", + "select_editable": "否", + "date_picker": "否", + "number_input": "否", + "file_display": "否", + "table_editable": "否", + "enum_options": "-", + "required_rule": "是", + "default_value_source": "AI输出", + "display_condition": "result_type = manual_review", + "validation_rule": "只读数组", + "write_path": "ai_payload_json.manual_review.blocking_points", + "opera_write_participation": "否", + "opera_parameter_mapping": "-", + "notes": "告诉人工为什么不能自动处理。", + "row_number": 154 + }, + { + "card_name": "Fallback 人工复核卡", + "result_type": "manual_review", + "task_type": "Fallback", + "task_subtype": "manual_review", + "applicable_scenario": "manual task", + "display_area": "复核内容", + "field_path": "manual_review.missing_fields[]", + "display_name": "缺失字段", + "field_source": "AI输出", + "visible": "是", + "editable": "否", + "input_editable": "否", + "select_editable": "否", + "date_picker": "否", + "number_input": "否", + "file_display": "否", + "table_editable": "否", + "enum_options": "-", + "required_rule": "是", + "default_value_source": "AI输出", + "display_condition": "result_type = manual_review", + "validation_rule": "只读数组", + "write_path": "ai_payload_json.manual_review.missing_fields", + "opera_write_participation": "否", + "opera_parameter_mapping": "-", + "notes": "提示人工需要补哪些参数。", + "row_number": 155 + }, + { + "card_name": "Fallback 人工复核卡", + "result_type": "manual_review", + "task_type": "Fallback", + "task_subtype": "manual_review", + "applicable_scenario": "manual task", + "display_area": "复核内容", + "field_path": "manual_review.conflicting_points[]", + "display_name": "冲突点", + "field_source": "AI输出", + "visible": "是", + "editable": "否", + "input_editable": "否", + "select_editable": "否", + "date_picker": "否", + "number_input": "否", + "file_display": "否", + "table_editable": "否", + "enum_options": "-", + "required_rule": "否", + "default_value_source": "AI输出", + "display_condition": "有冲突时展示", + "validation_rule": "只读数组", + "write_path": "ai_payload_json.manual_review.conflicting_points", + "opera_write_participation": "否", + "opera_parameter_mapping": "-", + "notes": "显示邮件/表格/系统上下文冲突。", + "row_number": 156 + }, + { + "card_name": "Fallback 人工复核卡", + "result_type": "manual_review", + "task_type": "Fallback", + "task_subtype": "manual_review", + "applicable_scenario": "manual task", + "display_area": "复核内容", + "field_path": "manual_review.evidence_to_check[]", + "display_name": "建议核对证据", + "field_source": "AI输出", + "visible": "是", + "editable": "否", + "input_editable": "否", + "select_editable": "否", + "date_picker": "否", + "number_input": "否", + "file_display": "否", + "table_editable": "否", + "enum_options": "-", + "required_rule": "否", + "default_value_source": "AI输出", + "display_condition": "有证据建议时展示", + "validation_rule": "只读数组", + "write_path": "ai_payload_json.manual_review.evidence_to_check", + "opera_write_participation": "否", + "opera_parameter_mapping": "-", + "notes": "S04 精简结构不包含 evidence_to_check。", + "row_number": 157 + }, + { + "card_name": "Fallback 人工复核卡", + "result_type": "manual_review", + "task_type": "Fallback", + "task_subtype": "manual_review", + "applicable_scenario": "manual task", + "display_area": "复核内容", + "field_path": "manual_review.suggested_human_actions[]", + "display_name": "建议人工动作", + "field_source": "AI输出", + "visible": "是", + "editable": "否", + "input_editable": "否", + "select_editable": "否", + "date_picker": "否", + "number_input": "否", + "file_display": "否", + "table_editable": "否", + "enum_options": "-", + "required_rule": "否", + "default_value_source": "AI输出", + "display_condition": "有建议时展示", + "validation_rule": "只读数组", + "write_path": "ai_payload_json.manual_review.suggested_human_actions", + "opera_write_participation": "否", + "opera_parameter_mapping": "-", + "notes": "S04/S05 精简结构不包含 suggested_human_actions。", + "row_number": 158 + }, + { + "card_name": "Fallback 人工复核卡", + "result_type": "manual_review", + "task_type": "Fallback", + "task_subtype": "route_conflict / current_history_boundary_unclear / insufficient_routing_context / business_boundary_unclear / message_or_business_unclear", + "applicable_scenario": "manual task", + "display_area": "候选信息", + "field_path": "case_keys.group_code", + "display_name": "候选Group Code", + "field_source": "AI输出", + "visible": "是", + "editable": "是", + "input_editable": "是", + "select_editable": "否", + "date_picker": "否", + "number_input": "否", + "file_display": "否", + "table_editable": "否", + "enum_options": "-", + "required_rule": "否", + "default_value_source": "AI候选key", + "display_condition": "有值时展示", + "validation_rule": "可空", + "write_path": "manual_resolution_payload.case_keys.group_code", + "opera_write_participation": "否", + "opera_parameter_mapping": "-", + "notes": "仅用于人工路由,不直接写Opera。", + "row_number": 159 + }, + { + "card_name": "Fallback 人工复核卡", + "result_type": "manual_review", + "task_type": "Fallback", + "task_subtype": "route_conflict / current_history_boundary_unclear / insufficient_routing_context / business_boundary_unclear / message_or_business_unclear", + "applicable_scenario": "manual task", + "display_area": "候选信息", + "field_path": "case_keys.confirmation_number", + "display_name": "候选Confirmation No.", + "field_source": "AI输出", + "visible": "是", + "editable": "是", + "input_editable": "是", + "select_editable": "否", + "date_picker": "否", + "number_input": "否", + "file_display": "否", + "table_editable": "否", + "enum_options": "-", + "required_rule": "否", + "default_value_source": "AI候选key", + "display_condition": "有值时展示", + "validation_rule": "可空", + "write_path": "manual_resolution_payload.case_keys.confirmation_number", + "opera_write_participation": "否", + "opera_parameter_mapping": "-", + "notes": "仅用于人工路由,不直接写Opera。", + "row_number": 160 + } + ] +} 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 new file mode 100644 index 0000000..744f48b --- /dev/null +++ b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/SuperAgentTaskResultControllerTest.java @@ -0,0 +1,828 @@ +package cn.nianxx.thhotel.workflows.reservation.control; + +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.not; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import cn.nianxx.thhotel.ThHotelApplication; +import cn.nianxx.thhotel.platform.message.common.request.CaptureSourceMessageCommand; +import cn.nianxx.thhotel.platform.message.common.result.SourceMessageCaptureResult; +import cn.nianxx.thhotel.platform.message.service.SourceMessageCaptureService; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.time.Instant; +import java.util.HexFormat; +import java.util.List; +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; + +@SpringBootTest( + classes = ThHotelApplication.class, + properties = { + "superagent.task-result.hmac-secret=test-superagent-secret", + "superagent.task-result.clock-skew-seconds=300", + "superagent.task-result.nonce-ttl-seconds=600", + "superagent.task-result.max-body-bytes=12000" + }) +@AutoConfigureMockMvc +@ActiveProfiles("test") +class SuperAgentTaskResultControllerTest { + + private static final String ENDPOINT = "/api/integrations/superagent/task-results"; + private static final String CLIENT_ID = "superagent-test-client"; + private static final String SECRET = "test-superagent-secret"; + + @Autowired + private MockMvc mockMvc; + + @Autowired + private SourceMessageCaptureService captureService; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @Test + void shouldRejectRequestWithoutHmacHeaders() throws Exception { + String body = minimalBody("1", "New Booking", "normal_task", "new_fit_reservation", """ + "case_keys": {}, + "extracted_fields": {} + """); + + mockMvc.perform(post(ENDPOINT) + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.error_code").value("AUTH_HEADER_MISSING")) + .andExpect(content().string(not(containsString(SECRET)))); + } + + @Test + void shouldRejectRequestWithInvalidSignature() throws Exception { + SourceMessageCaptureResult source = captureSourceMessage("mail-hmac-invalid-001"); + String body = minimalBody(source.inboxId().toString(), "New Booking", "normal_task", "new_fit_reservation", """ + "case_keys": {}, + "extracted_fields": {} + """); + String timestamp = Instant.now().toString(); + + mockMvc.perform(post(ENDPOINT) + .contentType(MediaType.APPLICATION_JSON) + .content(body) + .header("X-TH-Hotel-SuperAgent-Client-Id", CLIENT_ID) + .header("X-TH-Hotel-SuperAgent-Timestamp", timestamp) + .header("X-TH-Hotel-SuperAgent-Nonce", "nonce-invalid-signature") + .header("X-TH-Hotel-SuperAgent-Signature", "sha256=invalid")) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.error_code").value("AUTH_SIGNATURE_INVALID")) + .andExpect(content().string(not(containsString(SECRET)))); + } + + @Test + void shouldCreateTemporaryOrderTaskAndCardForNewBookingWithoutBusinessKey() throws Exception { + SourceMessageCaptureResult source = captureSourceMessage("mail-new-booking-temp-001"); + String body = minimalBody(source.inboxId().toString(), "New Booking", "normal_task", "new_fit_reservation", """ + "case_keys": {}, + "extracted_fields": { + "booking_object_type": "FIT Reservation", + "arrival_date": "2026-08-01", + "departure_date": "2026-08-02" + } + """); + + mockMvc.perform(signedPost(body, "nonce-new-booking-temp-001")) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.source_message_id").value(source.inboxId().toString())) + .andExpect(jsonPath("$.idempotent_replay").value(false)) + .andExpect(jsonPath("$.accepted_count").value(1)) + .andExpect(jsonPath("$.items[0].source_event_index").value(1)) + .andExpect(jsonPath("$.items[0].array_index").value(1)) + .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("TEMPORARY")) + .andExpect(content().string(not(containsString(SECRET)))); + + Long transitionCount = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) + FROM workflow_reservation_ai_transition + WHERE source_message_id = ? + AND ai_task_type = 'New Booking' + AND system_task_type = 'NEW_BOOKING' + """, Long.class, source.inboxId()); + Long temporaryOrderCount = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) + FROM workflow_reservation_order + WHERE source_message_id = ? + AND order_status = 'TEMPORARY' + AND order_key_type = 'TEMPORARY' + """, Long.class, source.inboxId()); + Long taskCardCount = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) + FROM workflow_reservation_task_card + WHERE ai_payload_json LIKE '%New Booking%' + AND field_contract_version = 'code-v1' + """, Long.class); + + org.assertj.core.api.Assertions.assertThat(transitionCount).isEqualTo(1L); + org.assertj.core.api.Assertions.assertThat(temporaryOrderCount).isEqualTo(1L); + org.assertj.core.api.Assertions.assertThat(taskCardCount).isGreaterThanOrEqualTo(1L); + } + + @Test + void shouldReturnIdempotentReplayForSameBodyWithNewNonce() throws Exception { + SourceMessageCaptureResult source = captureSourceMessage("mail-idempotent-replay-001"); + String body = minimalBody(source.inboxId().toString(), "New Booking", "normal_task", "new_fit_reservation", """ + "case_keys": {}, + "extracted_fields": {} + """); + + mockMvc.perform(signedPost(body, "nonce-idempotent-replay-001")) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.idempotent_replay").value(false)); + + mockMvc.perform(signedPost(body, "nonce-idempotent-replay-002")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.idempotent_replay").value(true)) + .andExpect(jsonPath("$.warnings[0].code").value("IDEMPOTENT_REPLAY")); + + Long batchCount = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) + FROM workflow_reservation_ai_batch + WHERE source_message_id = ? + """, Long.class, source.inboxId()); + org.assertj.core.api.Assertions.assertThat(batchCount).isEqualTo(1L); + } + + @Test + void shouldRejectSameNonceReplayBeforeProcessingBodyAgain() throws Exception { + SourceMessageCaptureResult source = captureSourceMessage("mail-nonce-replay-001"); + String body = minimalBody(source.inboxId().toString(), "New Booking", "normal_task", "new_fit_reservation", """ + "case_keys": {}, + "extracted_fields": {} + """); + + mockMvc.perform(signedPost(body, "nonce-replay-same")) + .andExpect(status().isCreated()); + + mockMvc.perform(signedPost(body, "nonce-replay-same")) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.error_code").value("AUTH_NONCE_REPLAY")); + } + + @Test + void shouldRejectDifferentPayloadForSameSourceMessage() throws Exception { + SourceMessageCaptureResult source = captureSourceMessage("mail-idempotency-conflict-001"); + String firstBody = minimalBody(source.inboxId().toString(), "New Booking", "normal_task", "new_fit_reservation", """ + "case_keys": {}, + "extracted_fields": {"arrival_date": "2026-08-01"} + """); + String changedBody = minimalBody(source.inboxId().toString(), "New Booking", "normal_task", "new_fit_reservation", """ + "case_keys": {}, + "extracted_fields": {"arrival_date": "2026-08-03"} + """); + + mockMvc.perform(signedPost(firstBody, "nonce-idempotency-conflict-001")) + .andExpect(status().isCreated()); + + mockMvc.perform(signedPost(changedBody, "nonce-idempotency-conflict-002")) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.error_code").value("IDEMPOTENCY_CONFLICT")); + } + + @Test + void shouldRejectTaskResultWhenStableStringExceedsDatabaseLimit() throws Exception { + SourceMessageCaptureResult source = captureSourceMessage("mail-field-too-long-001"); + String body = minimalBody(source.inboxId().toString(), "New Booking", "normal_task", "new_fit_reservation", """ + "case_keys": {}, + "extracted_fields": {} + """).replace( + "\"catalog_code\": \"S01\"", + "\"catalog_code\": \"" + "S".repeat(33) + "\""); + + mockMvc.perform(signedPost(body, "nonce-field-too-long-001")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error_code").value("TASK_RESULT_FIELD_TOO_LONG")); + } + + @Test + void shouldRejectSecurityHeaderWhenClientIdExceedsDatabaseLimit() throws Exception { + SourceMessageCaptureResult source = captureSourceMessage("mail-client-id-too-long-001"); + String body = minimalBody(source.inboxId().toString(), "New Booking", "normal_task", "new_fit_reservation", """ + "case_keys": {}, + "extracted_fields": {} + """); + String longClientId = "client-" + "x".repeat(129); + + mockMvc.perform(signedPostWithClientId(body, "nonce-client-id-too-long-001", longClientId)) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.error_code").value("AUTH_HEADER_INVALID")) + .andExpect(content().string(not(containsString(SECRET)))); + + Long nonceCount = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) + FROM integration_superagent_task_result_nonce + WHERE nonce = 'nonce-client-id-too-long-001' + """, Long.class); + assertThat(nonceCount).isZero(); + } + + @Test + void shouldEnforceOnlyOneActiveOrderPerHotelAndBusinessKey() { + jdbcTemplate.update(""" + INSERT INTO workflow_reservation_order ( + id, hotel_id, order_key_type, order_business_key, active_business_key, + temporary_order_code, order_status, display_name, source_message_id, + version, created_at, updated_at + ) + VALUES ( + 910000000000000001, 'HOTEL-TEST', 'GROUP_CODE', 'GRP-ACTIVE-UNIQUE-001', + 'GRP-ACTIVE-UNIQUE-001', 'TMP-UNIQUE-001', 'ACTIVE', 'GRP-ACTIVE-UNIQUE-001', + 910000000000000101, 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP + ) + """); + + assertThatThrownBy(() -> jdbcTemplate.update(""" + INSERT INTO workflow_reservation_order ( + id, hotel_id, order_key_type, order_business_key, active_business_key, + temporary_order_code, order_status, display_name, source_message_id, + version, created_at, updated_at + ) + VALUES ( + 910000000000000002, 'HOTEL-TEST', 'GROUP_CODE', 'GRP-ACTIVE-UNIQUE-001', + 'GRP-ACTIVE-UNIQUE-001', 'TMP-UNIQUE-002', 'ACTIVE', 'GRP-ACTIVE-UNIQUE-001', + 910000000000000102, 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP + ) + """)).isInstanceOf(DataIntegrityViolationException.class); + + jdbcTemplate.update(""" + INSERT INTO workflow_reservation_order ( + id, hotel_id, order_key_type, order_business_key, active_business_key, + temporary_order_code, order_status, display_name, source_message_id, + version, created_at, updated_at + ) + VALUES ( + 910000000000000003, 'HOTEL-TEST', 'CONFIRMATION_NUMBER', 'CNF-ACTIVE-UNIQUE-001', + 'CNF-ACTIVE-UNIQUE-001', 'TMP-UNIQUE-003', 'ACTIVE', 'CNF-ACTIVE-UNIQUE-001', + 910000000000000103, 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP + ) + """); + + assertThatThrownBy(() -> jdbcTemplate.update(""" + INSERT INTO workflow_reservation_order ( + id, hotel_id, order_key_type, order_business_key, active_business_key, + temporary_order_code, order_status, display_name, source_message_id, + version, created_at, updated_at + ) + VALUES ( + 910000000000000004, 'HOTEL-TEST', 'CONFIRMATION_NUMBER', 'CNF-ACTIVE-UNIQUE-001', + 'CNF-ACTIVE-UNIQUE-001', 'TMP-UNIQUE-004', 'ACTIVE', 'CNF-ACTIVE-UNIQUE-001', + 910000000000000104, 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP + ) + """)).isInstanceOf(DataIntegrityViolationException.class); + } + + @Test + void shouldEnforceUniqueExecutionOrderForQueueTasksInSameOrder() { + jdbcTemplate.update(""" + INSERT INTO workflow_reservation_order ( + id, hotel_id, order_key_type, order_business_key, active_business_key, + temporary_order_code, order_status, display_name, source_message_id, + version, created_at, updated_at + ) + VALUES ( + 910000000000000201, 'HOTEL-TEST', 'GROUP_CODE', 'GRP-QUEUE-UNIQUE-001', + 'GRP-QUEUE-UNIQUE-001', 'TMP-QUEUE-UNIQUE-001', 'ACTIVE', 'GRP-QUEUE-UNIQUE-001', + 910000000000000301, 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP + ) + """); + jdbcTemplate.update(""" + INSERT INTO workflow_reservation_task ( + id, hotel_id, order_id, source_message_id, ai_transition_id, + result_type, ai_task_type, system_task_type, task_card_type, + task_status, queue_participation, execution_order, + blocked_until_parent_completed, version, created_at, updated_at + ) + VALUES ( + 910000000000000202, 'HOTEL-TEST', 910000000000000201, + 910000000000000301, 910000000000000401, 'normal_task', + 'New Booking', 'NEW_BOOKING', 'NEW_BOOKING', + 'PENDING_CONFIRM', 1, 1, 0, 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP + ) + """); + + assertThatThrownBy(() -> jdbcTemplate.update(""" + INSERT INTO workflow_reservation_task ( + id, hotel_id, order_id, source_message_id, ai_transition_id, + result_type, ai_task_type, system_task_type, task_card_type, + task_status, queue_participation, execution_order, + blocked_until_parent_completed, version, created_at, updated_at + ) + VALUES ( + 910000000000000203, 'HOTEL-TEST', 910000000000000201, + 910000000000000302, 910000000000000402, 'normal_task', + 'Update Booking', 'UPDATE_BOOKING', 'UPDATE_BOOKING', + 'PENDING_CONFIRM', 1, 1, 0, 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP + ) + """)).isInstanceOf(DataIntegrityViolationException.class); + } + + @Test + void shouldAttachUpdateBookingToExistingActiveOrderByGroupCode() throws Exception { + SourceMessageCaptureResult newSource = captureSourceMessage("mail-existing-order-new-001"); + String newBookingBody = minimalBody(newSource.inboxId().toString(), "New Booking", "normal_task", "new_group_block", """ + "case_keys": {"group_code": "GRP-M002-EXIST-001"}, + "extracted_fields": {"booking_object_type": "Group Block"} + """); + + MvcResult newBookingResult = mockMvc.perform(signedPost(newBookingBody, "nonce-existing-order-new-001")) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.items[0].order_status").value("ACTIVE")) + .andReturn(); + String existingOrderId = com.jayway.jsonpath.JsonPath.read( + newBookingResult.getResponse().getContentAsString(), + "$.items[0].order_id" + ); + + SourceMessageCaptureResult updateSource = captureSourceMessage("mail-existing-order-update-001"); + String updateBody = minimalBody(updateSource.inboxId().toString(), "Update Booking", "normal_task", "update_stay_dates", """ + "case_keys": {"group_code": "GRP-M002-EXIST-001"}, + "extracted_fields": {"update_subtypes": ["update_stay_dates"]} + """); + + mockMvc.perform(signedPost(updateBody, "nonce-existing-order-update-001")) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.items[0].order_id").value(existingOrderId)) + .andExpect(jsonPath("$.items[0].system_task_type").value("UPDATE_BOOKING")) + .andExpect(jsonPath("$.items[0].task_card_type").value("UPDATE_BOOKING")) + .andExpect(jsonPath("$.items[0].task_status").value("PENDING_CONFIRM")); + } + + @Test + void shouldCreateReadOnlyMessageNotificationOnTemporaryOrderOutsideQueue() throws Exception { + SourceMessageCaptureResult source = captureSourceMessage("mail-message-notification-001"); + String body = minimalBody( + source.inboxId().toString(), + "Message Notification", + "informational_message", + "thank_you", + """ + "case_keys": {}, + "informational_message": { + "message_type": "thank_you", + "summary": "Guest acknowledged the booking update." + } + """); + + mockMvc.perform(signedPost(body, "nonce-message-notification-001")) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.items[0].system_task_type").value("INFORMATIONAL_MESSAGE")) + .andExpect(jsonPath("$.items[0].task_card_type").value("MESSAGE_NOTIFICATION")) + .andExpect(jsonPath("$.items[0].task_status").value("COMPLETED")) + .andExpect(jsonPath("$.items[0].order_status").value("TEMPORARY")); + + Long queueParticipationCount = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) + FROM workflow_reservation_task + WHERE source_message_id = ? + AND queue_participation = 0 + AND task_status = 'COMPLETED' + """, Long.class, source.inboxId()); + org.assertj.core.api.Assertions.assertThat(queueParticipationCount).isEqualTo(1L); + } + + @Test + void shouldMarkLaterTaskReadOnlyUntilPreviousQueueTaskIsCompletedOrFailed() throws Exception { + SourceMessageCaptureResult source = captureSourceMessage("mail-queue-readonly-001"); + String body = twoTaskBody(source.inboxId().toString(), "GRP-M002-QUEUE-001"); + + MvcResult result = mockMvc.perform(signedPost(body, "nonce-queue-readonly-001")) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.accepted_count").value(2)) + .andExpect(jsonPath("$.items[0].execution_order").value(1)) + .andExpect(jsonPath("$.items[1].execution_order").value(2)) + .andReturn(); + String firstTaskId = com.jayway.jsonpath.JsonPath.read(result.getResponse().getContentAsString(), "$.items[0].task_id"); + String secondTaskId = com.jayway.jsonpath.JsonPath.read(result.getResponse().getContentAsString(), "$.items[1].task_id"); + + mockMvc.perform(get("/api/reservation/tasks/{taskId}", secondTaskId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.task_id").value(secondTaskId)) + .andExpect(jsonPath("$.availability.blocked").value(true)) + .andExpect(jsonPath("$.availability.read_only").value(true)) + .andExpect(jsonPath("$.availability.editable").value(false)) + .andExpect(jsonPath("$.availability.confirmable").value(false)) + .andExpect(jsonPath("$.availability.executable").value(false)) + .andExpect(jsonPath("$.availability.blocked_by_task_id").value(firstTaskId)); + + jdbcTemplate.update(""" + UPDATE workflow_reservation_task + SET task_status = 'READY' + WHERE id = ? + """, Long.valueOf(firstTaskId)); + + mockMvc.perform(get("/api/reservation/tasks/{taskId}", secondTaskId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.availability.blocked").value(true)) + .andExpect(jsonPath("$.availability.blocked_by_task_id").value(firstTaskId)); + + jdbcTemplate.update(""" + UPDATE workflow_reservation_task + SET task_status = 'EXECUTING' + WHERE id = ? + """, Long.valueOf(firstTaskId)); + + mockMvc.perform(get("/api/reservation/tasks/{taskId}", secondTaskId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.availability.blocked").value(true)) + .andExpect(jsonPath("$.availability.blocked_by_task_id").value(firstTaskId)); + + jdbcTemplate.update(""" + UPDATE workflow_reservation_task + SET task_status = 'FAILED' + WHERE id = ? + """, Long.valueOf(firstTaskId)); + + mockMvc.perform(get("/api/reservation/tasks/{taskId}", secondTaskId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.availability.blocked").value(false)) + .andExpect(jsonPath("$.availability.read_only").value(false)) + .andExpect(jsonPath("$.availability.editable").value(true)) + .andExpect(jsonPath("$.availability.confirmable").value(true)); + } + + @Test + void shouldReturnTaskDetailFieldsFromTaskCardMatrix() throws Exception { + SourceMessageCaptureResult source = captureSourceMessage("mail-detail-matrix-001"); + String body = minimalBody(source.inboxId().toString(), "New Booking", "normal_task", "new_fit_reservation", """ + "case_keys": {"confirmation_number": "CNF-MATRIX-001"}, + "extracted_fields": {"booking_object_type": "FIT Reservation"} + """); + + MvcResult result = mockMvc.perform(signedPost(body, "nonce-detail-matrix-001")) + .andExpect(status().isCreated()) + .andReturn(); + String taskId = com.jayway.jsonpath.JsonPath.read(result.getResponse().getContentAsString(), "$.items[0].task_id"); + + mockMvc.perform(get("/api/reservation/tasks/{taskId}", taskId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.task_card_type").value("NEW_BOOKING")) + .andExpect(jsonPath("$.field_contract_version").value("code-v1")) + .andExpect(jsonPath("$.fields.length()").value(18)) + .andExpect(jsonPath("$.fields[?(@.field_path=='case_keys.confirmation_number')].display_name") + .value(org.hamcrest.Matchers.contains("Confirmation No."))) + .andExpect(jsonPath("$.fields[?(@.field_path=='case_keys.confirmation_number')].editable") + .value(org.hamcrest.Matchers.contains("是"))) + .andExpect(jsonPath("$.fields[?(@.field_path=='case_keys.confirmation_number')].write_path") + .value(org.hamcrest.Matchers.contains("confirmed_payload_json.case_keys.confirmation_number"))) + .andExpect(jsonPath("$.fields[?(@.field_path=='case_keys.confirmation_number')].value") + .value(org.hamcrest.Matchers.contains("CNF-MATRIX-001"))); + } + + @Test + void shouldConvertFallbackToUpdateBookingAndLogicDeleteEmptyTemporaryOrder() throws Exception { + SourceMessageCaptureResult targetSource = captureSourceMessage("mail-fallback-target-order-001"); + String targetOrderBody = minimalBody(targetSource.inboxId().toString(), "New Booking", "normal_task", "new_group_block", """ + "case_keys": {"group_code": "GRP-FALLBACK-TARGET-001"}, + "extracted_fields": {"booking_object_type": "Group Block"} + """); + MvcResult targetOrderResult = mockMvc.perform(signedPost(targetOrderBody, "nonce-fallback-target-order-001")) + .andExpect(status().isCreated()) + .andReturn(); + String targetOrderId = com.jayway.jsonpath.JsonPath.read( + targetOrderResult.getResponse().getContentAsString(), + "$.items[0].order_id" + ); + + SourceMessageCaptureResult fallbackSource = captureSourceMessage("mail-fallback-convert-001"); + String fallbackBody = minimalBody(fallbackSource.inboxId().toString(), "Fallback", "manual_review", "manual_review", """ + "case_keys": {"group_code": "GRP-FALLBACK-CANDIDATE-001"}, + "manual_review": {"reason_code": "business_boundary_unclear", "visible_reason": "Need human routing."}, + "extracted_fields": {} + """); + MvcResult fallbackResult = mockMvc.perform(signedPost(fallbackBody, "nonce-fallback-convert-001")) + .andExpect(status().isCreated()) + .andReturn(); + String fallbackTaskId = com.jayway.jsonpath.JsonPath.read( + fallbackResult.getResponse().getContentAsString(), + "$.items[0].task_id" + ); + String temporaryOrderId = com.jayway.jsonpath.JsonPath.read( + fallbackResult.getResponse().getContentAsString(), + "$.items[0].order_id" + ); + + mockMvc.perform(post("/api/reservation/tasks/{taskId}/manual-review-conversions", fallbackTaskId) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "target_task_type": "UPDATE_BOOKING", + "target_order_id": "%s" + } + """.formatted(targetOrderId))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.task_id").value(fallbackTaskId)) + .andExpect(jsonPath("$.order_id").value(targetOrderId)) + .andExpect(jsonPath("$.system_task_type").value("UPDATE_BOOKING")) + .andExpect(jsonPath("$.task_card_type").value("UPDATE_BOOKING")) + .andExpect(jsonPath("$.original_order_logic_deleted").value(true)); + + String tempOrderStatus = jdbcTemplate.queryForObject(""" + SELECT order_status + FROM workflow_reservation_order + WHERE id = ? + """, String.class, Long.valueOf(temporaryOrderId)); + Long auditCount = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) + FROM workflow_reservation_audit_log + WHERE task_id = ? + AND action = 'MANUAL_REVIEW_CONVERT' + """, Long.class, Long.valueOf(fallbackTaskId)); + assertThat(tempOrderStatus).isEqualTo("LOGIC_DELETED"); + assertThat(auditCount).isEqualTo(1L); + } + + @Test + void shouldConvertFallbackToNewBookingAndKeepOriginalTemporaryOrder() throws Exception { + SourceMessageCaptureResult fallbackSource = captureSourceMessage("mail-fallback-convert-new-001"); + String fallbackBody = minimalBody(fallbackSource.inboxId().toString(), "Fallback", "manual_review", "manual_review", """ + "case_keys": {"confirmation_number": "CNF-FALLBACK-NEW-001"}, + "manual_review": {"reason_code": "business_boundary_unclear", "visible_reason": "Need human routing."}, + "extracted_fields": {} + """); + MvcResult fallbackResult = mockMvc.perform(signedPost(fallbackBody, "nonce-fallback-convert-new-001")) + .andExpect(status().isCreated()) + .andReturn(); + String fallbackTaskId = com.jayway.jsonpath.JsonPath.read( + fallbackResult.getResponse().getContentAsString(), + "$.items[0].task_id" + ); + String temporaryOrderId = com.jayway.jsonpath.JsonPath.read( + fallbackResult.getResponse().getContentAsString(), + "$.items[0].order_id" + ); + + mockMvc.perform(post("/api/reservation/tasks/{taskId}/manual-review-conversions", fallbackTaskId) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "target_task_type": "NEW_BOOKING" + } + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.task_id").value(fallbackTaskId)) + .andExpect(jsonPath("$.order_id").value(temporaryOrderId)) + .andExpect(jsonPath("$.system_task_type").value("NEW_BOOKING")) + .andExpect(jsonPath("$.task_card_type").value("NEW_BOOKING")) + .andExpect(jsonPath("$.original_order_logic_deleted").value(false)); + + String tempOrderStatus = jdbcTemplate.queryForObject(""" + SELECT order_status + FROM workflow_reservation_order + WHERE id = ? + """, String.class, Long.valueOf(temporaryOrderId)); + assertThat(tempOrderStatus).isEqualTo("ACTIVE"); + String orderBusinessKey = jdbcTemplate.queryForObject(""" + SELECT order_business_key + FROM workflow_reservation_order + WHERE id = ? + """, String.class, Long.valueOf(temporaryOrderId)); + String activeBusinessKey = jdbcTemplate.queryForObject(""" + SELECT active_business_key + FROM workflow_reservation_order + WHERE id = ? + """, String.class, Long.valueOf(temporaryOrderId)); + String displayName = jdbcTemplate.queryForObject(""" + SELECT display_name + FROM workflow_reservation_order + WHERE id = ? + """, String.class, Long.valueOf(temporaryOrderId)); + assertThat(orderBusinessKey).isEqualTo("CNF-FALLBACK-NEW-001"); + assertThat(activeBusinessKey).isEqualTo("CNF-FALLBACK-NEW-001"); + assertThat(displayName).isEqualTo("CNF-FALLBACK-NEW-001"); + } + + @Test + void shouldConvertFallbackToCancelBookingWhenTargetOrderProvided() throws Exception { + SourceMessageCaptureResult targetSource = captureSourceMessage("mail-fallback-cancel-target-001"); + String targetOrderBody = minimalBody(targetSource.inboxId().toString(), "New Booking", "normal_task", "new_fit_reservation", """ + "case_keys": {"confirmation_number": "CNF-FALLBACK-CANCEL-001"}, + "extracted_fields": {"booking_object_type": "FIT Reservation"} + """); + MvcResult targetOrderResult = mockMvc.perform(signedPost(targetOrderBody, "nonce-fallback-cancel-target-001")) + .andExpect(status().isCreated()) + .andReturn(); + String targetOrderId = com.jayway.jsonpath.JsonPath.read( + targetOrderResult.getResponse().getContentAsString(), + "$.items[0].order_id" + ); + + SourceMessageCaptureResult fallbackSource = captureSourceMessage("mail-fallback-convert-cancel-001"); + String fallbackBody = minimalBody(fallbackSource.inboxId().toString(), "Fallback", "manual_review", "manual_review", """ + "case_keys": {}, + "manual_review": {"reason_code": "route_conflict", "visible_reason": "Need human routing."}, + "extracted_fields": {} + """); + MvcResult fallbackResult = mockMvc.perform(signedPost(fallbackBody, "nonce-fallback-convert-cancel-001")) + .andExpect(status().isCreated()) + .andReturn(); + String fallbackTaskId = com.jayway.jsonpath.JsonPath.read( + fallbackResult.getResponse().getContentAsString(), + "$.items[0].task_id" + ); + + mockMvc.perform(post("/api/reservation/tasks/{taskId}/manual-review-conversions", fallbackTaskId) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "target_task_type": "CANCEL_BOOKING", + "target_order_id": "%s" + } + """.formatted(targetOrderId))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.task_id").value(fallbackTaskId)) + .andExpect(jsonPath("$.order_id").value(targetOrderId)) + .andExpect(jsonPath("$.system_task_type").value("CANCEL_BOOKING")) + .andExpect(jsonPath("$.task_card_type").value("CANCEL_BOOKING")) + .andExpect(jsonPath("$.original_order_logic_deleted").value(true)); + } + + @Test + void shouldRequireTargetOrderWhenConvertingFallbackToUpdateOrCancel() throws Exception { + SourceMessageCaptureResult fallbackSource = captureSourceMessage("mail-fallback-target-required-001"); + String fallbackBody = minimalBody(fallbackSource.inboxId().toString(), "Fallback", "manual_review", "manual_review", """ + "case_keys": {}, + "manual_review": {"reason_code": "route_conflict", "visible_reason": "Need human routing."}, + "extracted_fields": {} + """); + MvcResult fallbackResult = mockMvc.perform(signedPost(fallbackBody, "nonce-fallback-target-required-001")) + .andExpect(status().isCreated()) + .andReturn(); + String fallbackTaskId = com.jayway.jsonpath.JsonPath.read( + fallbackResult.getResponse().getContentAsString(), + "$.items[0].task_id" + ); + + mockMvc.perform(post("/api/reservation/tasks/{taskId}/manual-review-conversions", fallbackTaskId) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "target_task_type": "CANCEL_BOOKING" + } + """)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error_code").value("TARGET_ORDER_REQUIRED")); + } + + private SourceMessageCaptureResult captureSourceMessage(String externalMessageId) { + return captureService.capture(new CaptureSourceMessageCommand( + "HOTEL-TEST", + "AGENTBUS", + "EMAIL", + externalMessageId, + "thread-" + externalMessageId, + "frame-" + externalMessageId, + "session-m002", + Instant.parse("2026-07-07T08:00:00Z"), + "guest@example.test", + "M002 SuperAgent intake", + "Please handle booking message.", + "Please handle booking message.", + "{\"source\":{\"external_message_id\":\"" + externalMessageId + "\"}}", + "agentbus-outlook-v1", + List.of() + )); + } + + private org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder signedPost( + String body, + String nonce) throws Exception { + return signedPostWithClientId(body, nonce, CLIENT_ID); + } + + private org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder signedPostWithClientId( + String body, + String nonce, + String clientId) throws Exception { + String timestamp = Instant.now().toString(); + return post(ENDPOINT) + .contentType(MediaType.APPLICATION_JSON) + .content(body) + .header("X-TH-Hotel-SuperAgent-Client-Id", clientId) + .header("X-TH-Hotel-SuperAgent-Timestamp", timestamp) + .header("X-TH-Hotel-SuperAgent-Nonce", nonce) + .header("X-TH-Hotel-SuperAgent-Signature", signature(body, nonce, timestamp, clientId)); + } + + private String minimalBody( + String sourceMessageId, + String taskType, + String resultType, + String taskSubtype, + String itemFields) { + return """ + { + "source_message_id": "%s", + "ai_task_results": [ + { + "source_event_index": 1, + "catalog_code": "S01", + "skill_id": "S01_new_booking_skill", + "result_type": "%s", + "task_type": "%s", + "task_subtype": "%s", + "current_or_history": "current", + "visible_reason": "AI extracted a task result.", + "relevant_message_excerpt": "Please handle booking message.", + "attachments": [], + "file_references": [], + "context_used": {}, + %s, + "additional_operations": [], + "idempotency_key": null + } + ], + "extraction_warnings": [] + } + """.formatted(sourceMessageId, resultType, taskType, taskSubtype, itemFields); + } + + private String twoTaskBody(String sourceMessageId, String groupCode) { + return """ + { + "source_message_id": "%s", + "ai_task_results": [ + { + "source_event_index": 1, + "catalog_code": "S01", + "skill_id": "S01_new_booking_skill", + "result_type": "normal_task", + "task_type": "New Booking", + "task_subtype": "new_group_block", + "current_or_history": "current", + "visible_reason": "AI extracted a new booking.", + "relevant_message_excerpt": "Please create the group booking.", + "attachments": [], + "file_references": [], + "context_used": {}, + "case_keys": {"group_code": "%s"}, + "extracted_fields": {"booking_object_type": "Group Block"}, + "additional_operations": [], + "idempotency_key": null + }, + { + "source_event_index": 2, + "catalog_code": "S02", + "skill_id": "S02_update_booking_amendment_skill", + "result_type": "normal_task", + "task_type": "Update Booking", + "task_subtype": "update_stay_dates", + "current_or_history": "current", + "visible_reason": "AI extracted an update.", + "relevant_message_excerpt": "Please update the group booking.", + "attachments": [], + "file_references": [], + "context_used": {}, + "case_keys": {"group_code": "%s"}, + "extracted_fields": {"update_subtypes": ["update_stay_dates"]}, + "additional_operations": [], + "idempotency_key": null + } + ], + "extraction_warnings": [] + } + """.formatted(sourceMessageId, groupCode, groupCode); + } + + private String signature(String body, String nonce, String timestamp) throws Exception { + return signature(body, nonce, timestamp, CLIENT_ID); + } + + private String signature(String body, String nonce, String timestamp, String clientId) throws Exception { + String bodySha256 = sha256(body); + String canonical = "POST\n" + ENDPOINT + "\n" + timestamp + "\n" + nonce + "\n" + clientId + "\n" + bodySha256; + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(SECRET.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); + return "sha256=" + HexFormat.of().formatHex(mac.doFinal(canonical.getBytes(StandardCharsets.UTF_8))); + } + + private String sha256(String body) throws Exception { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + return HexFormat.of().formatHex(digest.digest(body.getBytes(StandardCharsets.UTF_8))); + } +} diff --git a/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/SuperAgentTaskResultMaxBodyControllerTest.java b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/SuperAgentTaskResultMaxBodyControllerTest.java new file mode 100644 index 0000000..3990459 --- /dev/null +++ b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/SuperAgentTaskResultMaxBodyControllerTest.java @@ -0,0 +1,51 @@ +package cn.nianxx.thhotel.workflows.reservation.control; + +import static org.hamcrest.Matchers.not; +import static org.hamcrest.Matchers.containsString; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import cn.nianxx.thhotel.ThHotelApplication; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +@SpringBootTest( + classes = ThHotelApplication.class, + properties = { + "superagent.task-result.hmac-secret=test-superagent-secret", + "superagent.task-result.max-body-bytes=16" + }) +@AutoConfigureMockMvc +@ActiveProfiles("test") +class SuperAgentTaskResultMaxBodyControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Test + void shouldRejectBodyLargerThanConfiguredLimitBeforeParsingJson() throws Exception { + mockMvc.perform(post("/api/integrations/superagent/task-results") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"source_message_id\":\"too-large\"}")) + .andExpect(status().isPayloadTooLarge()) + .andExpect(jsonPath("$.error_code").value("REQUEST_BODY_TOO_LARGE")) + .andExpect(content().string(not(containsString("test-superagent-secret")))); + } + + @Test + void shouldRejectBodyLargerThanConfiguredLimitWhenContextPathIsConfigured() throws Exception { + mockMvc.perform(post("/hotel/api/integrations/superagent/task-results") + .contextPath("/hotel") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"source_message_id\":\"too-large-context-path\"}")) + .andExpect(status().isPayloadTooLarge()) + .andExpect(jsonPath("$.error_code").value("REQUEST_BODY_TOO_LARGE")); + } +}