实现 V4 卡片确认和来源通知确认
This commit is contained in:
@@ -4,15 +4,15 @@
|
||||
| --- | --- |
|
||||
| 最近更新 | 2026-07-19 |
|
||||
| 当前分支 | `feature/huangting` |
|
||||
| 当前阶段 | M002 V4 入站、多卡模型、持久化基线、入站写入和前端查询接口并行 |
|
||||
| 当前重点 | M002 V4 CP5 已开放 V4 工作台统一列表、订单任务列表 / 详情、S10/S99 来源通知详情查询接口;后续准备做 V4 卡片确认、复核和来源通知 ack 写接口 |
|
||||
| 当前阶段 | M002 V4 入站、多卡模型、持久化基线、入站写入、查询接口和卡片确认接口并行 |
|
||||
| 当前重点 | M002 V4 CP6 已开放 V4 卡片确认和 S10/S99 来源通知 ack;后续准备做 V4 复核解阻、前端页面和目录校验 |
|
||||
|
||||
## 1. 当前 Checkpoint
|
||||
|
||||
- 名称:`M002-V4-CP5-v4-query-interfaces`
|
||||
- 状态:Done,已实现 `GET /api/reservation/workbench-items`、`GET /api/reservation/order-tasks`、`GET /api/reservation/order-tasks/{orderTaskId}`、`GET /api/reservation/source-notifications/{notificationId}`。
|
||||
- 目标:让 V4 新模型中的业务订单任务、多卡和 S10/S99 来源通知可以被前端安全查询。
|
||||
- 边界:CP5 只做查询;不实现 V4 卡片确认 / 复核、S10/S99 ack、订单详情 V4 专属时间线、OPERA / OHIP、前端页面和历史数据迁移。
|
||||
- 名称:`M002-V4-CP6-v4-card-confirm-and-source-notification-ack`
|
||||
- 状态:Done,已实现 `POST /api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/confirm` 和 `POST /api/reservation/source-notifications/{notificationId}/ack`。
|
||||
- 目标:让 V4 新模型中的 Basic Information、业务卡和 S10/S99 来源通知可以由前端确认,并写入当前登录用户审计。
|
||||
- 边界:CP6 不实现 V4 复核解阻、普通任务任意切换订单、订单详情 V4 专属时间线、OPERA / OHIP、前端页面和历史数据迁移。
|
||||
|
||||
## 2. 当前优先级
|
||||
|
||||
@@ -34,12 +34,12 @@
|
||||
- `docs/import/` 下按日期导入的资料是输入材料,不等同于当前权威开发契约;当前开发应优先看 `docs/project/README.md` 标记为当前有效或权威契约的文档。
|
||||
- 后续每完成一个 Feature 或 Checkpoint,需要更新本文件,避免项目状态继续沉淀在聊天记录里。
|
||||
- M010 Rooming List Excel 生成后端 CP1 和前端 V1 已实现:前端 `/reservation/rooming-lists/new` 上传来源名单和手工字段,后端同步生成 `.xlsx` 直接下载,第一版不落库、不上传 OSS。
|
||||
- M002 V4 CP1 当前已完成入站解析和现有任务链路过渡适配;M002 V4 CP2 已完成订单任务与多卡领域模型设计;M002 V4 CP3 已完成 V4 订单任务、多卡和 S10/S99 来源通知表结构与 Repository 基线;M002 V4 CP4 已完成入站写入新模型;M002 V4 CP5 已完成前端查询接口。V4 写接口和 V4 前端页面仍未完成代码实现。
|
||||
- M002 V4 CP1 当前已完成入站解析和现有任务链路过渡适配;M002 V4 CP2 已完成订单任务与多卡领域模型设计;M002 V4 CP3 已完成 V4 订单任务、多卡和 S10/S99 来源通知表结构与 Repository 基线;M002 V4 CP4 已完成入站写入新模型;M002 V4 CP5 已完成前端查询接口;M002 V4 CP6 已完成普通卡片确认和 S10/S99 来源通知 ack。V4 复核接口和 V4 前端页面仍未完成代码实现。
|
||||
- M002 V4 CP2 已确认:V4 工作台统一列表草案为 `/api/reservation/workbench-items`,业务订单任务接口新开 `/api/reservation/order-tasks/**`,S10/S99 来源通知详情草案为 `/api/reservation/source-notifications/{notificationId}`;S10/S99 使用来源通知模型,不再挂隐藏技术订单;`FIT + BOOKING_CODE` 不建 ACTIVE 唯一约束,匹配多条进人工复核;Basic Information 必须先确认;Account / Market / Source 第一版使用固定种子数据;旧 V2/V3 任务详情和草稿确认接口后续可逐步废弃。
|
||||
|
||||
## 5. Next Steps
|
||||
|
||||
- 后续如继续做 M002 V4,应优先进入 `M002-V4-CP6`:实现 V4 卡片确认、复核解阻、S10/S99 来源通知 ack、审计和 version 并发校验。
|
||||
- 后续如继续做 M002 V4,应优先进入 `M002-V4-CP7`:实现 V4 复核解阻 / 复核场景订单归属确认,或进入 V4 前端页面接入 checkpoint。
|
||||
- 后续新增重要功能时,优先在 `docs/project/requirements/` 或未来 `docs/specs/` 中形成 Spec,再实现代码。
|
||||
- M010 后续如需预览、历史记录、OSS 下载、订单 / 任务预填或客户字段目录化,再单独开前后端 checkpoint。
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
- 任务状态 `FAILED` 第一版视为结束状态,不阻塞后续任务;但失败的 OPERA 操作不能跳过,必须展示失败原因并允许重试。
|
||||
- M002 V3 新入口采用结构化 `S10/S99`:`S10` 表示未匹配当前支持的业务事件,`S99` 表示输入不足或无法形成业务素材包;旧 `S000/S999` 继续按历史数据兼容展示。
|
||||
- V3 S10/S99 会创建旧 `SOURCE_MESSAGE_ONLY` 只读源邮件通知卡,任务列表可见,订单列表不可见;V4 S10/S99 已改为独立来源通知模型,并通过 V4 工作台 / 来源通知详情接口展示。
|
||||
- 源邮件只读通知卡不允许编辑、确认、人工转换订单、执行 OPERA 或重试 OPERA;不参与订单任务执行队列,不阻塞其他任务,也不被其他任务阻塞。
|
||||
- 旧 V3 源邮件只读通知卡不允许编辑、确认、人工转换订单、执行 OPERA 或重试 OPERA;V4 S10/S99 来源通知支持确认已读 / 已处理 ack,但不支持编辑、复核、OPERA 或人工终止,不参与订单任务执行队列,不阻塞其他任务,也不被其他任务阻塞。
|
||||
- type-known manual review 已支持同卡复核解阻第一版:应展示为原业务任务卡的复核模式,不应统一展示成 Fallback。只有业务类型或 subtype 本身未知时才进入 Fallback。
|
||||
- 复核场景下允许用户确认订单归属;当前第一版只允许确认当前任务所属订单,不等于开放普通任务任意切换订单。
|
||||
- 历史 Message Notification 挂临时订单,只读展示,不参与订单任务执行队列,不阻塞其他任务,也不被其他任务阻塞。
|
||||
@@ -58,6 +58,8 @@
|
||||
| `GET /api/reservation/order-tasks` | 查询 V4 业务订单任务列表 | 必须带 Bearer token,需要 `RESERVATION_TASK_READ`;只返回 V4 业务订单任务,不包含 S10/S99 来源通知;支持 `hotel_id`、`order_id`、`order_task_status`、`card_status`、`keyword`、`page_num`、`page_size`;`order_task_status` 非 `OPEN` / `COMPLETED` 返回 400,`card_status` 非 V4 卡状态返回 400;`card_status` 只筛业务 / 可处理卡,固定来源邮件展示卡不参与筛选。 |
|
||||
| `GET /api/reservation/order-tasks/{orderTaskId}` | 查询 V4 订单任务详情 | 必须带 Bearer token,需要 `RESERVATION_TASK_READ`,后端按订单任务实际酒店校验访问权;返回 `order_task`、`source_message_summary`、`source_message_card`、`basic_information_card`、`business_cards[]`、`card_counts`、`adapter_contract_errors[]` 和 `availability`;来源摘要按酒店过滤,邮件正文和附件仍走 SourceMessage 会话接口。 |
|
||||
| `GET /api/reservation/source-notifications/{notificationId}` | 查询 V4 S10/S99 来源通知详情 | 必须带 Bearer token,需要 `RESERVATION_TASK_READ`,后端按来源通知实际酒店校验访问权;只返回通知摘要、来源邮件通知卡、会话摘要和 `availability`;不返回订单任务、业务卡、邮件正文、附件 URL 或原始 AI payload。 |
|
||||
| `POST /api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/confirm` | 确认 V4 订单任务卡 | 必须带 Bearer token,需要 `RESERVATION_TASK_CONFIRM`,请求 JSON 带 `version`,可选 `confirmed_payload`;Basic Information 必须先确认,业务卡第一版不强制逐张顺序确认;确认后卡片 `CONFIRMED`、写 `confirmed_payload_json/confirmed_at/confirmed_by` 并锁定,重复确认返回错误;成功返回刷新后的订单任务详情。 |
|
||||
| `POST /api/reservation/source-notifications/{notificationId}/ack` | 确认 V4 S10/S99 来源通知已读 / 已处理 | 必须带 Bearer token,需要 `RESERVATION_TASK_CONFIRM`,请求 JSON 带 `version`;确认后 `notification_status=ACKED`,写 `ack_by/ack_at`,成功返回刷新后的来源通知详情;该动作不创建订单、不参与订单阻塞。 |
|
||||
| `GET /api/reservation/orders/{orderId}` | 查询订单详情与任务时间线 | 必须带 Bearer token,需要 `RESERVATION_ORDER_READ`,后端按订单所属酒店做访问校验;`include_tasks=false` 可只取订单摘要;时间线按后端队列顺序返回,前端不要自行按创建时间重排;`tasks[]` 已返回来源邮件会话摘要字段和 V3 路由字段;隐藏技术订单详情不可作为普通订单页打开。 |
|
||||
| `GET /api/reservation/tasks/{taskId}` | 查询任务详情 | 必须带 Bearer token,需要 `RESERVATION_TASK_READ`,后端按任务所属酒店做访问校验;以返回的可处理状态和只读原因控制按钮,不只看任务状态;`fields[]` 已包含 P0 字段元数据;旧源邮件只读通知卡字段列表和 OPERA 操作列表为空;V3 结构化 S10/S99 通过 `source_message_only_result.agent_assessment`、`notification`、`manual_review` 展示;普通业务任务可通过 `adapter_contract_errors[]` 和 `unhandled_intents[]` 查看同批次未建任务的诊断信息;type-known manual review 会返回顶层 `review_status`、`review_resolution` 和 `manual_review`。 |
|
||||
| `PUT /api/reservation/tasks/{taskId}/draft` | 保存任务草稿 | 只保存草稿,不代表用户最终确认。 |
|
||||
@@ -92,7 +94,7 @@
|
||||
| `GET /api/reservation/tasks/{taskId}` | 补齐顶层来源邮件字段,并扩展 `fields[]` 元数据。 | 顶层来源字段用于打开邮件会话;`fields[]` 中的 `result_type`、`task_type`、`task_subtype`、`default_value_source` 用于前端字段分组、调试和白名单对齐。 |
|
||||
| `GET /api/source-messages/{sourceMessageId}/conversation` | 新增邮件会话详情接口,并补齐 `html_body_sanitized` / `html_render_mode`。 | 当前唯一推荐路径是这个接口;前端渲染邮件 HTML 时优先使用 `html_body_sanitized`;不要调用历史讨论过的 `/api/source-message-conversations/{externalConversationId}`。 |
|
||||
| `POST /api/system/debug/eml-superagent-runs` | 新增 Debug EML 上传到 SuperAgent 调试接口,并补齐独立 Debug 外部消息 ID、原始 Message-ID 保留、安全 HTML 字段和入口通知识别。 | 只用于调试页面;请求为 multipart/form-data;必须传 `X-TH-Hotel-Debug-Upload-Key`,但该 key 不能写进前端源码、构建产物、URL、localStorage 或错误上报;SuperAgent 返回旧 S000/S999 或新 S10/S99 入口通知时都不应被前端视为 JSON 解析失败。 |
|
||||
| `GET /api/reservation/workbench-items` / `/api/reservation/order-tasks/**` / `/api/reservation/source-notifications/{notificationId}` | 新增 M002 V4 CP5 查询接口。 | 这是 V4 新模型前端主入口;查询阶段只读,`confirmable=false`、`ackable=false` 表示写接口还没开放,不是前端按钮 bug。工作台条目已返回 `created_at` / `updated_at` 作为排序兜底和调试字段;前端不要继续从旧 `/api/reservation/tasks/**` 推断 V4 多卡详情。 |
|
||||
| `GET /api/reservation/workbench-items` / `/api/reservation/order-tasks/**` / `/api/reservation/source-notifications/{notificationId}` | 新增 M002 V4 CP5 查询接口,并在 CP6 打开卡片确认 / 来源通知 ack availability。 | 这是 V4 新模型前端主入口;前端应按每张卡或通知返回的 `availability.confirmable`、`availability.ackable`、`readonly_reason_code` 控制按钮。工作台条目已返回 `created_at` / `updated_at` 作为排序兜底和调试字段;前端不要继续从旧 `/api/reservation/tasks/**` 推断 V4 多卡详情。 |
|
||||
|
||||
V4 CP5 分页注意:`page_num` 从 1 开始,后端第一版安全上限为 100;`page_size` 最大 100。超出上限时后端按上限处理并在 `page.page_num` / `page.page_size` 中返回实际使用值。`order_task_status` 和 `card_status` 是稳定枚举查询参数,前端不要传中文文案或自造状态码。
|
||||
|
||||
@@ -461,15 +463,15 @@ RESERVATION_ROOMING_LIST_GENERATE
|
||||
|
||||
- 普通任务切换订单接口继续后置。
|
||||
- M002 V4 入站解析与数据模型基线已完成第一版:后端可接收 `source_message + order_contexts[] + message_events[]`,识别 `NEW_BOOKING`、`UPDATE_BOOKING`、`CANCEL_BOOKING`、`TRACE_RESERVATION_NOTES`、`ROOMING_LIST`、`PAYMENT`,并保存 V4 原始 payload、`route_code`、系统处理分类和 `field_contract_version=20260718-v4`。前端暂不需要直接调用 V4 回调接口。
|
||||
- M002 V4 CP5 已完成查询接口:普通 V4 业务包可通过 `/api/reservation/workbench-items`、`/api/reservation/order-tasks`、`/api/reservation/order-tasks/{orderTaskId}` 查看;V4 S10/S99 来源通知可通过 `/api/reservation/source-notifications/{notificationId}` 查看。卡片确认、复核和 S10/S99 ack 写接口仍未开放。
|
||||
- M002 V4 CP5 已完成查询接口:普通 V4 业务包可通过 `/api/reservation/workbench-items`、`/api/reservation/order-tasks`、`/api/reservation/order-tasks/{orderTaskId}` 查看;V4 S10/S99 来源通知可通过 `/api/reservation/source-notifications/{notificationId}` 查看。M002 V4 CP6 已开放普通卡片确认和 S10/S99 ack 写接口;V4 复核写接口仍未开放。
|
||||
- V4 任务卡的 `display_payload_json` 只保留后端白名单展示字段;`ai_payload_json` 才包含完整 SuperAgent 原始 event。后续 V4 查询接口不得把 `ai_payload_json`、附件 URL 或 raw evidence 直接给普通页面渲染。
|
||||
- V4 可映射 event 现阶段仍保留现有任务详情结构作为过渡兼容;任务详情中若出现 `field_contract_version=20260718-v4` 或 AI payload 内的 `v4_source_message`、`v4_order_context`、`v4_message_event`,前端第一版只读展示即可,不要据此假定完整 V4 多卡页面已经完成。
|
||||
- V4 `PAYMENT.attachment_ids[]` 不匹配、`UPDATE_BOOKING` 携带 `rate_code` 等问题会出现在任务详情同批次的 `adapter_contract_errors[]` 只读诊断块中,不展示保存、确认、执行或重试按钮。该字段只返回白名单诊断字段,不返回完整 AI payload、邮件正文、附件 URL 或 raw evidence。
|
||||
- V4 包级契约错误只会保存在 AI transition 中,不会出现在普通任务列表;V4 event 级契约错误如果同批次存在其它业务任务,前端仍按任务详情里的 `adapter_contract_errors[]` 只读展示诊断信息。
|
||||
- M002 V4 CP2 订单任务与多卡领域模型设计已落到 `docs/project/requirements/M002-v4-order-task-card-domain-model-cp2.md`:后续前端 V4 页面应围绕 `order_task + source_message_card + basic_information_card + business_cards[]` 设计;V4 工作台统一列表、业务订单任务列表 / 详情和 S10/S99 来源通知详情已实现。
|
||||
- M002 V4 CP3 已新增 V4 订单任务、任务卡、S10/S99 来源通知三张表和 Repository 基线;M002 V4 CP4 已把正式 V4 回调写入这些表;M002 V4 CP5 已开放查询。当前仍不代表 V4 卡片确认、复核或 S10/S99 ack API 已经可用。
|
||||
- M002 V4 CP3 已新增 V4 订单任务、任务卡、S10/S99 来源通知三张表和 Repository 基线;M002 V4 CP4 已把正式 V4 回调写入这些表;M002 V4 CP5 已开放查询;M002 V4 CP6 已开放普通卡片确认和 S10/S99 ack。当前仍不代表 V4 复核 API 已经可用。
|
||||
- V4 新模型确认口径是不保存后端草稿、卡片最终确认后锁定、技术异常不进入用户可处理卡、当前不生成 OPERA 模拟操作。Basic Information 必须先确认;其它业务卡第一版不强制逐张顺序确认。现有 V3 `draft`、`confirm`、`manual-review-resolutions` 和 OPERA 模拟接口仍只代表旧链路能力,不能直接等同 V4 多卡最终接口。
|
||||
- V4 S10/S99 已采用来源通知模型入库:新 V4 `route_code=S10/S99` 不再挂隐藏技术订单,也不再创建旧 `SOURCE_MESSAGE_ONLY` 任务;对应工作台 / 来源通知详情查询接口已开放,ack 写接口仍未开放。旧 `SOURCE_MESSAGE_ONLY` 只读任务仅代表 V3 S10/S99 和旧 S000/S999 兼容数据。
|
||||
- V4 S10/S99 已采用来源通知模型入库:新 V4 `route_code=S10/S99` 不再挂隐藏技术订单,也不再创建旧 `SOURCE_MESSAGE_ONLY` 任务;对应工作台 / 来源通知详情查询接口和 ack 写接口已开放。旧 `SOURCE_MESSAGE_ONLY` 只读任务仅代表 V3 S10/S99 和旧 S000/S999 兼容数据。
|
||||
- M002 V3 的结构化 `S10/S99` 入站、40 条 P0.1 路由枚举 / 稳定配置、`UNHANDLED_CURRENT_INTENT`、`adapter_contract_error` transition 最小落库、任务列表 / 订单时间线 / 任务详情 V3 路由字段和只读诊断块透出、type-known manual review 同卡解阻第一版、typed infrastructure error、P0 fixtures 回归基线和 Parent Group / Cancel Allotment 路由修订均已完成。
|
||||
- 系统管理后台 V1 已完成;后续若要做用户搜索更多筛选、批量操作、密码策略增强、MFA、登录设备管理,应单独开需求。
|
||||
- 现有 Reservation / SourceMessage 业务接口的强制登录、强制权限和业务审计 actor 全量迁移仍后置。
|
||||
|
||||
@@ -477,12 +477,12 @@ M002 V4 CP2 设计文档已落地:
|
||||
- 文档路径:`docs/project/requirements/M002-v4-order-task-card-domain-model-cp2.md`。
|
||||
- 设计内容:SourceMessage 邮件展示卡、S10/S99 来源通知、`source_message_id + order_ref` 订单任务、Basic Information 独立卡、每个 V4 event 的业务卡、卡片确认 / 复核 / 锁定、同订单阻塞、表结构草案和后续接口草案。
|
||||
- 已确认:V4 工作台统一列表新开 `/api/reservation/workbench-items`,业务订单任务新开 `/api/reservation/order-tasks/**`,S10/S99 来源通知使用 `/api/reservation/source-notifications/**`;S10/S99 采用来源通知模型;`FIT + BOOKING_CODE` 不建 ACTIVE 唯一约束,匹配多条进人工复核;Basic Information 必须先确认,其它业务卡第一版不强制逐张确认;Account / Market / Source 目录第一版使用后端固定种子数据。
|
||||
- 当前状态:CP3 表结构 / Repository、CP4 入站写入新模型和 CP5 查询接口已完成;V4 卡片确认 / 复核、S10/S99 ack 和前端页面仍未实现。
|
||||
- 当前状态:CP3 表结构 / Repository、CP4 入站写入新模型、CP5 查询接口和 CP6 普通卡片确认 / S10/S99 ack 已完成;V4 复核接口和前端页面仍未实现。
|
||||
|
||||
仍需后续 checkpoint 实现:
|
||||
|
||||
- V4 表结构和 Repository 落地已完成第一版:新增 V4 订单任务表、V4 任务卡表和 V4 来源通知表,并提供 Entity、Mapper、Repository、幂等创建、`order_context_index` 稳定排序、非 event 卡 `source_event_index=0` 和 version 乐观锁更新基础方法。
|
||||
- V4 入站写入新模型已完成第一版:真正创建 SourceMessage 展示卡、Basic Information 卡、业务卡和 S10/S99 来源通知。
|
||||
- V4 卡片确认 / 复核 / 锁定、S10/S99 ack、同订单阻塞写侧校验和业务审计。
|
||||
- V4 复核解阻、复核场景订单归属确认和前端页面模型切换。
|
||||
- V4 前端页面模型、任务详情字段矩阵和目录校验完全切换。
|
||||
- 真实 OPERA / OHIP、普通任务任意切换订单、字段矩阵从当前扁平结构整体迁移到 0711 P0 新结构、历史旧 Parent Cancel Booking payload 批量迁移。
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
| --- | --- |
|
||||
| 文档版本 | 1.5 |
|
||||
| 日期 | 2026-07-19 |
|
||||
| 状态 | 当前 V4 字段基线;后端已完成 CP1 入站解析基线、CP2 多卡模型设计、CP3 持久化基线、CP4 入站写入新模型和 CP5 查询接口,V4 写接口仍需后续 checkpoint |
|
||||
| 状态 | 当前 V4 字段基线;后端已完成 CP1 入站解析基线、CP2 多卡模型设计、CP3 持久化基线、CP4 入站写入新模型、CP5 查询接口和 CP6 普通卡片确认 / S10/S99 ack |
|
||||
| 适用范围 | 0718 业务基线下,Agent → Adapter / MCP → 信息系统的业务回调字段 |
|
||||
| 不适用范围 | 数据库表设计、前端视觉细节、真实 PMS API、技术失败后台重试、旧 M002 V3 数据兼容 |
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
|
||||
本契约用于后续 M002 V4 主流程设计、后端领域建模、前端页面模型、Adapter / MCP Schema 对齐和 SuperAgent 联调。当前后端已按本文完成 V4 入站解析基线:能识别 V4 包、校验关键契约、保存 AI transition / 任务卡原始 payload,并把可映射的六类 event 先接入现有订单任务链路。
|
||||
|
||||
V4 订单任务与多卡领域模型的 CP2 设计已经单独落到 `M002-v4-order-task-card-domain-model-cp2.md`。截至 CP5,表结构、Entity、Mapper、Repository 基线已经实现,SuperAgent V4 入站已经能写入 V4 订单任务、来源邮件展示卡、Basic Information 卡、业务卡和 S10/S99 来源通知;V4 工作台、订单任务列表 / 详情和来源通知详情查询接口已实现,卡片确认、复核和来源通知 ack 写接口仍未实现。
|
||||
V4 订单任务与多卡领域模型的 CP2 设计已经单独落到 `M002-v4-order-task-card-domain-model-cp2.md`。截至 CP6,表结构、Entity、Mapper、Repository 基线已经实现,SuperAgent V4 入站已经能写入 V4 订单任务、来源邮件展示卡、Basic Information 卡、业务卡和 S10/S99 来源通知;V4 工作台、订单任务列表 / 详情、来源通知详情查询接口、普通卡片确认和 S10/S99 ack 写接口已实现,V4 复核写接口仍未实现。
|
||||
|
||||
当前已确认开发阶段数据可以清空,因此 M002 V4 后续可以按新模型重建,不要求兼容旧任务数据、旧草稿、旧 OPERA 模拟、旧 `S000/S999`、旧 Fallback 或旧 `case_keys`。
|
||||
|
||||
@@ -723,18 +723,20 @@ AI 回调包
|
||||
- 合法 V4 event 按 `event_type` 创建 `ROOM_INFORMATION`、`TRACE_RESERVATION_NOTES`、`ROOMING_LIST` 或 `PAYMENT` 业务卡;event 契约错误只落 AI transition。
|
||||
- V4 S10/S99 写入 `workflow_reservation_v4_source_notification`,状态为 `ACK_REQUIRED`。
|
||||
|
||||
当前 CP5 已完成:
|
||||
当前 CP5 / CP6 已完成:
|
||||
|
||||
- `GET /api/reservation/workbench-items` 查询 V4 工作台统一列表,混排 V4 业务订单任务和 S10/S99 来源通知。
|
||||
- `GET /api/reservation/order-tasks` 查询 V4 业务订单任务列表,不包含 S10/S99 来源通知。
|
||||
- `GET /api/reservation/order-tasks/{orderTaskId}` 查询 V4 订单任务详情,按来源邮件展示卡、Basic Information 卡和业务卡拆分。
|
||||
- `GET /api/reservation/source-notifications/{notificationId}` 查询 V4 S10/S99 来源通知详情。
|
||||
- CP5 查询接口强制 Bearer 登录、`RESERVATION_TASK_READ` 和酒店访问权;不返回邮件正文、附件 URL、`ai_payload_json` 或来源通知原始 payload。
|
||||
- `POST /api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/confirm` 确认 V4 卡片,强制 Bearer 登录、`RESERVATION_TASK_CONFIRM`、酒店访问权和 version 并发校验。
|
||||
- `POST /api/reservation/source-notifications/{notificationId}/ack` 确认 V4 S10/S99 来源通知已读 / 已处理,强制 Bearer 登录、`RESERVATION_TASK_CONFIRM`、酒店访问权和 version 并发校验。
|
||||
|
||||
当前仍未完成:
|
||||
|
||||
- 普通 V4 业务包暂时仍保留旧 V3 任务状态、草稿和 OPERA 模拟骨架兼容,便于前端过渡;V4 写接口和前端页面完成后再逐步废弃旧链路。
|
||||
- V4 卡片确认、复核解阻和 S10/S99 来源通知 ack 写接口。
|
||||
- V4 复核解阻写接口。
|
||||
- 尚未接入真实 PMS / OPERA / OHIP。
|
||||
- 尚未改造前端 V4 页面模型。
|
||||
|
||||
@@ -754,4 +756,4 @@ AI 回调包
|
||||
- 技术异常只进入 AI transition / 技术运行记录,不进入用户可处理卡。
|
||||
- CP3 已新增 V4 订单任务表、V4 任务卡表和 V4 来源通知表,继续复用 SourceMessage、AI batch、AI transition、Reservation Order 和业务审计表。
|
||||
|
||||
CP3 只完成表结构、Entity、Mapper、Repository、幂等创建、`order_context_index` 稳定排序、非 event 卡 `source_event_index=0` 和基础乐观锁更新。CP4 已把 V4 回调写入新模型。CP5 已开放查询接口。后续开发应继续补卡片确认 / 复核、S10/S99 ack 和前端页面。
|
||||
CP3 只完成表结构、Entity、Mapper、Repository、幂等创建、`order_context_index` 稳定排序、非 event 卡 `source_event_index=0` 和基础乐观锁更新。CP4 已把 V4 回调写入新模型,CP5 已开放查询接口,CP6 已开放普通卡片确认和 S10/S99 ack。后续开发应继续补 V4 复核和前端页面。
|
||||
|
||||
@@ -4,11 +4,11 @@
|
||||
|
||||
| 项目 | 内容 |
|
||||
| --- | --- |
|
||||
| 文档版本 | 0.3 |
|
||||
| 文档版本 | 0.4 |
|
||||
| 日期 | 2026-07-19 |
|
||||
| 状态 | CP2 设计已确认;CP3 表结构、Entity、Mapper、Repository 基线已实现;CP4 入站写入新模型已实现;CP5 查询接口已实现 |
|
||||
| 状态 | CP2 设计已确认;CP3 表结构、Entity、Mapper、Repository 基线已实现;CP4 入站写入新模型已实现;CP5 查询接口已实现;CP6 卡片确认和 S10/S99 ack 已实现 |
|
||||
| 适用范围 | M002 V4 入站后的订单任务、多卡、状态、查询和写操作设计 |
|
||||
| 不适用范围 | 卡片确认 / 复核接口、S10/S99 ack 接口、真实 PMS / OPERA / OHIP、前端页面视觉稿、历史数据迁移 |
|
||||
| 不适用范围 | V4 复核接口、真实 PMS / OPERA / OHIP、前端页面视觉稿、历史数据迁移 |
|
||||
|
||||
## 1. 文档定位
|
||||
|
||||
@@ -16,7 +16,7 @@ M002 V4 CP1 已完成 SuperAgent V4 回调包入站解析、基础校验、路
|
||||
|
||||
本文是 CP2 设计文档,用于把 2026-07-18 V4 字段契约落成后续可开发的数据模型和接口草案。
|
||||
|
||||
截至 CP5,后端已实现本文第 10、11 节中的持久化基线,并已把 SuperAgent V4 入站结果写入新表:普通业务包创建 V4 订单任务、来源邮件展示卡、Basic Information 卡和业务卡;V4 S10/S99 创建来源通知。当前已开放 V4 工作台、订单任务列表 / 详情和来源通知详情查询接口;V4 卡片确认、复核和来源通知 ack 写接口仍未开放。
|
||||
截至 CP6,后端已实现本文第 10、11 节中的持久化基线,并已把 SuperAgent V4 入站结果写入新表:普通业务包创建 V4 订单任务、来源邮件展示卡、Basic Information 卡和业务卡;V4 S10/S99 创建来源通知。当前已开放 V4 工作台、订单任务列表 / 详情、来源通知详情查询接口、V4 卡片确认接口和 S10/S99 来源通知 ack 接口;V4 复核写接口仍未开放。
|
||||
|
||||
后续如本文与 `M002-v4-agent-callback-field-contract.md` 的字段契约冲突,以字段契约为准;如与安全边界冲突,以 `security-access-control-boundary.md` 为准。
|
||||
|
||||
@@ -26,7 +26,7 @@ M002 V4 CP1 已完成 SuperAgent V4 回调包入站解析、基础校验、路
|
||||
| --- | --- | --- |
|
||||
| 入站识别 | 已识别 `route_code`、`source_message`、`order_contexts[]`、`message_events[]` | CP4 已把有合法 event 的 `order_ref` 建成订单任务聚合;CP5 已开放 V4 安全查询接口 |
|
||||
| SourceMessage | 已按 `source_message.source_message_id` 反查 SourceMessage Inbox | CP4 已固定生成普通业务包内邮件展示卡;邮件正文完整读取仍走 SourceMessage 会话接口 |
|
||||
| Basic Information | 已写入 V4 Basic Information 独立卡 | 目录校验、确认 / 复核写接口仍待 CP6 / CP7 |
|
||||
| Basic Information | 已写入 V4 Basic Information 独立卡 | CP6 已支持确认并锁定;目录校验和复核写接口仍待后续 checkpoint |
|
||||
| 业务 Event | 可映射 event 临时创建旧 `workflow_reservation_task`,并已额外创建 V4 业务卡 | 旧任务链路仍作前端过渡兼容,后续 V4 查询和写接口完成后再逐步废弃 |
|
||||
| 技术错误 | 已落 `adapter_contract_error` transition | 已符合目标方向:不创建用户可处理卡 |
|
||||
| 草稿 / READY / OPERA | 仍复用 V3 草稿、READY 和 OPERA 模拟骨架 | V4 新数据确认口径是不保存草稿、确认后锁定、当前不生成 OPERA |
|
||||
@@ -417,7 +417,7 @@ CP3 Repository 已封装:
|
||||
- 来源通知状态的 version 乐观锁更新基础方法。
|
||||
- event 卡必须传入正数一基 `source_event_index`;漏传会拒绝写入,避免被误当成非 event 卡。
|
||||
|
||||
CP5 尚未实现卡片确认、复核解阻、订单归属确认、来源通知 ack 的业务 Service 和 API;这些仍属于后续 checkpoint。
|
||||
CP6 已实现普通卡片确认和 S10/S99 来源通知 ack 的业务 Service 和 API;V4 复核解阻、复核场景订单归属确认仍属于后续 checkpoint。
|
||||
|
||||
Service 不直接访问 Mapper。
|
||||
|
||||
@@ -427,10 +427,10 @@ Service 不直接访问 Mapper。
|
||||
|
||||
- `ReservationV4TaskIntakeService`:已实现,V4 入站从 AI transition 落 V4 订单任务、卡片和 S10/S99 来源通知。
|
||||
- `ReservationV4OrderTaskQueryService`:前端查询订单任务列表和详情。
|
||||
- `ReservationV4TaskCardCommandService`:处理卡片确认、复核和订单归属确认。
|
||||
- `ReservationV4SourceNotificationService`:处理 S10/S99 来源通知查询和确认。
|
||||
- `ReservationV4CommandService`:已实现卡片确认和 S10/S99 来源通知确认;后续继续承接 V4 复核和订单归属确认。
|
||||
- `ReservationV4SourceNotificationService`:来源通知查询已并入 V4 查询服务,确认已并入 V4 命令服务。
|
||||
|
||||
当前 `ReservationAiTaskIntakeServiceImpl` 已在 V4 分支调用 `ReservationV4TaskIntakeService` 完成新模型写入;后续查询、确认和复核仍应继续拆到独立 V4 service,避免主入站类继续膨胀。
|
||||
当前 `ReservationAiTaskIntakeServiceImpl` 已在 V4 分支调用 `ReservationV4TaskIntakeService` 完成新模型写入;查询和确认已拆到独立 V4 service,后续复核仍应继续留在 V4 命令侧,避免主入站类继续膨胀。
|
||||
|
||||
## 12. 前端查询接口
|
||||
|
||||
@@ -578,7 +578,7 @@ GET /api/reservation/orders/{orderId}/order-tasks
|
||||
|
||||
用于订单详情页展示 V4 订单任务时间线。旧 `GET /api/reservation/orders/{orderId}` 可以在过渡期继续返回 V3 `tasks[]`。
|
||||
|
||||
## 13. 前端写操作接口草案
|
||||
## 13. 前端写操作接口
|
||||
|
||||
### 13.1 确认卡片
|
||||
|
||||
@@ -590,10 +590,14 @@ POST /api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/confirm
|
||||
|
||||
请求要点:
|
||||
|
||||
- 只提交当前卡允许编辑的字段。
|
||||
- 请求 JSON 必须携带 `version`;`confirmed_payload` 可选,未传时后端使用当前展示 payload 作为确认快照。
|
||||
- 只提交当前卡允许编辑的字段或当前卡确认快照。
|
||||
- 不提交草稿。
|
||||
- 必须带 `version` 做并发校验。
|
||||
- 后端确认后卡片 `CONFIRMED` 并锁定。
|
||||
- 返回刷新后的 `GET /api/reservation/order-tasks/{orderTaskId}` 详情结构。
|
||||
- Basic Information 必须先确认;业务卡第一版不强制逐张顺序确认。
|
||||
- `SOURCE_MESSAGE_DISPLAY`、`CONFIRMED`、`REVIEW_REQUIRED` 卡不能通过该接口确认。
|
||||
|
||||
### 13.2 复核并确认卡片
|
||||
|
||||
@@ -625,6 +629,7 @@ S10/S99 已确认采用来源通知模型,不继续复用隐藏技术订单或
|
||||
- 必须带 `version` 做并发校验。
|
||||
- 确认后 `notification_status=ACKED`,写确认人和 UTC 确认时间。
|
||||
- 重复提交已确认通知应返回幂等成功或明确的已确认状态,不允许回退到 `ACK_REQUIRED`。
|
||||
- 返回刷新后的 `GET /api/reservation/source-notifications/{notificationId}` 详情结构。
|
||||
|
||||
## 14. 旧 V3 / V2 兼容和废弃边界
|
||||
|
||||
@@ -681,7 +686,7 @@ AI 原始 payload、邮件正文、附件 URL 和技术 trace 不应直接进入
|
||||
| M002-V4-CP3 | V4 表结构和基础 Repository | 新增 V4 order task / card / source notification 表、Entity、Mapper、Repository、测试 |
|
||||
| M002-V4-CP4 | V4 入站落新模型 | 已完成:SuperAgent V4 回调创建订单任务、Basic Information 卡、业务卡、邮件展示卡和 S10/S99 来源通知 |
|
||||
| M002-V4-CP5 | V4 查询接口 | 已完成:工作台统一列表、订单任务列表、订单任务详情和来源通知详情查询接口;订单详情 V4 专属时间线后续再做 |
|
||||
| M002-V4-CP6 | V4 卡片确认和复核 | 不保存草稿,支持确认、复核、锁定、审计、阻塞规则和 S10/S99 来源通知确认 |
|
||||
| M002-V4-CP6 | V4 卡片确认和 S10/S99 ack | 已完成:不保存草稿,支持普通卡片确认、确认后锁定、Basic Information 前置约束、同订单前置任务写侧阻塞、业务审计、version 并发校验和 S10/S99 来源通知确认;V4 复核接口后置 |
|
||||
| M002-V4-CP7 | 受控目录第一版 | Account、RoomType、RateCode、Department 固定目录或版本化快照校验 |
|
||||
| M002-V4-CP8 | V4 前端契约收口 | 字段、控件、availability、错误展示和旧任务入口切换 |
|
||||
| M002-V4-CP9 | 旧 V3 / V2 能力收口评估 | 明确哪些兼容入口可以关闭,哪些仍保留只读历史 |
|
||||
|
||||
@@ -53,9 +53,9 @@
|
||||
| `GET /api/reservation/source-notifications/{notificationId}` | `FRONTEND_USER` | 已实现 M002 V4 CP5;强制 Bearer 登录 + `RESERVATION_TASK_READ` + 来源通知所属酒店访问权 | 保持登录 + `RESERVATION_TASK_READ` + 来源通知所属酒店访问权 | 只读查询默认不写业务审计;邮件正文和附件读取仍走 SourceMessage 原文权限;不得返回来源通知原始 payload |
|
||||
| `PUT /api/reservation/tasks/{taskId}/draft` | `FRONTEND_USER` | 第一版未全量强制登录;actor 仍待迁移 | 登录 + `RESERVATION_TASK_EDIT` + 酒店访问权 | 写草稿审计可按业务需要记录 |
|
||||
| `POST /api/reservation/tasks/{taskId}/confirm` | `FRONTEND_USER` | 第一版未全量强制登录;actor 仍待迁移 | 登录 + `RESERVATION_TASK_CONFIRM` + 酒店访问权 | 必须写业务审计 |
|
||||
| `POST /api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/confirm` | `FRONTEND_USER` | M002 V4 CP2 草案,尚未实现 | 登录 + `RESERVATION_TASK_CONFIRM` + 订单任务所属酒店访问权 + version 并发校验 | 必须写业务审计 |
|
||||
| `POST /api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/confirm` | `FRONTEND_USER` | 已实现 M002 V4 CP6;强制 Bearer 登录 + `RESERVATION_TASK_CONFIRM` + 订单任务所属酒店访问权 + version 并发校验 | 保持;Basic Information 前置确认,确认后卡片锁定,不返回 AI 原始 payload | 必须写业务审计,actor 使用当前登录用户 |
|
||||
| `POST /api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/review-resolution` | `FRONTEND_USER` | M002 V4 CP2 草案,尚未实现 | 登录 + `RESERVATION_MANUAL_REVIEW_RESOLVE` + 订单任务所属酒店访问权 + version 并发校验 | 必须写业务审计,记录复核修正和订单归属确认摘要 |
|
||||
| `POST /api/reservation/source-notifications/{notificationId}/ack` | `FRONTEND_USER` | M002 V4 CP2 草案,尚未实现 | 登录 + `RESERVATION_TASK_CONFIRM` + 来源通知所属酒店访问权 + version 并发校验 | 必须写业务审计,记录已读 / 已处理确认 |
|
||||
| `POST /api/reservation/source-notifications/{notificationId}/ack` | `FRONTEND_USER` | 已实现 M002 V4 CP6;强制 Bearer 登录 + `RESERVATION_TASK_CONFIRM` + 来源通知所属酒店访问权 + version 并发校验 | 保持;只用于 V4 S10/S99 来源通知确认已读 / 已处理,不创建订单、不参与订单阻塞 | 必须写业务审计,记录已读 / 已处理确认,actor 使用当前登录用户 |
|
||||
| `POST /api/reservation/tasks/{taskId}/manual-review-conversions` | `FRONTEND_USER` | 第一版已写业务审计,但 actor 待迁移 | 登录 + `RESERVATION_MANUAL_REVIEW_RESOLVE` + 酒店访问权 | 必须写业务审计和原因 |
|
||||
| `POST /api/reservation/tasks/{taskId}/manual-review-resolutions` | `FRONTEND_USER` | 第一版已写业务审计,但 actor 待迁移 | 登录 + `RESERVATION_MANUAL_REVIEW_RESOLVE` + 酒店访问权 | 必须写业务审计 |
|
||||
| `POST /api/reservation/tasks/{taskId}/opera-operations/{operationId}/execute` | `FRONTEND_USER` | 当前为 OPERA 模拟 | 登录 + `RESERVATION_OPERA_SIM_EXECUTE` + 酒店访问权 | 必须写业务审计和 attempt |
|
||||
|
||||
@@ -10,5 +10,6 @@ public enum ReservationV4ReadonlyReasonCode {
|
||||
PRIOR_CARD_NOT_CONFIRMED,
|
||||
PRIOR_ORDER_TASK_NOT_COMPLETED,
|
||||
COMMAND_API_PENDING,
|
||||
ACK_API_PENDING
|
||||
ACK_API_PENDING,
|
||||
REVIEW_API_PENDING
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
package cn.nianxx.thhotel.workflows.reservation.common.request;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
|
||||
/**
|
||||
* Reservation V4 卡片确认请求。confirmed_payload 为空时后端使用当前展示 payload 作为确认快照。
|
||||
*
|
||||
* @param version 前端读取到的卡片乐观锁版本
|
||||
* @param confirmedPayload 用户确认后的卡片 payload
|
||||
*/
|
||||
public record ReservationV4CardConfirmRequest(
|
||||
Long version,
|
||||
@JsonProperty("confirmed_payload")
|
||||
JsonNode confirmedPayload
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package cn.nianxx.thhotel.workflows.reservation.common.request;
|
||||
|
||||
/**
|
||||
* Reservation V4 来源通知确认请求。第一版只要求带上前端读取到的 version。
|
||||
*
|
||||
* @param version 来源通知乐观锁版本
|
||||
* @param reason 可选处理说明,当前只写入审计摘要
|
||||
*/
|
||||
public record ReservationV4SourceNotificationAckRequest(
|
||||
Long version,
|
||||
String reason
|
||||
) {
|
||||
}
|
||||
@@ -3,7 +3,7 @@ package cn.nianxx.thhotel.workflows.reservation.common.result;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
/**
|
||||
* Reservation V4 查询侧动作可用性。CP5 只读查询阶段不开放写接口,因此 confirmable / ackable 默认由后续 CP 打开。
|
||||
* Reservation V4 查询侧动作可用性。用于前端判断卡片确认、来源通知确认和只读原因。
|
||||
*
|
||||
* @param blocked 是否被前置订单任务或卡片阻塞
|
||||
* @param readOnly 当前是否只读
|
||||
|
||||
@@ -17,6 +17,7 @@ import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
ReservationTaskController.class,
|
||||
ReservationFrontendQueryController.class,
|
||||
ReservationV4QueryController.class,
|
||||
ReservationV4CommandController.class,
|
||||
ReservationDemoDataController.class,
|
||||
ReservationInvoiceGenerationController.class,
|
||||
ReservationRoomingListGenerationController.class
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
package cn.nianxx.thhotel.workflows.reservation.control;
|
||||
|
||||
import cn.nianxx.thhotel.platform.access.common.enums.PlatformPermissionCode;
|
||||
import cn.nianxx.thhotel.platform.security.common.dto.AuthenticatedUserContext;
|
||||
import cn.nianxx.thhotel.platform.security.service.FrontendAuthorizationService;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationV4CardConfirmRequest;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationV4SourceNotificationAckRequest;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4OrderTaskDetailResult;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4SourceNotificationDetailResult;
|
||||
import cn.nianxx.thhotel.workflows.reservation.service.ReservationV4CommandService;
|
||||
import org.springframework.http.MediaType;
|
||||
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 V4 前端写操作 Controller。只暴露卡片确认和来源通知确认入口。
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/reservation")
|
||||
public class ReservationV4CommandController {
|
||||
|
||||
private final ReservationV4CommandService commandService;
|
||||
private final FrontendAuthorizationService authorizationService;
|
||||
|
||||
/**
|
||||
* 注入 V4 命令服务和前端鉴权服务,Controller 不直接访问 Repository。
|
||||
*/
|
||||
public ReservationV4CommandController(
|
||||
ReservationV4CommandService commandService,
|
||||
FrontendAuthorizationService authorizationService) {
|
||||
this.commandService = commandService;
|
||||
this.authorizationService = authorizationService;
|
||||
}
|
||||
|
||||
/**
|
||||
* 确认 V4 订单任务下的一张业务卡;Basic Information 未确认时业务卡不可确认。
|
||||
*/
|
||||
@PostMapping(
|
||||
value = "/order-tasks/{orderTaskId}/cards/{cardId}/confirm",
|
||||
consumes = MediaType.APPLICATION_JSON_VALUE,
|
||||
produces = MediaType.APPLICATION_JSON_VALUE)
|
||||
public ReservationV4OrderTaskDetailResult confirmTaskCard(
|
||||
@PathVariable Long orderTaskId,
|
||||
@PathVariable Long cardId,
|
||||
@RequestBody(required = false) ReservationV4CardConfirmRequest request) {
|
||||
AuthenticatedUserContext actor = authorizationService.requirePermission(
|
||||
PlatformPermissionCode.RESERVATION_TASK_CONFIRM.name());
|
||||
return commandService.confirmTaskCard(orderTaskId, cardId, request, actor);
|
||||
}
|
||||
|
||||
/**
|
||||
* 确认 S10/S99 来源通知已读或已处理;不创建订单、不参与订单阻塞。
|
||||
*/
|
||||
@PostMapping(
|
||||
value = "/source-notifications/{notificationId}/ack",
|
||||
consumes = MediaType.APPLICATION_JSON_VALUE,
|
||||
produces = MediaType.APPLICATION_JSON_VALUE)
|
||||
public ReservationV4SourceNotificationDetailResult ackSourceNotification(
|
||||
@PathVariable Long notificationId,
|
||||
@RequestBody(required = false) ReservationV4SourceNotificationAckRequest request) {
|
||||
AuthenticatedUserContext actor = authorizationService.requirePermission(
|
||||
PlatformPermissionCode.RESERVATION_TASK_CONFIRM.name());
|
||||
return commandService.ackSourceNotification(notificationId, request, actor);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4OrderTask
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationPageSnapshot;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4TaskCardDraft;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4TaskCardSnapshot;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationV4CardStatus;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationV4OrderTaskQueryRequest;
|
||||
import cn.nianxx.thhotel.workflows.reservation.domain.ReservationV4OrderTaskEntity;
|
||||
import cn.nianxx.thhotel.workflows.reservation.domain.ReservationV4TaskCardEntity;
|
||||
@@ -282,6 +283,51 @@ public class MybatisReservationV4WorkflowRepository implements ReservationV4Work
|
||||
return updated == 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 version 乐观锁确认 V4 任务卡,同时写入用户确认 payload、确认人和确认时间。
|
||||
*/
|
||||
@Override
|
||||
public boolean confirmTaskCardWithVersion(
|
||||
String hotelId,
|
||||
Long taskCardId,
|
||||
Long expectedVersion,
|
||||
String confirmedPayloadJson,
|
||||
String confirmedBy,
|
||||
LocalDateTime confirmedAt) {
|
||||
int updated = taskCardMapper.update(Wrappers.<ReservationV4TaskCardEntity>lambdaUpdate()
|
||||
.set(ReservationV4TaskCardEntity::getCardStatus, ReservationV4CardStatus.CONFIRMED.name())
|
||||
.set(ReservationV4TaskCardEntity::getConfirmedPayloadJson, confirmedPayloadJson)
|
||||
.set(ReservationV4TaskCardEntity::getConfirmedBy, confirmedBy)
|
||||
.set(ReservationV4TaskCardEntity::getConfirmedAt, confirmedAt)
|
||||
.set(ReservationV4TaskCardEntity::getUpdatedAt, confirmedAt)
|
||||
.setSql("version = version + 1")
|
||||
.eq(ReservationV4TaskCardEntity::getHotelId, hotelId)
|
||||
.eq(ReservationV4TaskCardEntity::getId, taskCardId)
|
||||
.eq(ReservationV4TaskCardEntity::getVersion, expectedVersion)
|
||||
.eq(ReservationV4TaskCardEntity::getCardStatus, ReservationV4CardStatus.PENDING_CONFIRM.name())
|
||||
.isNull(ReservationV4TaskCardEntity::getLogicDeletedAt));
|
||||
return updated == 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新 V4 订单任务派生状态,用于卡片确认后刷新 OPEN / COMPLETED。
|
||||
*/
|
||||
@Override
|
||||
public boolean updateOrderTaskStatus(
|
||||
String hotelId,
|
||||
Long orderTaskId,
|
||||
String orderTaskStatus,
|
||||
LocalDateTime now) {
|
||||
int updated = orderTaskMapper.update(Wrappers.<ReservationV4OrderTaskEntity>lambdaUpdate()
|
||||
.set(ReservationV4OrderTaskEntity::getOrderTaskStatus, orderTaskStatus)
|
||||
.set(ReservationV4OrderTaskEntity::getUpdatedAt, now)
|
||||
.setSql("version = version + 1")
|
||||
.eq(ReservationV4OrderTaskEntity::getHotelId, hotelId)
|
||||
.eq(ReservationV4OrderTaskEntity::getId, orderTaskId)
|
||||
.isNull(ReservationV4OrderTaskEntity::getLogicDeletedAt));
|
||||
return updated == 1;
|
||||
}
|
||||
|
||||
private ReservationV4OrderTaskSnapshot toOrderTaskSnapshot(ReservationV4OrderTaskEntity entity) {
|
||||
return new ReservationV4OrderTaskSnapshot(
|
||||
entity.getId(),
|
||||
|
||||
@@ -86,4 +86,24 @@ public interface ReservationV4WorkflowRepository {
|
||||
Long expectedVersion,
|
||||
String cardStatus,
|
||||
LocalDateTime now);
|
||||
|
||||
/**
|
||||
* 按 version 乐观锁确认 V4 任务卡,同时写入用户确认 payload、确认人和确认时间。
|
||||
*/
|
||||
boolean confirmTaskCardWithVersion(
|
||||
String hotelId,
|
||||
Long taskCardId,
|
||||
Long expectedVersion,
|
||||
String confirmedPayloadJson,
|
||||
String confirmedBy,
|
||||
LocalDateTime confirmedAt);
|
||||
|
||||
/**
|
||||
* 更新 V4 订单任务派生状态,用于卡片确认后刷新 OPEN / COMPLETED。
|
||||
*/
|
||||
boolean updateOrderTaskStatus(
|
||||
String hotelId,
|
||||
Long orderTaskId,
|
||||
String orderTaskStatus,
|
||||
LocalDateTime now);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
package cn.nianxx.thhotel.workflows.reservation.service;
|
||||
|
||||
import cn.nianxx.thhotel.platform.security.common.dto.AuthenticatedUserContext;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationV4CardConfirmRequest;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationV4SourceNotificationAckRequest;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4OrderTaskDetailResult;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4SourceNotificationDetailResult;
|
||||
|
||||
/**
|
||||
* Reservation V4 命令服务。承接卡片确认和 S10/S99 来源通知确认写操作。
|
||||
*/
|
||||
public interface ReservationV4CommandService {
|
||||
|
||||
/**
|
||||
* 确认指定 V4 任务卡,并返回刷新后的订单任务详情。
|
||||
*/
|
||||
ReservationV4OrderTaskDetailResult confirmTaskCard(
|
||||
Long orderTaskId,
|
||||
Long cardId,
|
||||
ReservationV4CardConfirmRequest request,
|
||||
AuthenticatedUserContext actor);
|
||||
|
||||
/**
|
||||
* 确认 S10/S99 来源通知已读或已处理,并返回刷新后的来源通知详情。
|
||||
*/
|
||||
ReservationV4SourceNotificationDetailResult ackSourceNotification(
|
||||
Long notificationId,
|
||||
ReservationV4SourceNotificationAckRequest request,
|
||||
AuthenticatedUserContext actor);
|
||||
}
|
||||
@@ -0,0 +1,408 @@
|
||||
package cn.nianxx.thhotel.workflows.reservation.service.impl;
|
||||
|
||||
import cn.nianxx.thhotel.platform.hotel.service.HotelContextException;
|
||||
import cn.nianxx.thhotel.platform.hotel.service.HotelContextService;
|
||||
import cn.nianxx.thhotel.platform.security.common.dto.AuthenticatedUserContext;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationAuditLogDraft;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4OrderTaskSnapshot;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4SourceNotificationSnapshot;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4TaskCardSnapshot;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationV4CardStatus;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationV4CardType;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationV4NotificationStatus;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationV4OrderTaskStatus;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationV4CardConfirmRequest;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationV4SourceNotificationAckRequest;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4OrderTaskDetailResult;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4SourceNotificationDetailResult;
|
||||
import cn.nianxx.thhotel.workflows.reservation.repository.ReservationAiWorkflowRepository;
|
||||
import cn.nianxx.thhotel.workflows.reservation.repository.ReservationV4SourceNotificationRepository;
|
||||
import cn.nianxx.thhotel.workflows.reservation.repository.ReservationV4WorkflowRepository;
|
||||
import cn.nianxx.thhotel.workflows.reservation.service.ReservationV4CommandService;
|
||||
import cn.nianxx.thhotel.workflows.reservation.service.ReservationV4QueryService;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Iterator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/**
|
||||
* Reservation V4 命令服务实现。负责卡片确认、来源通知确认、状态刷新和业务审计。
|
||||
*/
|
||||
@Service
|
||||
public class ReservationV4CommandServiceImpl implements ReservationV4CommandService {
|
||||
|
||||
private static final String ACTOR_TYPE_USER = "USER";
|
||||
private static final String ACTION_V4_CARD_CONFIRM = "V4_CARD_CONFIRM";
|
||||
private static final String ACTION_V4_SOURCE_NOTIFICATION_ACK = "V4_SOURCE_NOTIFICATION_ACK";
|
||||
|
||||
private final ReservationV4WorkflowRepository workflowRepository;
|
||||
private final ReservationV4SourceNotificationRepository sourceNotificationRepository;
|
||||
private final ReservationAiWorkflowRepository auditRepository;
|
||||
private final ReservationV4QueryService queryService;
|
||||
private final HotelContextService hotelContextService;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
/**
|
||||
* 注入 V4 Repository、查询服务、酒店上下文和审计边界,命令服务不直接依赖 Mapper。
|
||||
*/
|
||||
public ReservationV4CommandServiceImpl(
|
||||
ReservationV4WorkflowRepository workflowRepository,
|
||||
ReservationV4SourceNotificationRepository sourceNotificationRepository,
|
||||
ReservationAiWorkflowRepository auditRepository,
|
||||
ReservationV4QueryService queryService,
|
||||
HotelContextService hotelContextService,
|
||||
ObjectMapper objectMapper) {
|
||||
this.workflowRepository = workflowRepository;
|
||||
this.sourceNotificationRepository = sourceNotificationRepository;
|
||||
this.auditRepository = auditRepository;
|
||||
this.queryService = queryService;
|
||||
this.hotelContextService = hotelContextService;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
/**
|
||||
* 确认指定 V4 任务卡,并返回刷新后的订单任务详情。
|
||||
*/
|
||||
@Override
|
||||
@Transactional
|
||||
public ReservationV4OrderTaskDetailResult confirmTaskCard(
|
||||
Long orderTaskId,
|
||||
Long cardId,
|
||||
ReservationV4CardConfirmRequest request,
|
||||
AuthenticatedUserContext actor) {
|
||||
Long expectedVersion = requireVersion(request == null ? null : request.version());
|
||||
LocalDateTime now = LocalDateTime.now(ZoneOffset.UTC);
|
||||
ReservationV4OrderTaskSnapshot orderTask = requireOrderTask(orderTaskId);
|
||||
requireHotelAccess(orderTask.hotelId());
|
||||
ReservationV4TaskCardSnapshot card = requireTaskCard(orderTask.hotelId(), cardId, orderTask.id());
|
||||
validateCardConfirmable(orderTask, card);
|
||||
ensureNoPriorOrderTaskBlocking(orderTask);
|
||||
ensureBasicInformationConfirmed(orderTask, card);
|
||||
|
||||
String confirmedPayloadJson = confirmedPayloadJson(card, request == null ? null : request.confirmedPayload());
|
||||
String actorId = actorIdentifier(actor);
|
||||
boolean updated = workflowRepository.confirmTaskCardWithVersion(
|
||||
orderTask.hotelId(),
|
||||
card.id(),
|
||||
expectedVersion,
|
||||
confirmedPayloadJson,
|
||||
actorId,
|
||||
now);
|
||||
if (!updated) {
|
||||
handleCardConfirmRace(orderTask.hotelId(), card.id());
|
||||
}
|
||||
refreshOrderTaskStatus(orderTask, now);
|
||||
writeCardConfirmAudit(orderTask, card, actorId, confirmedPayloadJson, now);
|
||||
return queryService.getOrderTaskDetail(orderTask.hotelId(), orderTask.id());
|
||||
}
|
||||
|
||||
/**
|
||||
* 确认 S10/S99 来源通知已读或已处理,并返回刷新后的来源通知详情。
|
||||
*/
|
||||
@Override
|
||||
@Transactional
|
||||
public ReservationV4SourceNotificationDetailResult ackSourceNotification(
|
||||
Long notificationId,
|
||||
ReservationV4SourceNotificationAckRequest request,
|
||||
AuthenticatedUserContext actor) {
|
||||
Long expectedVersion = requireVersion(request == null ? null : request.version());
|
||||
LocalDateTime now = LocalDateTime.now(ZoneOffset.UTC);
|
||||
ReservationV4SourceNotificationSnapshot notification = requireSourceNotification(notificationId);
|
||||
requireHotelAccess(notification.hotelId());
|
||||
if (ReservationV4NotificationStatus.ACKED.name().equals(notification.notificationStatus())) {
|
||||
return queryService.getSourceNotificationDetail(notification.hotelId(), notification.id());
|
||||
}
|
||||
if (!ReservationV4NotificationStatus.ACK_REQUIRED.name().equals(notification.notificationStatus())) {
|
||||
throw error(HttpStatus.CONFLICT, "V4_SOURCE_NOTIFICATION_NOT_ACKABLE", "当前来源通知状态不允许确认。");
|
||||
}
|
||||
String actorId = actorIdentifier(actor);
|
||||
boolean updated = sourceNotificationRepository.updateNotificationStatusWithVersion(
|
||||
notification.hotelId(),
|
||||
notification.id(),
|
||||
expectedVersion,
|
||||
ReservationV4NotificationStatus.ACKED.name(),
|
||||
actorId,
|
||||
now);
|
||||
if (!updated) {
|
||||
ReservationV4SourceNotificationSnapshot latest = requireSourceNotification(notificationId);
|
||||
if (ReservationV4NotificationStatus.ACKED.name().equals(latest.notificationStatus())) {
|
||||
return queryService.getSourceNotificationDetail(latest.hotelId(), latest.id());
|
||||
}
|
||||
throw error(HttpStatus.CONFLICT, "V4_SOURCE_NOTIFICATION_VERSION_CONFLICT", "来源通知版本已变化,请刷新后重试。");
|
||||
}
|
||||
writeSourceNotificationAckAudit(notification, actorId, request == null ? null : request.reason(), now);
|
||||
return queryService.getSourceNotificationDetail(notification.hotelId(), notification.id());
|
||||
}
|
||||
|
||||
private void validateCardConfirmable(
|
||||
ReservationV4OrderTaskSnapshot orderTask,
|
||||
ReservationV4TaskCardSnapshot card) {
|
||||
if (ReservationV4CardType.SOURCE_MESSAGE_DISPLAY.name().equals(card.cardType())) {
|
||||
throw error(HttpStatus.CONFLICT, "V4_CARD_NOT_CONFIRMABLE", "来源邮件展示卡不允许确认。");
|
||||
}
|
||||
if (ReservationV4CardStatus.CONFIRMED.name().equals(card.cardStatus())) {
|
||||
throw error(HttpStatus.CONFLICT, "V4_CARD_ALREADY_CONFIRMED", "该卡片已经确认,不能重复确认。");
|
||||
}
|
||||
if (ReservationV4CardStatus.REVIEW_REQUIRED.name().equals(card.cardStatus())) {
|
||||
throw error(HttpStatus.CONFLICT, "V4_CARD_REVIEW_REQUIRED", "该卡片需要先完成人工复核。");
|
||||
}
|
||||
if (!ReservationV4CardStatus.PENDING_CONFIRM.name().equals(card.cardStatus())) {
|
||||
throw error(HttpStatus.CONFLICT, "V4_CARD_NOT_CONFIRMABLE", "当前卡片状态不允许确认。");
|
||||
}
|
||||
if (ReservationV4OrderTaskStatus.COMPLETED.name().equals(orderTask.orderTaskStatus())) {
|
||||
throw error(HttpStatus.CONFLICT, "V4_ORDER_TASK_ALREADY_COMPLETED", "该 V4 订单任务已经完成。");
|
||||
}
|
||||
}
|
||||
|
||||
private void ensureBasicInformationConfirmed(
|
||||
ReservationV4OrderTaskSnapshot orderTask,
|
||||
ReservationV4TaskCardSnapshot card) {
|
||||
if (ReservationV4CardType.BASIC_INFORMATION.name().equals(card.cardType())) {
|
||||
return;
|
||||
}
|
||||
ReservationV4TaskCardSnapshot basicCard = workflowRepository.findTaskCardsByOrderTaskId(
|
||||
orderTask.hotelId(),
|
||||
orderTask.id()).stream()
|
||||
.filter(candidate -> ReservationV4CardType.BASIC_INFORMATION.name().equals(candidate.cardType()))
|
||||
.findFirst()
|
||||
.orElseThrow(() -> error(
|
||||
HttpStatus.CONFLICT,
|
||||
"V4_BASIC_INFORMATION_MISSING",
|
||||
"V4 订单任务缺少 Basic Information 卡。"));
|
||||
if (!ReservationV4CardStatus.CONFIRMED.name().equals(basicCard.cardStatus())) {
|
||||
throw error(
|
||||
HttpStatus.CONFLICT,
|
||||
"V4_BASIC_INFORMATION_NOT_CONFIRMED",
|
||||
"Basic Information 卡未确认前,业务卡不可确认。");
|
||||
}
|
||||
}
|
||||
|
||||
private void ensureNoPriorOrderTaskBlocking(ReservationV4OrderTaskSnapshot orderTask) {
|
||||
if (orderTask.orderId() == null) {
|
||||
return;
|
||||
}
|
||||
List<ReservationV4OrderTaskSnapshot> sameOrderTasks = workflowRepository.findOrderTasksByOrderIds(
|
||||
orderTask.hotelId(),
|
||||
List.of(orderTask.orderId()));
|
||||
for (ReservationV4OrderTaskSnapshot candidate : sameOrderTasks) {
|
||||
if (Objects.equals(candidate.id(), orderTask.id())) {
|
||||
return;
|
||||
}
|
||||
if (!ReservationV4OrderTaskStatus.COMPLETED.name().equals(candidate.orderTaskStatus())) {
|
||||
throw error(
|
||||
HttpStatus.CONFLICT,
|
||||
"V4_PRIOR_ORDER_TASK_NOT_COMPLETED",
|
||||
"同一本地订单下存在更早未完成的 V4 订单任务。");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void refreshOrderTaskStatus(ReservationV4OrderTaskSnapshot orderTask, LocalDateTime now) {
|
||||
List<ReservationV4TaskCardSnapshot> cards = workflowRepository.findTaskCardsByOrderTaskId(
|
||||
orderTask.hotelId(),
|
||||
orderTask.id());
|
||||
boolean hasUnfinishedCard = cards.stream()
|
||||
.filter(card -> !ReservationV4CardType.SOURCE_MESSAGE_DISPLAY.name().equals(card.cardType()))
|
||||
.anyMatch(card -> !ReservationV4CardStatus.CONFIRMED.name().equals(card.cardStatus()));
|
||||
String nextStatus = hasUnfinishedCard
|
||||
? ReservationV4OrderTaskStatus.OPEN.name()
|
||||
: ReservationV4OrderTaskStatus.COMPLETED.name();
|
||||
if (!Objects.equals(orderTask.orderTaskStatus(), nextStatus)) {
|
||||
boolean updated = workflowRepository.updateOrderTaskStatus(orderTask.hotelId(), orderTask.id(), nextStatus, now);
|
||||
if (!updated) {
|
||||
throw error(
|
||||
HttpStatus.CONFLICT,
|
||||
"V4_ORDER_TASK_STATUS_REFRESH_FAILED",
|
||||
"V4 订单任务状态刷新失败,请刷新后重试。");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void handleCardConfirmRace(String hotelId, Long cardId) {
|
||||
ReservationV4TaskCardSnapshot latest = workflowRepository.findTaskCardById(hotelId, cardId)
|
||||
.orElseThrow(() -> notFound("V4_TASK_CARD_NOT_FOUND", "V4 任务卡不存在。"));
|
||||
if (ReservationV4CardStatus.CONFIRMED.name().equals(latest.cardStatus())) {
|
||||
throw error(HttpStatus.CONFLICT, "V4_CARD_ALREADY_CONFIRMED", "该卡片已经确认,不能重复确认。");
|
||||
}
|
||||
throw error(HttpStatus.CONFLICT, "V4_CARD_VERSION_CONFLICT", "任务卡版本已变化,请刷新后重试。");
|
||||
}
|
||||
|
||||
private String confirmedPayloadJson(ReservationV4TaskCardSnapshot card, JsonNode confirmedPayload) {
|
||||
if (confirmedPayload != null && !confirmedPayload.isNull() && !confirmedPayload.isMissingNode()) {
|
||||
return toJson(confirmedPayload);
|
||||
}
|
||||
if (hasText(card.displayPayloadJson())) {
|
||||
return card.displayPayloadJson();
|
||||
}
|
||||
return "{}";
|
||||
}
|
||||
|
||||
private ReservationV4OrderTaskSnapshot requireOrderTask(Long orderTaskId) {
|
||||
return workflowRepository.findOrderTaskById(orderTaskId)
|
||||
.orElseThrow(() -> notFound("V4_ORDER_TASK_NOT_FOUND", "V4 订单任务不存在。"));
|
||||
}
|
||||
|
||||
private ReservationV4TaskCardSnapshot requireTaskCard(String hotelId, Long cardId, Long orderTaskId) {
|
||||
ReservationV4TaskCardSnapshot card = workflowRepository.findTaskCardById(hotelId, cardId)
|
||||
.orElseThrow(() -> notFound("V4_TASK_CARD_NOT_FOUND", "V4 任务卡不存在。"));
|
||||
if (!Objects.equals(orderTaskId, card.v4OrderTaskId())) {
|
||||
throw notFound("V4_TASK_CARD_NOT_FOUND", "V4 任务卡不存在。");
|
||||
}
|
||||
return card;
|
||||
}
|
||||
|
||||
private ReservationV4SourceNotificationSnapshot requireSourceNotification(Long notificationId) {
|
||||
return sourceNotificationRepository.findSourceNotificationById(notificationId)
|
||||
.orElseThrow(() -> notFound("V4_SOURCE_NOTIFICATION_NOT_FOUND", "V4 来源通知不存在。"));
|
||||
}
|
||||
|
||||
private void requireHotelAccess(String hotelId) {
|
||||
try {
|
||||
hotelContextService.requireAccessibleHotel(hotelId);
|
||||
} catch (HotelContextException exception) {
|
||||
throw new ReservationTaskWorkflowException(
|
||||
exception.getStatus(),
|
||||
exception.getErrorCode(),
|
||||
exception.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private Long requireVersion(Long version) {
|
||||
if (version == null) {
|
||||
throw error(HttpStatus.BAD_REQUEST, "V4_VERSION_REQUIRED", "请求必须携带当前 version。");
|
||||
}
|
||||
if (version < 0) {
|
||||
throw error(HttpStatus.BAD_REQUEST, "V4_VERSION_INVALID", "version 不能小于 0。");
|
||||
}
|
||||
return version;
|
||||
}
|
||||
|
||||
private String actorIdentifier(AuthenticatedUserContext actor) {
|
||||
if (actor == null) {
|
||||
return "unknown-user";
|
||||
}
|
||||
if (hasText(actor.username())) {
|
||||
return actor.username();
|
||||
}
|
||||
return actor.userId() == null ? "unknown-user" : actor.userId().toString();
|
||||
}
|
||||
|
||||
private void writeCardConfirmAudit(
|
||||
ReservationV4OrderTaskSnapshot orderTask,
|
||||
ReservationV4TaskCardSnapshot card,
|
||||
String actorId,
|
||||
String confirmedPayloadJson,
|
||||
LocalDateTime now) {
|
||||
Map<String, Object> beforeSnapshot = new LinkedHashMap<>();
|
||||
beforeSnapshot.put("v4_order_task_id", orderTask.id().toString());
|
||||
beforeSnapshot.put("v4_task_card_id", card.id().toString());
|
||||
beforeSnapshot.put("card_type", card.cardType());
|
||||
beforeSnapshot.put("card_status", card.cardStatus());
|
||||
beforeSnapshot.put("version", card.version());
|
||||
beforeSnapshot.put("has_confirmed_payload", hasText(card.confirmedPayloadJson()));
|
||||
Map<String, Object> afterSnapshot = new LinkedHashMap<>();
|
||||
afterSnapshot.put("v4_order_task_id", orderTask.id().toString());
|
||||
afterSnapshot.put("v4_task_card_id", card.id().toString());
|
||||
afterSnapshot.put("card_type", card.cardType());
|
||||
afterSnapshot.put("card_status", ReservationV4CardStatus.CONFIRMED.name());
|
||||
afterSnapshot.put("confirmed_by", actorId);
|
||||
afterSnapshot.put("confirmed_payload_keys", payloadFieldNames(confirmedPayloadJson));
|
||||
auditRepository.insertAuditLog(new ReservationAuditLogDraft(
|
||||
orderTask.hotelId(),
|
||||
orderTask.orderId(),
|
||||
null,
|
||||
null,
|
||||
ACTOR_TYPE_USER,
|
||||
actorId,
|
||||
ACTION_V4_CARD_CONFIRM,
|
||||
null,
|
||||
toJson(beforeSnapshot),
|
||||
toJson(afterSnapshot),
|
||||
now));
|
||||
}
|
||||
|
||||
private void writeSourceNotificationAckAudit(
|
||||
ReservationV4SourceNotificationSnapshot notification,
|
||||
String actorId,
|
||||
String reason,
|
||||
LocalDateTime now) {
|
||||
Map<String, Object> beforeSnapshot = new LinkedHashMap<>();
|
||||
beforeSnapshot.put("v4_source_notification_id", notification.id().toString());
|
||||
beforeSnapshot.put("route_code", notification.routeCode());
|
||||
beforeSnapshot.put("notification_status", notification.notificationStatus());
|
||||
beforeSnapshot.put("version", notification.version());
|
||||
Map<String, Object> afterSnapshot = new LinkedHashMap<>();
|
||||
afterSnapshot.put("v4_source_notification_id", notification.id().toString());
|
||||
afterSnapshot.put("route_code", notification.routeCode());
|
||||
afterSnapshot.put("notification_status", ReservationV4NotificationStatus.ACKED.name());
|
||||
afterSnapshot.put("ack_by", actorId);
|
||||
auditRepository.insertAuditLog(new ReservationAuditLogDraft(
|
||||
notification.hotelId(),
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
ACTOR_TYPE_USER,
|
||||
actorId,
|
||||
ACTION_V4_SOURCE_NOTIFICATION_ACK,
|
||||
trimToNull(reason),
|
||||
toJson(beforeSnapshot),
|
||||
toJson(afterSnapshot),
|
||||
now));
|
||||
}
|
||||
|
||||
private List<String> payloadFieldNames(String json) {
|
||||
if (!hasText(json)) {
|
||||
return List.of();
|
||||
}
|
||||
try {
|
||||
JsonNode node = objectMapper.readTree(json);
|
||||
if (node == null || !node.isObject()) {
|
||||
return List.of();
|
||||
}
|
||||
List<String> fieldNames = new ArrayList<>();
|
||||
Iterator<String> iterator = node.fieldNames();
|
||||
while (iterator.hasNext()) {
|
||||
fieldNames.add(iterator.next());
|
||||
}
|
||||
return fieldNames;
|
||||
} catch (Exception exception) {
|
||||
return List.of();
|
||||
}
|
||||
}
|
||||
|
||||
private String toJson(Object value) {
|
||||
try {
|
||||
return objectMapper.writeValueAsString(value);
|
||||
} catch (Exception exception) {
|
||||
return "{}";
|
||||
}
|
||||
}
|
||||
|
||||
private ReservationTaskWorkflowException notFound(String errorCode, String message) {
|
||||
return new ReservationTaskWorkflowException(HttpStatus.NOT_FOUND, errorCode, message);
|
||||
}
|
||||
|
||||
private ReservationTaskWorkflowException error(HttpStatus status, String errorCode, String message) {
|
||||
return new ReservationTaskWorkflowException(status, errorCode, message);
|
||||
}
|
||||
|
||||
private boolean hasText(String value) {
|
||||
return trimToNull(value) != null;
|
||||
}
|
||||
|
||||
private String trimToNull(String value) {
|
||||
if (value == null || value.trim().isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
}
|
||||
@@ -423,14 +423,14 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService
|
||||
}
|
||||
return new ReservationV4ActionAvailabilityResult(
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
ReservationV4ReadonlyReasonCode.COMMAND_API_PENDING.name(),
|
||||
ReservationV4ReadonlyReasonCode.PROCESSABLE.name(),
|
||||
null,
|
||||
null,
|
||||
"V4 卡片确认和复核写接口将在后续 checkpoint 开放。");
|
||||
null);
|
||||
}
|
||||
|
||||
private ReservationV4ActionAvailabilityResult cardAvailability(
|
||||
@@ -467,18 +467,29 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService
|
||||
if (ReservationV4CardStatus.CONFIRMED.name().equals(card.cardStatus())) {
|
||||
return readOnlyAvailability(ReservationV4ReadonlyReasonCode.CARD_LOCKED.name(), null, null);
|
||||
}
|
||||
if (ReservationV4CardStatus.PENDING_CONFIRM.name().equals(card.cardStatus())
|
||||
|| ReservationV4CardStatus.REVIEW_REQUIRED.name().equals(card.cardStatus())) {
|
||||
if (ReservationV4CardStatus.PENDING_CONFIRM.name().equals(card.cardStatus())) {
|
||||
return new ReservationV4ActionAvailabilityResult(
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
ReservationV4ReadonlyReasonCode.PROCESSABLE.name(),
|
||||
null,
|
||||
null,
|
||||
null);
|
||||
}
|
||||
if (ReservationV4CardStatus.REVIEW_REQUIRED.name().equals(card.cardStatus())) {
|
||||
return new ReservationV4ActionAvailabilityResult(
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
ReservationV4ReadonlyReasonCode.COMMAND_API_PENDING.name(),
|
||||
ReservationV4ReadonlyReasonCode.REVIEW_API_PENDING.name(),
|
||||
null,
|
||||
null,
|
||||
"V4 卡片确认和复核写接口将在后续 checkpoint 开放。");
|
||||
"V4 人工复核写接口将在后续 checkpoint 开放。");
|
||||
}
|
||||
return readOnlyAvailability(ReservationV4ReadonlyReasonCode.CARD_LOCKED.name(), null, null);
|
||||
}
|
||||
@@ -489,15 +500,15 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService
|
||||
return readOnlyAvailability(ReservationV4ReadonlyReasonCode.CARD_LOCKED.name(), null, null);
|
||||
}
|
||||
return new ReservationV4ActionAvailabilityResult(
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
ReservationV4ReadonlyReasonCode.ACK_API_PENDING.name(),
|
||||
ReservationV4ReadonlyReasonCode.PROCESSABLE.name(),
|
||||
null,
|
||||
null,
|
||||
"V4 来源通知确认写接口将在后续 checkpoint 开放。");
|
||||
null);
|
||||
}
|
||||
|
||||
private ReservationV4ActionAvailabilityResult readOnlyAvailability(
|
||||
|
||||
@@ -0,0 +1,508 @@
|
||||
package cn.nianxx.thhotel.workflows.reservation.control;
|
||||
|
||||
import static cn.nianxx.thhotel.support.MockMvcAuthTestSupport.loginToken;
|
||||
import static cn.nianxx.thhotel.support.MockMvcAuthTestSupport.performAuthorized;
|
||||
import static org.hamcrest.Matchers.not;
|
||||
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.hotel.repository.PlatformHotelRepository;
|
||||
import cn.nianxx.thhotel.platform.identity.common.enums.PlatformUserStatus;
|
||||
import cn.nianxx.thhotel.platform.identity.domain.PlatformUserEntity;
|
||||
import cn.nianxx.thhotel.platform.identity.repository.PlatformIdentityRepository;
|
||||
import cn.nianxx.thhotel.platform.identity.service.impl.AuthPasswordService;
|
||||
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 cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4OrderTaskDraft;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4OrderTaskSnapshot;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4SourceNotificationDraft;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4SourceNotificationSnapshot;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4TaskCardDraft;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4TaskCardSnapshot;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationV4CardStatus;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationV4CardType;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationV4NotificationStatus;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationV4OrderTaskStatus;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationV4TargetResolutionStatus;
|
||||
import cn.nianxx.thhotel.workflows.reservation.repository.ReservationV4SourceNotificationRepository;
|
||||
import cn.nianxx.thhotel.workflows.reservation.repository.ReservationV4WorkflowRepository;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.List;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
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.test.context.ActiveProfiles;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
@SpringBootTest(
|
||||
classes = ThHotelApplication.class,
|
||||
properties = {
|
||||
"spring.datasource.url=jdbc:h2:mem:reservation_v4_command_controller_test;MODE=MySQL;DATABASE_TO_LOWER=TRUE;CASE_INSENSITIVE_IDENTIFIERS=TRUE",
|
||||
"auth.bootstrap.admin.username=v4-command-admin",
|
||||
"auth.bootstrap.admin.password=Admin@123456",
|
||||
"auth.bootstrap.admin.display-name=V4命令管理员",
|
||||
"auth.bootstrap.default-hotel-id=HOTEL-TEST",
|
||||
"auth.bootstrap.default-hotel-name=测试酒店",
|
||||
"auth.bootstrap.default-hotel-time-zone=Asia/Bangkok",
|
||||
"auth.session.ttl-minutes=720"
|
||||
})
|
||||
@AutoConfigureMockMvc
|
||||
@ActiveProfiles("test")
|
||||
class ReservationV4CommandControllerTest {
|
||||
|
||||
private static final String HOTEL_ID = "HOTEL-TEST";
|
||||
private static final String OTHER_HOTEL_ID = "HOTEL-OTHER";
|
||||
|
||||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@Autowired
|
||||
private SourceMessageCaptureService captureService;
|
||||
|
||||
@Autowired
|
||||
private ReservationV4WorkflowRepository workflowRepository;
|
||||
|
||||
@Autowired
|
||||
private ReservationV4SourceNotificationRepository sourceNotificationRepository;
|
||||
|
||||
@Autowired
|
||||
private PlatformIdentityRepository identityRepository;
|
||||
|
||||
@Autowired
|
||||
private PlatformHotelRepository hotelRepository;
|
||||
|
||||
@Autowired
|
||||
private AuthPasswordService passwordService;
|
||||
|
||||
@Autowired
|
||||
private JdbcTemplate jdbcTemplate;
|
||||
|
||||
private String adminToken;
|
||||
private String noPermissionToken;
|
||||
|
||||
@BeforeEach
|
||||
void ensureNoPermissionUser() {
|
||||
PlatformUserEntity user = identityRepository.findUserByUsername("v4-command-no-permission")
|
||||
.orElseGet(() -> {
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
PlatformUserEntity created = new PlatformUserEntity();
|
||||
created.setUsername("v4-command-no-permission");
|
||||
created.setPasswordHash(passwordService.hash("NoPerm@123456"));
|
||||
created.setDisplayName("V4 命令无权限用户");
|
||||
created.setUserStatus(PlatformUserStatus.ACTIVE.name());
|
||||
created.setSuperAdmin(false);
|
||||
created.setPasswordChangedAt(now);
|
||||
created.setCreatedAt(now);
|
||||
created.setUpdatedAt(now);
|
||||
identityRepository.insertUser(created);
|
||||
return created;
|
||||
});
|
||||
hotelRepository.ensureUserHotel(user.getId(), HOTEL_ID, true);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectBusinessCardConfirmBeforeBasicInformation() throws Exception {
|
||||
SeededOrderTask seeded = seedOrderTask(
|
||||
HOTEL_ID,
|
||||
"mail-v4-command-basic-block-001",
|
||||
Instant.parse("2026-07-19T01:00:00Z"));
|
||||
|
||||
performAuthorized(mockMvc, adminToken(), post("/api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/confirm",
|
||||
seeded.orderTask().id(),
|
||||
seeded.businessCard().id())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{
|
||||
"version": 0,
|
||||
"confirmed_payload": {
|
||||
"room_items": [{"room_type_code": "TWN", "room_count": 2}]
|
||||
}
|
||||
}
|
||||
"""))
|
||||
.andExpect(status().isConflict())
|
||||
.andExpect(jsonPath("$.error_code").value("V4_BASIC_INFORMATION_NOT_CONFIRMED"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldConfirmBasicInformationAndThenBusinessCard() throws Exception {
|
||||
SeededOrderTask seeded = seedOrderTask(
|
||||
HOTEL_ID,
|
||||
"mail-v4-command-confirm-001",
|
||||
Instant.parse("2026-07-19T01:10:00Z"));
|
||||
|
||||
performAuthorized(mockMvc, adminToken(), post("/api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/confirm",
|
||||
seeded.orderTask().id(),
|
||||
seeded.basicCard().id())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{
|
||||
"version": 0,
|
||||
"confirmed_payload": {
|
||||
"company": "Q.B.D. TRAVEL GROUP CO., LTD",
|
||||
"group_code": "GRP-V4-COMMAND-001"
|
||||
}
|
||||
}
|
||||
"""))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.basic_information_card.card_status").value("CONFIRMED"))
|
||||
.andExpect(jsonPath("$.basic_information_card.confirmed_by").value("v4-command-admin"))
|
||||
.andExpect(jsonPath("$.basic_information_card.confirmed_payload.group_code")
|
||||
.value("GRP-V4-COMMAND-001"))
|
||||
.andExpect(jsonPath("$.business_cards[0].availability.confirmable").value(true))
|
||||
.andExpect(jsonPath("$.business_cards[0].availability.read_only").value(false))
|
||||
.andExpect(content().string(not(org.hamcrest.Matchers.containsString("private.example.test"))));
|
||||
|
||||
performAuthorized(mockMvc, adminToken(), post("/api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/confirm",
|
||||
seeded.orderTask().id(),
|
||||
seeded.businessCard().id())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{
|
||||
"version": 0,
|
||||
"confirmed_payload": {
|
||||
"room_items": [{"room_type_code": "TWN", "room_count": 2}]
|
||||
}
|
||||
}
|
||||
"""))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.order_task.order_task_status").value("COMPLETED"))
|
||||
.andExpect(jsonPath("$.business_cards[0].card_status").value("CONFIRMED"))
|
||||
.andExpect(jsonPath("$.business_cards[0].confirmed_payload.room_items[0].room_type_code")
|
||||
.value("TWN"))
|
||||
.andExpect(jsonPath("$.business_cards[0].availability.confirmable").value(false))
|
||||
.andExpect(jsonPath("$.business_cards[0].availability.readonly_reason_code").value("CARD_LOCKED"));
|
||||
assertAuditCount("V4_CARD_CONFIRM", "v4-command-admin", seeded.orderTask().id().toString(), 2);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectRepeatedCardConfirm() throws Exception {
|
||||
SeededOrderTask seeded = seedOrderTask(
|
||||
HOTEL_ID,
|
||||
"mail-v4-command-repeat-001",
|
||||
Instant.parse("2026-07-19T01:20:00Z"));
|
||||
confirmBasicCard(seeded);
|
||||
|
||||
performAuthorized(mockMvc, adminToken(), post("/api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/confirm",
|
||||
seeded.orderTask().id(),
|
||||
seeded.basicCard().id())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{
|
||||
"version": 1,
|
||||
"confirmed_payload": {"group_code": "SHOULD-NOT-REWRITE"}
|
||||
}
|
||||
"""))
|
||||
.andExpect(status().isConflict())
|
||||
.andExpect(jsonPath("$.error_code").value("V4_CARD_ALREADY_CONFIRMED"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectCardConfirmWhenPriorOrderTaskIsOpen() throws Exception {
|
||||
Long sharedOrderId = 990000000000040001L;
|
||||
seedOrderTask(
|
||||
HOTEL_ID,
|
||||
"mail-v4-command-prior-open-001",
|
||||
Instant.parse("2026-07-19T01:25:00Z"),
|
||||
sharedOrderId);
|
||||
SeededOrderTask later = seedOrderTask(
|
||||
HOTEL_ID,
|
||||
"mail-v4-command-prior-open-002",
|
||||
Instant.parse("2026-07-19T01:26:00Z"),
|
||||
sharedOrderId);
|
||||
|
||||
performAuthorized(mockMvc, adminToken(), post("/api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/confirm",
|
||||
later.orderTask().id(),
|
||||
later.basicCard().id())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{
|
||||
"version": 0,
|
||||
"confirmed_payload": {"group_code": "GRP-V4-COMMAND-001"}
|
||||
}
|
||||
"""))
|
||||
.andExpect(status().isConflict())
|
||||
.andExpect(jsonPath("$.error_code").value("V4_PRIOR_ORDER_TASK_NOT_COMPLETED"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldAckS10SourceNotification() throws Exception {
|
||||
ReservationV4SourceNotificationSnapshot notification = seedSourceNotification(
|
||||
HOTEL_ID,
|
||||
"mail-v4-command-s10-ack-001",
|
||||
"S10",
|
||||
Instant.parse("2026-07-19T01:30:00Z"));
|
||||
|
||||
performAuthorized(mockMvc, adminToken(), post("/api/reservation/source-notifications/{notificationId}/ack",
|
||||
notification.id())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{
|
||||
"version": 0
|
||||
}
|
||||
"""))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.notification.notification_status").value("ACKED"))
|
||||
.andExpect(jsonPath("$.notification.ack_by").value("v4-command-admin"))
|
||||
.andExpect(jsonPath("$.source_message_card.card_status").value("ACKED"))
|
||||
.andExpect(jsonPath("$.availability.ackable").value(false))
|
||||
.andExpect(jsonPath("$.availability.readonly_reason_code").value("CARD_LOCKED"));
|
||||
assertAuditCount("V4_SOURCE_NOTIFICATION_ACK", "v4-command-admin", notification.id().toString(), 1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectV4CommandsWhenPermissionMissing() throws Exception {
|
||||
SeededOrderTask seeded = seedOrderTask(
|
||||
HOTEL_ID,
|
||||
"mail-v4-command-no-permission-001",
|
||||
Instant.parse("2026-07-19T01:40:00Z"));
|
||||
ReservationV4SourceNotificationSnapshot notification = seedSourceNotification(
|
||||
HOTEL_ID,
|
||||
"mail-v4-command-no-permission-s10-001",
|
||||
"S10",
|
||||
Instant.parse("2026-07-19T01:41:00Z"));
|
||||
|
||||
performAuthorized(mockMvc, noPermissionToken(), post("/api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/confirm",
|
||||
seeded.orderTask().id(),
|
||||
seeded.basicCard().id())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{"version": 0}
|
||||
"""))
|
||||
.andExpect(status().isForbidden())
|
||||
.andExpect(jsonPath("$.error_code").value("FRONTEND_PERMISSION_DENIED"));
|
||||
performAuthorized(mockMvc, noPermissionToken(), post("/api/reservation/source-notifications/{notificationId}/ack",
|
||||
notification.id())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{"version": 0}
|
||||
"""))
|
||||
.andExpect(status().isForbidden())
|
||||
.andExpect(jsonPath("$.error_code").value("FRONTEND_PERMISSION_DENIED"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectV4CommandsWhenHotelAccessDenied() throws Exception {
|
||||
SeededOrderTask seeded = seedOrderTask(
|
||||
OTHER_HOTEL_ID,
|
||||
"mail-v4-command-cross-hotel-001",
|
||||
Instant.parse("2026-07-19T01:50:00Z"));
|
||||
ReservationV4SourceNotificationSnapshot notification = seedSourceNotification(
|
||||
OTHER_HOTEL_ID,
|
||||
"mail-v4-command-cross-hotel-s10-001",
|
||||
"S99",
|
||||
Instant.parse("2026-07-19T01:51:00Z"));
|
||||
|
||||
performAuthorized(mockMvc, adminToken(), post("/api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/confirm",
|
||||
seeded.orderTask().id(),
|
||||
seeded.basicCard().id())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{"version": 0}
|
||||
"""))
|
||||
.andExpect(status().isForbidden())
|
||||
.andExpect(jsonPath("$.error_code").value("HOTEL_ACCESS_DENIED"));
|
||||
performAuthorized(mockMvc, adminToken(), post("/api/reservation/source-notifications/{notificationId}/ack",
|
||||
notification.id())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{"version": 0}
|
||||
"""))
|
||||
.andExpect(status().isForbidden())
|
||||
.andExpect(jsonPath("$.error_code").value("HOTEL_ACCESS_DENIED"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 V4 命令管理员 token,测试通过真实登录链路覆盖前端鉴权。
|
||||
*/
|
||||
private String adminToken() throws Exception {
|
||||
if (adminToken == null) {
|
||||
adminToken = loginToken(mockMvc, "v4-command-admin", "Admin@123456");
|
||||
}
|
||||
return adminToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取没有 RESERVATION_TASK_CONFIRM 权限的普通用户 token。
|
||||
*/
|
||||
private String noPermissionToken() throws Exception {
|
||||
if (noPermissionToken == null) {
|
||||
noPermissionToken = loginToken(mockMvc, "v4-command-no-permission", "NoPerm@123456");
|
||||
}
|
||||
return noPermissionToken;
|
||||
}
|
||||
|
||||
private void confirmBasicCard(SeededOrderTask seeded) throws Exception {
|
||||
performAuthorized(mockMvc, adminToken(), post("/api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/confirm",
|
||||
seeded.orderTask().id(),
|
||||
seeded.basicCard().id())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{
|
||||
"version": 0,
|
||||
"confirmed_payload": {"group_code": "GRP-V4-COMMAND-001"}
|
||||
}
|
||||
"""))
|
||||
.andExpect(status().isOk());
|
||||
}
|
||||
|
||||
private SeededOrderTask seedOrderTask(String hotelId, String externalMessageId, Instant receivedAt) {
|
||||
return seedOrderTask(hotelId, externalMessageId, receivedAt, null);
|
||||
}
|
||||
|
||||
private SeededOrderTask seedOrderTask(
|
||||
String hotelId,
|
||||
String externalMessageId,
|
||||
Instant receivedAt,
|
||||
Long orderId) {
|
||||
SourceMessageCaptureResult source = captureSourceMessage(
|
||||
hotelId,
|
||||
externalMessageId,
|
||||
"V4 Command Business",
|
||||
receivedAt);
|
||||
LocalDateTime now = LocalDateTime.ofInstant(receivedAt.plusSeconds(10), ZoneOffset.UTC);
|
||||
ReservationV4OrderTaskSnapshot orderTask = workflowRepository.findOrCreateOrderTask(new ReservationV4OrderTaskDraft(
|
||||
hotelId,
|
||||
source.inboxId(),
|
||||
990000000000010001L + Math.abs(externalMessageId.hashCode()),
|
||||
"order-command-" + externalMessageId,
|
||||
1,
|
||||
orderId,
|
||||
"GROUP",
|
||||
"GROUP_CODE",
|
||||
"GRP-V4-COMMAND-001",
|
||||
ReservationV4TargetResolutionStatus.RESOLVED.name(),
|
||||
ReservationV4OrderTaskStatus.OPEN.name(),
|
||||
LocalDateTime.ofInstant(receivedAt, ZoneOffset.UTC),
|
||||
now));
|
||||
ReservationV4TaskCardSnapshot sourceCard = insertCard(orderTask, hotelId,
|
||||
ReservationV4CardType.SOURCE_MESSAGE_DISPLAY.name(), null, 0, 10,
|
||||
ReservationV4CardStatus.READONLY.name(), null, """
|
||||
{"card_type":"SOURCE_MESSAGE_DISPLAY","source_message":{"subject":"V4 Command Business"}}
|
||||
""");
|
||||
ReservationV4TaskCardSnapshot basicCard = insertCard(orderTask, hotelId,
|
||||
ReservationV4CardType.BASIC_INFORMATION.name(), null, 0, 20,
|
||||
ReservationV4CardStatus.PENDING_CONFIRM.name(), null, """
|
||||
{"card_type":"BASIC_INFORMATION","company":"Q.B.D. TRAVEL GROUP CO., LTD","group_code":"GRP-V4-COMMAND-001"}
|
||||
""");
|
||||
ReservationV4TaskCardSnapshot businessCard = insertCard(orderTask, hotelId,
|
||||
ReservationV4CardType.ROOM_INFORMATION.name(), "NEW_BOOKING", 1, 30,
|
||||
ReservationV4CardStatus.PENDING_CONFIRM.name(), null, """
|
||||
{"card_type":"ROOM_INFORMATION","event_type":"NEW_BOOKING","room_items":[{"room_type_code":"TWN","room_count":2}]}
|
||||
""");
|
||||
return new SeededOrderTask(orderTask, sourceCard, basicCard, businessCard);
|
||||
}
|
||||
|
||||
private ReservationV4SourceNotificationSnapshot seedSourceNotification(
|
||||
String hotelId,
|
||||
String externalMessageId,
|
||||
String routeCode,
|
||||
Instant receivedAt) {
|
||||
SourceMessageCaptureResult source = captureSourceMessage(
|
||||
hotelId,
|
||||
externalMessageId,
|
||||
"V4 Command Notification",
|
||||
receivedAt);
|
||||
return sourceNotificationRepository.findOrCreateSourceNotification(new ReservationV4SourceNotificationDraft(
|
||||
hotelId,
|
||||
source.inboxId(),
|
||||
990000000000020001L + Math.abs(externalMessageId.hashCode()),
|
||||
null,
|
||||
routeCode,
|
||||
ReservationV4NotificationStatus.ACK_REQUIRED.name(),
|
||||
"""
|
||||
{"route_code":"%s","source_message":{"body":"Sensitive raw notification body"}}
|
||||
""".formatted(routeCode),
|
||||
LocalDateTime.ofInstant(receivedAt, ZoneOffset.UTC),
|
||||
LocalDateTime.ofInstant(receivedAt.plusSeconds(20), ZoneOffset.UTC)));
|
||||
}
|
||||
|
||||
private ReservationV4TaskCardSnapshot insertCard(
|
||||
ReservationV4OrderTaskSnapshot orderTask,
|
||||
String hotelId,
|
||||
String cardType,
|
||||
String eventType,
|
||||
Integer sourceEventIndex,
|
||||
Integer sortOrder,
|
||||
String cardStatus,
|
||||
String reviewStatus,
|
||||
String displayPayloadJson) {
|
||||
return workflowRepository.insertTaskCard(new ReservationV4TaskCardDraft(
|
||||
hotelId,
|
||||
orderTask.id(),
|
||||
orderTask.sourceMessageId(),
|
||||
eventType == null ? null : 990000000000030001L + sourceEventIndex,
|
||||
cardType,
|
||||
eventType,
|
||||
sourceEventIndex,
|
||||
sortOrder,
|
||||
cardStatus,
|
||||
reviewStatus,
|
||||
"""
|
||||
{"private_url":"https://private.example.test/raw","raw":"must stay internal"}
|
||||
""",
|
||||
displayPayloadJson,
|
||||
null,
|
||||
orderTask.createdAt()));
|
||||
}
|
||||
|
||||
private SourceMessageCaptureResult captureSourceMessage(
|
||||
String hotelId,
|
||||
String externalMessageId,
|
||||
String subject,
|
||||
Instant receivedAt) {
|
||||
return captureService.capture(new CaptureSourceMessageCommand(
|
||||
hotelId,
|
||||
"AGENTBUS",
|
||||
"EMAIL",
|
||||
externalMessageId,
|
||||
"thread-" + externalMessageId,
|
||||
"frame-" + externalMessageId,
|
||||
"session-v4-command",
|
||||
receivedAt,
|
||||
null,
|
||||
"guest@example.test",
|
||||
subject,
|
||||
"Please handle V4 command message.",
|
||||
"<html><body>Please handle V4 command message.</body></html>",
|
||||
"{\"source\":{\"external_message_id\":\"" + externalMessageId + "\"}}",
|
||||
"agentbus-outlook-v1",
|
||||
List.of()
|
||||
));
|
||||
}
|
||||
|
||||
private void assertAuditCount(String action, String actorId, String snapshotFragment, int expectedCount) {
|
||||
Integer count = jdbcTemplate.queryForObject(
|
||||
"""
|
||||
SELECT COUNT(*)
|
||||
FROM workflow_reservation_audit_log
|
||||
WHERE hotel_id = ?
|
||||
AND action = ?
|
||||
AND actor_id = ?
|
||||
AND after_snapshot_json LIKE ?
|
||||
""",
|
||||
Integer.class,
|
||||
HOTEL_ID,
|
||||
action,
|
||||
actorId,
|
||||
"%" + snapshotFragment + "%");
|
||||
org.assertj.core.api.Assertions.assertThat(count).isEqualTo(expectedCount);
|
||||
}
|
||||
|
||||
private record SeededOrderTask(
|
||||
ReservationV4OrderTaskSnapshot orderTask,
|
||||
ReservationV4TaskCardSnapshot sourceCard,
|
||||
ReservationV4TaskCardSnapshot basicCard,
|
||||
ReservationV4TaskCardSnapshot businessCard
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -243,8 +243,9 @@ class ReservationV4QueryControllerTest {
|
||||
.andExpect(jsonPath("$.business_cards[0].card_type").value("ROOM_INFORMATION"))
|
||||
.andExpect(jsonPath("$.business_cards[0].display_payload.event_type").value("NEW_BOOKING"))
|
||||
.andExpect(jsonPath("$.business_cards[0].ai_payload_json").doesNotExist())
|
||||
.andExpect(jsonPath("$.availability.read_only").value(true))
|
||||
.andExpect(jsonPath("$.availability.readonly_reason_code").value("COMMAND_API_PENDING"))
|
||||
.andExpect(jsonPath("$.availability.read_only").value(false))
|
||||
.andExpect(jsonPath("$.availability.confirmable").value(true))
|
||||
.andExpect(jsonPath("$.availability.readonly_reason_code").value("PROCESSABLE"))
|
||||
.andExpect(jsonPath("$.source_message_summary.subject").value("V4 Query Business"))
|
||||
.andExpect(content().string(not(containsString("https://private.example.test"))));
|
||||
}
|
||||
@@ -266,8 +267,9 @@ class ReservationV4QueryControllerTest {
|
||||
.andExpect(jsonPath("$.items[0].order_task.display_order_key").value("GRP-V4-QUERY-001"))
|
||||
.andExpect(jsonPath("$.items[0].card_counts.pending_confirm_count").value(2))
|
||||
.andExpect(jsonPath("$.items[0].display_status").value("OPEN"))
|
||||
.andExpect(jsonPath("$.items[0].availability.read_only").value(true))
|
||||
.andExpect(jsonPath("$.items[0].availability.readonly_reason_code").value("COMMAND_API_PENDING"))
|
||||
.andExpect(jsonPath("$.items[0].availability.read_only").value(false))
|
||||
.andExpect(jsonPath("$.items[0].availability.confirmable").value(true))
|
||||
.andExpect(jsonPath("$.items[0].availability.readonly_reason_code").value("PROCESSABLE"))
|
||||
.andExpect(jsonPath("$.page.total").value(1));
|
||||
}
|
||||
|
||||
@@ -326,8 +328,9 @@ class ReservationV4QueryControllerTest {
|
||||
.andExpect(jsonPath("$.source_message_card.card_type").value("SOURCE_MESSAGE_NOTIFICATION"))
|
||||
.andExpect(jsonPath("$.conversation_summary.source_message_id")
|
||||
.value(notification.sourceMessageId().toString()))
|
||||
.andExpect(jsonPath("$.availability.ackable").value(false))
|
||||
.andExpect(jsonPath("$.availability.read_only").value(true))
|
||||
.andExpect(jsonPath("$.availability.ackable").value(true))
|
||||
.andExpect(jsonPath("$.availability.read_only").value(false))
|
||||
.andExpect(jsonPath("$.availability.readonly_reason_code").value("PROCESSABLE"))
|
||||
.andExpect(jsonPath("$.raw_payload_json").doesNotExist())
|
||||
.andExpect(content().string(not(containsString("Sensitive raw notification body"))));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user