From f228c2b704845915a222eb868341d7dd13ba7dd8 Mon Sep 17 00:00:00 2001 From: andy Date: Tue, 21 Jul 2026 11:35:17 +0700 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8DV4=E5=A4=8D=E6=A0=B8=E6=8C=87?= =?UTF-8?q?=E9=92=88=E4=B8=8E=E4=BB=BB=E5=8A=A1=E8=AF=A6=E6=83=85=E8=84=B1?= =?UTF-8?q?=E6=95=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- PROJECT_STATE.md | 14 +- .../backend-to-frontend-notes.md | 2 +- .../frontend-to-backend-api-requests.md | 2 +- ...002-v4-order-task-card-domain-model-cp2.md | 4 +- .../security-access-control-boundary.md | 2 +- .../impl/ReservationV4CommandServiceImpl.java | 29 ++++ .../impl/ReservationV4QueryServiceImpl.java | 151 +++++++++++++++++- .../ReservationV4CommandControllerTest.java | 140 ++++++++++++++++ .../ReservationV4QueryControllerTest.java | 128 +++++++++++++++ 9 files changed, 452 insertions(+), 20 deletions(-) diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index d87a883..01d8996 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -4,16 +4,16 @@ | --- | --- | | 最近更新 | 2026-07-21 | | 当前分支 | `feature/huangting` | -| 当前阶段 | M002 V4 入站、多卡模型、持久化基线、入站写入、查询接口、卡片确认、复核解阻、目录校验、订单详情 V4 总览、DB 目录、Lookup API、前端 lookup 接入、目录管理后台 CP1 前后端、订单列表 V4 继续处理入口 / open count 收口、V4 业务审计查询、停止旧任务双写、Debug EML V4 profile 对齐、Room Information 后端展示模型与前端业务化展示、V4 任务详情 smoke 修复、Rooming List 确认自动 DEF 后端联动、Account + booking type 过滤 Rate Code 文档口径、Payment 附件预览文档口径、Rooming List 事项确认卡文档口径,以及 V4 复核态卡片交互和字段白名单文档口径 | -| 当前重点 | M002 V4 已停止普通业务入站双写旧 `workflow_reservation_task`,V4 后新业务主线只写 V4 order task / cards / source notification;Debug EML V4 smoke 默认复用实时 AgentBus V4 Open API subject,避免误走历史 Debug V2/V3 profile。开发阶段不维护 V2/V3 旧任务兼容,测试数据可重建,生产迁移策略后续上线前单独设计。`GET /api/reservation/orders` 可返回 V4 下一步订单任务、卡片、动作类型、动作状态、V4 open 数和统一展示字段 `open_work_item_count`;旧 `open_task_count` / `next_processable_task_id` 仅作历史诊断兼容。Room Information 已完成后端稳定展示模型和前端业务化展示:`GET /api/reservation/order-tasks/{orderTaskId}` 在 `display_payload.room_information` 返回 New / Update / Cancel 的 `current_values`、`proposed_values`、`final_values`、`change_summary[]`,前端只消费该展示模型和 `fields[]`,不再从 Agent raw payload、`business_fields` 或 `target_order` 自行推导;Nights、Breakfast 和 Group Booking Status 均以后端派生值为准;确认和复核写入稳定 `confirmed_payload_json.room_information.final_values`,不回写 Agent 原始 `target_order`、Adult、邮件正文或附件 URL;接口对前端暴露的 `fields[].write_target` 使用 `confirmed_payload` / `review_resolution.field_overrides` 这类安全语义,不暴露内部列名。V4 任务详情 smoke 修复已完成:页面顺序固定为 Basic Information、业务卡、SourceMessage Display;来源邮件卡位于页面底部,只通过 SourceMessage conversation 接口定位当前触发邮件并默认折叠正文;Basic Information 的展示和确认 payload 不再返回 Agent `target_order`。Rooming List 卡确认时已实现 Group 自动置 `DEF`:如同订单存在可更新的已确认 Room Information 快照,后端会覆盖其 `group_booking_status=DEF` 并写 `V4_ROOMING_LIST_AUTO_DEF` 审计;刷新任务详情时 `display_payload` 和 `confirmed_payload` 均以 DEF 后的确认快照为准;当前订单详情 `order_overview` 不返回 Group Booking Status 字段;如没有可更新投影,Rooming List 确认仍成功,只写安全审计提示,不临时创建不完整 Room Information。已确认 Rate Code 下一阶段按 Reservation Account + `booking_type`(GROUP / FIT)过滤和校验,不按全酒店 Rate Code 全量展示;已确认 Payment 卡展示付款凭证附件时,`attachment_ids[]` 第一版只读,前端只展示并确认卡片,不增删或替换附件集合,图片在卡片内显示缩略图并点击大图预览,非图片统一文件列表 + 下载,附件外链仍走 SourceMessage 原文权限链路;已确认 Rooming List 任务卡第一版只做事项确认,不做名单解析、附件预览、Excel 生成或 PMS 导入,用户点击“确认卡片”表示已人工处理该 Rooming List 事项;已确认 `REVIEW_REQUIRED` 仍是原业务卡复核态,页面按钮统一叫“确认卡片”,复核态允许编辑当前卡 `fields[]` 白名单内业务字段,问题字段红字提示。后续可继续做测试机 V4 smoke 复测、Rooming List 前端轻量卡展示、Payment 附件预览、Account 范围 Rate Code lookup、真实 PMS / OPERA / OHIP 同步或 SuperAgent 目录供给方案。 | +| 当前阶段 | M002 V4 入站、多卡模型、持久化基线、入站写入、查询接口、卡片确认、复核解阻、目录校验、订单详情 V4 总览、DB 目录、Lookup API、前端 lookup 接入、目录管理后台 CP1 前后端、订单列表 V4 继续处理入口 / open count 收口、V4 业务审计查询、停止旧任务双写、Debug EML V4 profile 对齐、Room Information 后端展示模型与前端业务化展示、V4 任务详情 smoke 修复、Rooming List 确认自动 DEF 后端联动、Room Information 复核 pointer 与任务详情安全边界修复、Account + booking type 过滤 Rate Code 文档口径、Payment 附件预览文档口径、Rooming List 事项确认卡文档口径,以及 V4 复核态卡片交互和字段白名单文档口径 | +| 当前重点 | M002 V4 已停止普通业务入站双写旧 `workflow_reservation_task`,V4 后新业务主线只写 V4 order task / cards / source notification;Debug EML V4 smoke 默认复用实时 AgentBus V4 Open API subject,避免误走历史 Debug V2/V3 profile。开发阶段不维护 V2/V3 旧任务兼容,测试数据可重建,生产迁移策略后续上线前单独设计。`GET /api/reservation/orders` 可返回 V4 下一步订单任务、卡片、动作类型、动作状态、V4 open 数和统一展示字段 `open_work_item_count`;旧 `open_task_count` / `next_processable_task_id` 仅作历史诊断兼容。Room Information 已完成后端稳定展示模型和前端业务化展示:`GET /api/reservation/order-tasks/{orderTaskId}` 在 `display_payload.room_information` 返回 New / Update / Cancel 的 `current_values`、`proposed_values`、`final_values`、`change_summary[]`,前端只消费该展示模型和 `fields[]`,不再从 Agent raw payload、`business_fields` 或 `target_order` 自行推导;如果卡片 payload 已经是稳定 `room_information.final_values` 结构,后端会按稳定模型归一化查询和复核;Nights、Breakfast 和 Group Booking Status 均以后端派生值为准;确认和复核写入稳定 `confirmed_payload_json.room_information.final_values`,不回写 Agent 原始 `target_order`、Adult、邮件正文或附件 URL;接口对前端暴露的 `fields[].write_target` 使用 `confirmed_payload` / `review_resolution.field_overrides` 这类安全语义,不暴露内部列名。V4 任务详情 smoke 修复已完成:页面顺序固定为 Basic Information、业务卡、SourceMessage Display;来源邮件卡位于页面底部,只通过 SourceMessage conversation 接口定位当前触发邮件并默认折叠正文;Basic Information 和普通业务卡的展示 / 确认 payload 不再返回 Agent `target_order`,普通业务卡还会移除邮件 HTML、raw evidence、附件原始 URL 和 PMS 原始响应等敏感字段。Rooming List 卡确认时已实现 Group 自动置 `DEF`:如同订单存在可更新的已确认 Room Information 快照,后端会覆盖其 `group_booking_status=DEF` 并写 `V4_ROOMING_LIST_AUTO_DEF` 审计;刷新任务详情时 `display_payload` 和 `confirmed_payload` 均以 DEF 后的确认快照为准;当前订单详情 `order_overview` 不返回 Group Booking Status 字段;如没有可更新投影,Rooming List 确认仍成功,只写安全审计提示,不临时创建不完整 Room Information。已确认 Rate Code 下一阶段按 Reservation Account + `booking_type`(GROUP / FIT)过滤和校验,不按全酒店 Rate Code 全量展示;已确认 Payment 卡展示付款凭证附件时,`attachment_ids[]` 第一版只读,前端只展示并确认卡片,不增删或替换附件集合,图片在卡片内显示缩略图并点击大图预览,非图片统一文件列表 + 下载,附件外链仍走 SourceMessage 原文权限链路;已确认 Rooming List 任务卡第一版只做事项确认,不做名单解析、附件预览、Excel 生成或 PMS 导入,用户点击“确认卡片”表示已人工处理该 Rooming List 事项;已确认 `REVIEW_REQUIRED` 仍是原业务卡复核态,页面按钮统一叫“确认卡片”,复核态允许编辑当前卡 `fields[]` 白名单内业务字段,问题字段红字提示。后续可继续做测试机 V4 smoke 复测、Rooming List 前端轻量卡展示、Payment 附件预览、Account 范围 Rate Code lookup、真实 PMS / OPERA / OHIP 同步或 SuperAgent 目录供给方案。 | ## 1. 当前 Checkpoint -- 名称:`M002-V4-task-detail-smoke-fixes-frontend` -- 状态:Frontend Done,已修复测试机 smoke 发现的 V4 任务详情页展示顺序、来源邮件正文和 UPDATE_BOOKING 对比展示问题。 -- 目标:V4 任务详情页展示顺序固定为 Basic Information、业务卡、SourceMessage Display;来源邮件卡位于页面底部,通过 SourceMessage conversation 接口定位当前触发该 order task 的邮件正文,默认折叠并可展开;UPDATE_BOOKING Room Information 明确展示当前值、建议值和最终值。 -- 边界:本 checkpoint 不做 Payment 附件预览、不做 Rate Code lookup、不改后端接口;前端不展示 `ai_payload_json`、raw evidence、附件 URL 或未 sanitize 的 `html_body`。 -- 联调备注:若用户缺少 SourceMessage 原文权限或 conversation 接口失败,来源邮件卡降级展示安全摘要和邮件会话入口;SourceMessage Display 不再渲染后端返回的通用 `fields[]`,避免误透敏感字段。 +- 名称:`M002-V4-review-required-pointer-and-api-safety-fix` +- 状态:Backend Done,已修复 Room Information 稳定模型复核 pointer 与任务详情安全边界问题。 +- 目标:`fields[]` 中 `editable=true` 且 `write_target=review_resolution.field_overrides` 的 Room Information pointer 必须可被 `review-resolution` 接收;普通业务卡详情不暴露 Agent `target_order`、邮件 HTML、raw evidence、附件原始 URL 或 PMS 原始响应。 +- 边界:本 checkpoint 不做 Payment 附件预览、不做 Rate Code lookup、不做 PMS / OPERA / OHIP、不推进 M011。 +- 联调备注:Source conversation 接口仍是带 `SOURCE_MESSAGE_ORIGINAL_READ` 的原文权限接口,可返回 `html_body` 和 `html_body_sanitized`;V4 task detail 本身不得返回 raw HTML。 ## 2. 当前优先级 diff --git a/docs/project/frontend-backend/backend-to-frontend-notes.md b/docs/project/frontend-backend/backend-to-frontend-notes.md index 5f08f8e..5d07967 100644 --- a/docs/project/frontend-backend/backend-to-frontend-notes.md +++ b/docs/project/frontend-backend/backend-to-frontend-notes.md @@ -56,7 +56,7 @@ | `GET /api/reservation/tasks` | 查询任务列表 / 工作台 | 必须带 Bearer token,需要 `RESERVATION_TASK_READ`;未传 `order_id` 时按来源消息接收时间倒序,传 `order_id` 时按同订单队列顺序正序;用 `can_process` 和 `readonly_reason_code` 控制入口按钮;列表不返回 AI 原始 payload、邮件正文或附件 URL;已返回来源邮件会话摘要字段,并支持 `order_status` 按任务所属订单状态筛选;旧 S000/S999 和 V3 S10/S99 以 `task_type=SOURCE_MESSAGE_ONLY` 只读任务返回,列表已透出 `result_type`、`ai_task_type`、`route_code`、`system_process_category`。V4 S10/S99 不再进入该旧任务表,应从 V4 工作台来源通知接口展示。 | | `GET /api/reservation/workbench-items` | 查询 V4 工作台统一列表 | 必须带 Bearer token,需要 `RESERVATION_TASK_READ`;返回 V4 业务订单任务和 S10/S99 来源通知混排摘要;支持 `hotel_id`、`item_type`、`keyword`、`page_num`、`page_size`;默认按 `source_received_at` 倒序,同一来源时间下按 `updated_at`、`created_at`、数字 `target_id` 倒序;列表不返回邮件正文、附件 URL、`ai_payload_json` 或来源通知原始 payload。 | | `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 会话接口。V4 任务详情页展示顺序固定为 Basic Information、业务卡、SourceMessage Display;来源邮件卡位于页面最下方,正文限定为当前触发该 order task 的 SourceMessage 正文,前端用 `source_message_summary.source_message_id` 调用 `GET /api/source-messages/{sourceMessageId}/conversation` 后定位当前邮件。Payment 卡下一阶段可返回 `payment_attachments[]` 安全摘要用于展示凭证附件,但本接口不得返回附件 URL;图片缩略图 / 大图和非图片下载 URL 仍通过 SourceMessage 会话权限链路取得。CP8 起每张 V4 任务卡返回 `fields[]`,前端应以该字段白名单渲染可编辑控件;`write_target` 只返回 `confirmed_payload`、`review_resolution.field_overrides`、`none` 等前端安全语义,不暴露内部列名。Room Information 卡已新增 `display_payload.room_information` 稳定展示模型,前端优先读取 `current_values` / `proposed_values` / `final_values` / `change_summary[]`,不要再从 Agent raw payload、`business_fields` 或 `target_order` 自行推导业务展示;Basic Information 的 `display_payload` / `confirmed_payload` 不返回 Agent `target_order`。 | +| `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 会话接口。V4 任务详情页展示顺序固定为 Basic Information、业务卡、SourceMessage Display;来源邮件卡位于页面最下方,正文限定为当前触发该 order task 的 SourceMessage 正文,前端用 `source_message_summary.source_message_id` 调用 `GET /api/source-messages/{sourceMessageId}/conversation` 后定位当前邮件。Payment 卡下一阶段可返回 `payment_attachments[]` 安全摘要用于展示凭证附件,但本接口不得返回附件 URL;图片缩略图 / 大图和非图片下载 URL 仍通过 SourceMessage 会话权限链路取得。CP8 起每张 V4 任务卡返回 `fields[]`,前端应以该字段白名单渲染可编辑控件;`write_target` 只返回 `confirmed_payload`、`review_resolution.field_overrides`、`none` 等前端安全语义,不暴露内部列名。Room Information 卡已新增 `display_payload.room_information` 稳定展示模型,前端优先读取 `current_values` / `proposed_values` / `final_values` / `change_summary[]`,不要再从 Agent raw payload、`business_fields` 或 `target_order` 自行推导业务展示;Basic Information 以及普通业务卡的 `display_payload` / `confirmed_payload` 不返回 Agent `target_order`,普通业务卡也会移除邮件 HTML、raw evidence、附件原始 URL 和 PMS 原始响应等敏感字段。 | | `GET /api/reservation/order-tasks/{orderTaskId}/audits` | 查询 V4 订单任务审计流水 | 必须带 Bearer token,需要 `RESERVATION_AUDIT_READ`,后端按订单任务实际酒店校验访问权;返回 `order_task_id` 和 `items[]`。`items[]` 用于展示 V4 卡片确认、复核解阻、订单归属确认轨迹和 `V4_ROOMING_LIST_AUTO_DEF` 自动 DEF 摘要,只包含脱敏后的审计摘要,不包含邮件正文、HTML、附件 URL、AI 原始 payload、token 或 secret。 | | `GET /api/reservation/source-notifications/{notificationId}` | 查询 V4 S10/S99 来源通知详情 | 必须带 Bearer token,需要 `RESERVATION_TASK_READ`,后端按来源通知实际酒店校验访问权;只返回通知摘要、来源邮件通知卡、会话摘要和 `availability`;不返回订单任务、业务卡、邮件正文、附件 URL 或原始 AI payload。 | | `GET /api/reservation/source-notifications/{notificationId}/audits` | 查询 V4 S10/S99 来源通知审计流水 | 必须带 Bearer token,需要 `RESERVATION_AUDIT_READ`,后端按来源通知实际酒店校验访问权;返回 `notification_id` 和 `items[]`。`items[]` 第一版用于展示来源通知 ack 记录,只包含脱敏后的审计摘要。 | diff --git a/docs/project/frontend-backend/frontend-to-backend-api-requests.md b/docs/project/frontend-backend/frontend-to-backend-api-requests.md index 2d371c2..0662fb5 100644 --- a/docs/project/frontend-backend/frontend-to-backend-api-requests.md +++ b/docs/project/frontend-backend/frontend-to-backend-api-requests.md @@ -1201,4 +1201,4 @@ POST /api/reservation/tasks/{taskId}/order-binding - V4 真实目录与 Lookup API 第一版已在后端 CP11 落地,前端 CP12 已接入 `GET /api/reservation/lookups/accounts`、`GET /api/reservation/lookups/room-types`、`GET /api/reservation/lookups/rate-codes` 用于 V4 字段选择控件。前端按 `options_source` 选择接口,空列表 / stale / warnings 只做非阻塞提示,确认和复核仍只提交 code;Rate Code 下一阶段已确认要按 Account + `booking_type` 过滤,前端需等后端新增 `account_code`、`booking_type` 参数和适用性校验后再联动,不能自行硬编码 OWNER RATE Excel;真实 PMS 同步、目录管理后台扩展和 SuperAgent 目录机器接口仍后置。 - V4 Payment 附件预览下一阶段已确认:后端需补 `payment_attachments[]` 安全摘要;前端图片缩略图 + 点击大图预览,非图片文件列表 + 下载;预览和下载仍走 SourceMessage conversation 原文权限链路。`attachment_ids[]` 第一版作为 Agent 返回的只读业务事实,前端只展示并确认卡片,不做附件集合编辑。 - V4 复核态交互已确认:`REVIEW_REQUIRED` 不新建独立复核任务卡,仍在原业务卡内编辑当前卡 `fields[]` 白名单业务字段;问题字段红字提示;主按钮文案统一为“确认卡片”,但前端内部调用 `POST /api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/review-resolution`。 -- V4 Room Information smoke 修复已完成:`REVIEW_REQUIRED` 下当前卡白名单业务字段会按 `fields[].editable=true` 暴露给前端,`/room_information/final_values/...` pointer 可用于复核提交;Basic Information 的 `display_payload` / `confirmed_payload` 不返回 Agent `target_order`;Rooming List 自动 DEF 后刷新任务详情的 Room Information `display_payload` / `confirmed_payload` 应显示 DEF,审计接口返回 `V4_ROOMING_LIST_AUTO_DEF`;当前订单详情 `order_overview` 不返回 Group Booking Status 字段。 +- V4 Room Information smoke 修复已完成:`REVIEW_REQUIRED` 下当前卡白名单业务字段会按 `fields[].editable=true` 暴露给前端,`/room_information/final_values/...` pointer 可用于复核提交;Basic Information 和普通业务卡的 `display_payload` / `confirmed_payload` 不返回 Agent `target_order`,普通业务卡也会移除邮件 HTML、raw evidence、附件原始 URL 和 PMS 原始响应等敏感字段;Rooming List 自动 DEF 后刷新任务详情的 Room Information `display_payload` / `confirmed_payload` 应显示 DEF,审计接口返回 `V4_ROOMING_LIST_AUTO_DEF`;当前订单详情 `order_overview` 不返回 Group Booking Status 字段。 diff --git a/docs/project/requirements/M002-v4-order-task-card-domain-model-cp2.md b/docs/project/requirements/M002-v4-order-task-card-domain-model-cp2.md index 05b6231..84b7eba 100644 --- a/docs/project/requirements/M002-v4-order-task-card-domain-model-cp2.md +++ b/docs/project/requirements/M002-v4-order-task-card-domain-model-cp2.md @@ -571,7 +571,7 @@ Room Information 卡展示模型: - `ROOM_INFORMATION` 卡只由 `NEW_BOOKING`、`UPDATE_BOOKING`、`CANCEL_BOOKING` 三类 event 触发;`TRACE_RESERVATION_NOTES`、`ROOMING_LIST`、`PAYMENT` 不触发房型信息卡。 - SuperAgent 仍只输出字段契约中的业务字段。Nights、Breakfast、Group Booking Status、Block ID、Confirmation Number 和 Adult 不由 SuperAgent 输出;其中 Adult 第一版不在卡内展示。 -- 后端已在 `GET /api/reservation/order-tasks/{orderTaskId}` 的 Room Information 业务卡 `display_payload.room_information` 中补稳定展示模型,结构为 `event_type`、`booking_type`、`current_values`、`proposed_values`、`final_values`、`change_summary[]`、`group_booking_status_options[]`。前端按该展示模型渲染业务 UI,不再从 Agent raw payload / `target_order` 自行推导;`fields[]` 继续作为确认 / 复核的可编辑字段白名单。`fields[].write_target` 对前端只表达请求体目标,例如 `confirmed_payload` 或 `review_resolution.field_overrides`,不暴露后端内部列名。 +- 后端已在 `GET /api/reservation/order-tasks/{orderTaskId}` 的 Room Information 业务卡 `display_payload.room_information` 中补稳定展示模型,结构为 `event_type`、`booking_type`、`current_values`、`proposed_values`、`final_values`、`change_summary[]`、`group_booking_status_options[]`。前端按该展示模型渲染业务 UI,不再从 Agent raw payload / `target_order` 自行推导;如果存量或调试数据里已经持久化为稳定 `room_information.final_values` 模型,后端会按该稳定模型归一化展示和复核,不再回退到 Agent raw 推导。`fields[]` 继续作为确认 / 复核的可编辑字段白名单。`fields[].write_target` 对前端只表达请求体目标,例如 `confirmed_payload` 或 `review_resolution.field_overrides`,不暴露后端内部列名。 - `fields[]` 的 Room Information 主路径统一为 `/room_information/final_values/...`,例如 `/room_information/final_values/arrival_date`、`/room_information/final_values/room_items/0/room_type_code`。确认接口收到该结构时,后端会从展示模型派生 `confirmed_payload_json.room_information.final_values`,并重新计算 `nights`、`breakfast_included` 和 `group_booking_status_label`;只读字段、Agent `target_order`、Adult 和前端注入字段不会写入确认快照。`REVIEW_REQUIRED` 状态下,当前卡白名单内业务字段可以返回 `editable=true` 并允许同一 pointer 走 `review-resolution`,不再限定只能修空值、`missing_fields[]` 或目录错误字段。 - `NEW_BOOKING`:卡片展示创建后的最终值。Agent 提供 `target_order`、`arrival_date`、`departure_date`、`rate_code`、`booking_scenario`、`room_items[]`,Fit 可提供 `guest_name`;后端派生 `nights`、`breakfast_included` 和 Group Booking Status。 - `UPDATE_BOOKING`:后端从本地订单投影读取当前值,用 Agent `after` 合并得到最终值;页面上方展示本次实际变化的 `change_summary[]`,例如 `入住日期:2026-07-12 -> 2026-07-20`。如果日期变化导致 `nights` 变化,`nights` 也必须出现在差异区;字段区展示合并后的最终值。 @@ -580,7 +580,7 @@ Room Information 卡展示模型: - `breakfast_included` 是卡片展示和确认使用的布尔字段。Group 固定含早,前端显示勾选且只读;Fit 按 Rate Code 派生,Rate Code 包含 `RB` 时含早,包含 `RO` 时不含早;如果 Rate Code 无法派生,前端显示必填勾选框,由用户确认是否含早。 - Group Booking Status 仅 Group 显示,稳定 code 为 `TEN`、`DEF`、`INQ`,前端显示 `TEN-Tentative`、`DEF-Definite`、`INQ-Inquiry`。New Group 默认 `TEN`;`booking_scenario=STANDARD | PROPOSAL` 仅保留为 Agent 场景参考,不映射 Group Booking Status。`NEW_BOOKING` / `UPDATE_BOOKING` 确认前可手动改选,`CANCEL_BOOKING` 只读。 - `ROOMING_LIST` 卡确认时,如果同订单为 Group,后端已把 Group Booking Status 自动置为 `DEF`,即使此前为 `TEN` 或 `INQ`;该自动变更写入 `V4_ROOMING_LIST_AUTO_DEF` 业务审计,并且刷新任务详情时 Room Information 的 `display_payload.room_information.final_values`、`confirmed_payload.room_information.final_values` 都以后端 DEF 后的确认快照为准。当前订单详情 `order_overview` 不返回 Group Booking Status 字段,仍只展示既有确认快照字段。Fit 不显示也不变更 Group Booking Status。 -- `target_order.locator_value` 不作为前端可编辑字段,也不在普通任务详情的 Basic Information `display_payload` / `confirmed_payload` 中返回;订单归属错误时通过 V4 复核选择正确订单或创建正确订单投影,不直接改写 Agent 原始 `target_order.locator_value`。但 New Booking 创建 / 确认的最终订单投影字段允许编辑:Group 显示并允许编辑 `group_block_name`,默认值来自 Agent `target_order.locator_value` 且 `locator_type=GROUP_CODE`;Fit 显示并允许编辑 `fit_name`,默认值来自 `guest_name ?? target_order.locator_value`。用户修改这些字段只影响本系统最终订单投影和确认快照,不回写 Agent 原始定位字段。 +- `target_order.locator_value` 不作为前端可编辑字段,也不在普通任务详情的 Basic Information 或普通业务卡 `display_payload` / `confirmed_payload` 中返回;订单归属错误时通过 V4 复核选择正确订单或创建正确订单投影,不直接改写 Agent 原始 `target_order.locator_value`。但 New Booking 创建 / 确认的最终订单投影字段允许编辑:Group 显示并允许编辑 `group_block_name`,默认值来自 Agent `target_order.locator_value` 且 `locator_type=GROUP_CODE`;Fit 显示并允许编辑 `fit_name`,默认值来自 `guest_name ?? target_order.locator_value`。用户修改这些字段只影响本系统最终订单投影和确认快照,不回写 Agent 原始定位字段。普通业务卡还会移除邮件 HTML、raw evidence、附件原始 URL 和 PMS 原始响应等敏感字段。 - Block ID 和 Confirmation Number 第一版只读;存在本地投影或未来 PMS 结果时展示,否则为空。Block ID 仅 Group 显示,Confirmation Number 仅 Fit 显示。 V4 任务详情页第一版字段白名单: diff --git a/docs/project/security-access-control-boundary.md b/docs/project/security-access-control-boundary.md index e85a703..71b9724 100644 --- a/docs/project/security-access-control-boundary.md +++ b/docs/project/security-access-control-boundary.md @@ -49,7 +49,7 @@ | `GET /api/reservation/tasks/{taskId}` | `FRONTEND_USER` | 已强制 Bearer 登录 + `RESERVATION_TASK_READ`;按任务实际所属酒店校验访问权 | 保持登录 + `RESERVATION_TASK_READ` + 任务所属酒店访问权 | 只读查询默认不写业务审计 | | `GET /api/reservation/workbench-items` | `FRONTEND_USER` | 已实现 M002 V4 CP5;强制 Bearer 登录 + `RESERVATION_TASK_READ` + 酒店访问权 | 保持登录 + `RESERVATION_TASK_READ` + 酒店访问权;统一返回 V4 业务订单任务和 S10/S99 来源通知摘要 | 只读查询默认不写业务审计;不得返回邮件正文、附件 URL、AI 原始 payload 或来源通知原始 payload;同来源时间下使用 `updated_at` / `created_at` / 数字 ID 稳定排序 | | `GET /api/reservation/order-tasks` | `FRONTEND_USER` | 已实现 M002 V4 CP5;强制 Bearer 登录 + `RESERVATION_TASK_READ` + 酒店访问权 | 保持登录 + `RESERVATION_TASK_READ` + 酒店访问权;只返回 V4 业务订单任务,不返回 S10/S99 来源通知 | 只读查询默认不写业务审计;不得返回 AI 原始 payload;`card_status` 只匹配业务 / 可处理卡,固定来源邮件展示卡不参与筛选 | -| `GET /api/reservation/order-tasks/{orderTaskId}` | `FRONTEND_USER` | 已实现 M002 V4 CP5;强制 Bearer 登录 + `RESERVATION_TASK_READ` + 订单任务所属酒店访问权 | 保持登录 + `RESERVATION_TASK_READ` + 订单任务所属酒店访问权;V4 任务详情页展示顺序为 Basic Information、业务卡、SourceMessage Display;Basic Information 不返回 Agent `target_order`;Room Information 展示模型只返回当前酒店本地订单投影、Agent 白名单字段和系统派生值;`fields[].write_target` 只返回前端安全语义,不暴露内部列名;Payment 卡可返回付款凭证附件安全摘要;Payment 第一版 `attachment_ids[]` 只读展示,不支持前端增删或替换附件集合 | 只读查询默认不写业务审计;本接口不得直接返回邮件正文、HTML 或附件 URL,来源邮件卡正文和 Payment 图片预览 / 非图片下载必须通过 `GET /api/source-messages/{id}/conversation` 的 SourceMessage 原文权限链路读取;Basic Information 和 Room Information 展示模型不得返回 PMS 原始响应、价格明细、AI 原始 payload、Agent 原始 `target_order` 或跨酒店订单值;Payment 安全摘要只能包含附件 ID、文件名、类型、大小、是否图片、是否可预览 / 下载等;不得返回 `ai_payload_json`;同批次 `adapter_contract_errors[]` 只返回白名单诊断字段;前端普通业务卡如遇 URL-like 附件字符串必须二次脱敏 | +| `GET /api/reservation/order-tasks/{orderTaskId}` | `FRONTEND_USER` | 已实现 M002 V4 CP5;强制 Bearer 登录 + `RESERVATION_TASK_READ` + 订单任务所属酒店访问权 | 保持登录 + `RESERVATION_TASK_READ` + 订单任务所属酒店访问权;V4 任务详情页展示顺序为 Basic Information、业务卡、SourceMessage Display;Basic Information 不返回 Agent `target_order`;Room Information 展示模型只返回当前酒店本地订单投影、Agent 白名单字段和系统派生值;普通业务卡 `display_payload` / `confirmed_payload` 会移除 Agent `target_order`、邮件 HTML、raw evidence、附件原始 URL 和 PMS 原始响应等敏感字段;`fields[].write_target` 只返回前端安全语义,不暴露内部列名;Payment 卡可返回付款凭证附件安全摘要;Payment 第一版 `attachment_ids[]` 只读展示,不支持前端增删或替换附件集合 | 只读查询默认不写业务审计;本接口不得直接返回邮件正文、HTML 或附件 URL,来源邮件卡正文和 Payment 图片预览 / 非图片下载必须通过 `GET /api/source-messages/{id}/conversation` 的 SourceMessage 原文权限链路读取;Basic Information、Room Information 展示模型和普通业务卡不得返回 PMS 原始响应、价格明细、AI 原始 payload、Agent 原始 `target_order` 或跨酒店订单值;Payment 安全摘要只能包含附件 ID、文件名、类型、大小、是否图片、是否可预览 / 下载等;不得返回 `ai_payload_json`;同批次 `adapter_contract_errors[]` 只返回白名单诊断字段;前端普通业务卡如遇 URL-like 附件字符串必须二次脱敏 | | `GET /api/reservation/order-tasks/{orderTaskId}/audits` | `FRONTEND_USER` | 已强制 Bearer 登录 + `RESERVATION_AUDIT_READ` + V4 订单任务所属酒店访问权 | 保持登录 + `RESERVATION_AUDIT_READ` + 订单任务所属酒店访问权;返回卡片确认、复核解阻和 `V4_ROOMING_LIST_AUTO_DEF` 自动 DEF 审计摘要 | 查询审计不再写审计;返回快照必须脱敏,不返回原始邮件正文、HTML、附件 URL、AI 原始 payload、token 或 secret | | `GET /api/reservation/source-notifications/{notificationId}` | `FRONTEND_USER` | 已实现 M002 V4 CP5;强制 Bearer 登录 + `RESERVATION_TASK_READ` + 来源通知所属酒店访问权 | 保持登录 + `RESERVATION_TASK_READ` + 来源通知所属酒店访问权 | 只读查询默认不写业务审计;邮件正文和附件读取仍走 SourceMessage 原文权限;不得返回来源通知原始 payload 或附件 URL;前端普通通知卡如遇 URL-like 附件字符串必须二次脱敏 | | `GET /api/reservation/source-notifications/{notificationId}/audits` | `FRONTEND_USER` | 已强制 Bearer 登录 + `RESERVATION_AUDIT_READ` + 来源通知所属酒店访问权 | 保持登录 + `RESERVATION_AUDIT_READ` + 来源通知所属酒店访问权;仅返回 S10/S99 来源通知 ack 审计摘要 | 查询审计不再写审计;返回快照必须脱敏,不返回原始邮件正文、HTML、附件 URL、AI 原始 payload、token 或 secret | diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationV4CommandServiceImpl.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationV4CommandServiceImpl.java index b5db0d8..2e3f028 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationV4CommandServiceImpl.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationV4CommandServiceImpl.java @@ -1144,6 +1144,9 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ ReservationV4OrderTaskSnapshot orderTask, ReservationV4TaskCardSnapshot card, ObjectNode displayPayload) { + if (displayPayload.path("room_information").path("final_values").isObject()) { + return stableRoomInformationDisplayModel(orderTask, card, displayPayload); + } String eventType = firstText(card.eventType(), textAt(displayPayload, "event_type")); JsonNode targetOrder = displayPayload.path("target_order"); String bookingType = firstText(textAt(targetOrder, "booking_type"), orderTask.targetBookingType()); @@ -1164,6 +1167,32 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ return model; } + /** + * 兼容已经持久化为稳定 room_information 的展示快照,复核时直接以该稳定模型作为白名单基准。 + */ + private ObjectNode stableRoomInformationDisplayModel( + ReservationV4OrderTaskSnapshot orderTask, + ReservationV4TaskCardSnapshot card, + ObjectNode displayPayload) { + JsonNode roomInformation = displayPayload.path("room_information"); + String eventType = firstText(card.eventType(), firstText(textAt(roomInformation, "event_type"), textAt(displayPayload, "event_type"))); + String bookingType = firstText(textAt(roomInformation, "booking_type"), orderTask.targetBookingType()); + ObjectNode currentValues = roomInformationDisplayValues(bookingType, roomInformation.path("current_values"), true); + ObjectNode proposedValues = roomInformationDisplayValues(bookingType, roomInformation.path("proposed_values"), true); + ObjectNode finalValues = roomInformationDisplayValues(bookingType, roomInformation.path("final_values"), true); + ensureRoomInformationEditablePlaceholders(card, bookingType, finalValues); + normalizeRoomInformationDerivedFields(bookingType, finalValues); + + ObjectNode model = objectMapper.createObjectNode(); + model.put("event_type", eventType); + model.put("booking_type", bookingType); + model.set("current_values", currentValues); + model.set("proposed_values", proposedValues); + model.set("final_values", finalValues); + model.set("change_summary", changeSummary(currentValues, finalValues)); + return model; + } + private ObjectNode currentRoomInformationProjection(ReservationV4OrderTaskSnapshot orderTask) { ObjectNode current = objectMapper.createObjectNode(); if (orderTask.orderId() == null) { diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationV4QueryServiceImpl.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationV4QueryServiceImpl.java index 3770ef4..8ccfaff 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationV4QueryServiceImpl.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationV4QueryServiceImpl.java @@ -111,6 +111,13 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService "order_ref", "route_code", "target_order"); + private static final Set V4_BUSINESS_PAYLOAD_SENSITIVE_FIELDS = Set.of( + "attachment_url", + "html_body", + "html_body_sanitized", + "pms_raw_response", + "raw_evidence", + "target_order"); private final ReservationV4WorkflowRepository workflowRepository; private final ReservationAiWorkflowRepository aiWorkflowRepository; @@ -434,9 +441,13 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService if (ReservationV4CardType.BASIC_INFORMATION.name().equals(card.cardType())) { return safeBasicInformationPayload(displayPayload); } - if (!isRoomInformationEventCard(card) || !displayPayload.isObject()) { + if (ReservationV4CardType.SOURCE_MESSAGE_DISPLAY.name().equals(card.cardType()) + || ReservationV4CardType.SOURCE_MESSAGE_NOTIFICATION.name().equals(card.cardType())) { return displayPayload; } + if (!isRoomInformationEventCard(card) || !displayPayload.isObject()) { + return safeBusinessPayload(displayPayload); + } ObjectNode source = (ObjectNode) displayPayload; ObjectNode safePayload = objectMapper.createObjectNode(); safePayload.put("card_type", firstText(textAt(source, "card_type"), card.cardType())); @@ -456,7 +467,7 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService if (ReservationV4CardType.BASIC_INFORMATION.name().equals(card.cardType())) { return safeBasicInformationPayload(confirmedPayload); } - return confirmedPayload; + return safeBusinessPayload(confirmedPayload); } /** @@ -494,6 +505,83 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService target.set(fieldName, value); } + /** + * 清洗普通业务卡展示 / 确认 payload,避免 Agent 定位三元组、邮件正文或外部原始证据混入任务详情。 + */ + private JsonNode safeBusinessPayload(JsonNode payload) { + if (payload == null || payload.isMissingNode() || payload.isNull()) { + return NullNode.getInstance(); + } + JsonNode copied = payload.deepCopy(); + removeSensitiveBusinessPayloadFields(copied); + return copied; + } + + private void removeSensitiveBusinessPayloadFields(JsonNode node) { + if (node == null || node.isMissingNode() || node.isNull()) { + return; + } + if (node.isObject()) { + ObjectNode objectNode = (ObjectNode) node; + objectNode.remove(V4_BUSINESS_PAYLOAD_SENSITIVE_FIELDS); + Iterator> iterator = objectNode.fields(); + List fieldsToRemove = new ArrayList<>(); + while (iterator.hasNext()) { + Map.Entry entry = iterator.next(); + if (isSensitiveBusinessPayloadField(entry.getKey()) || isSensitiveBusinessPayloadValue(entry.getValue())) { + fieldsToRemove.add(entry.getKey()); + continue; + } + removeSensitiveBusinessPayloadFields(entry.getValue()); + } + if (!fieldsToRemove.isEmpty()) { + objectNode.remove(fieldsToRemove); + } + return; + } + if (node.isArray()) { + ArrayNode arrayNode = (ArrayNode) node; + for (int index = 0; index < arrayNode.size(); index++) { + JsonNode item = arrayNode.get(index); + if (isSensitiveBusinessPayloadValue(item)) { + arrayNode.set(index, NullNode.getInstance()); + continue; + } + removeSensitiveBusinessPayloadFields(item); + } + } + } + + private boolean isSensitiveBusinessPayloadField(String fieldName) { + String normalized = fieldName == null ? "" : fieldName.toLowerCase(Locale.ROOT); + String compact = normalized.replace("_", "").replace("-", ""); + return V4_BUSINESS_PAYLOAD_SENSITIVE_FIELDS.contains(normalized) + || "url".equals(normalized) + || normalized.endsWith("_url") + || normalized.contains("private_url") + || normalized.contains("download_url") + || normalized.contains("signed_url") + || normalized.contains("attachment_url") + || normalized.contains("external_url") + || compact.endsWith("url") + || compact.contains("externalurl") + || compact.contains("signedurl") + || compact.contains("privateurl") + || compact.contains("downloadurl"); + } + + private boolean isSensitiveBusinessPayloadValue(JsonNode value) { + if (value == null || !value.isTextual()) { + return false; + } + String text = value.asText(""); + String normalized = text.toLowerCase(Locale.ROOT); + return normalized.startsWith("http://") + || normalized.startsWith("https://") + || normalized.startsWith("oss://") + || normalized.startsWith("file://"); + } + private ReservationV4TaskCardResult toSourceNotificationCard( ReservationV4SourceNotificationSnapshot notification, ReservationV4ActionAvailabilityResult availability) { @@ -1062,6 +1150,34 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService ReservationV4OrderTaskSnapshot orderTask, ReservationV4TaskCardSnapshot card, ObjectNode displayPayload) { + ObjectNode model; + if (displayPayload.path("room_information").path("final_values").isObject()) { + model = stableRoomInformationDisplayModel(orderTask, card, displayPayload); + } else { + model = derivedRoomInformationDisplayModel(orderTask, card, displayPayload); + } + ObjectNode confirmedFinalValues = confirmedStableRoomInformationFinalValues(card); + if (confirmedFinalValues != null) { + String bookingType = textAt(model, "booking_type"); + normalizeRoomInformationDerivedFields(bookingType, confirmedFinalValues); + ObjectNode finalValues = roomInformationDisplayValues(bookingType, confirmedFinalValues, true); + ObjectNode currentValues = model.path("current_values").isObject() + ? (ObjectNode) model.path("current_values") + : objectMapper.createObjectNode(); + model.set("final_values", finalValues); + model.set("change_summary", changeSummary(currentValues, finalValues)); + } + model.set("group_booking_status_options", groupBookingStatusOptions()); + return model; + } + + /** + * 从 Agent 原始 Room Information payload 生成稳定展示模型。 + */ + private ObjectNode derivedRoomInformationDisplayModel( + ReservationV4OrderTaskSnapshot orderTask, + ReservationV4TaskCardSnapshot card, + ObjectNode displayPayload) { String eventType = firstText(card.eventType(), textAt(displayPayload, "event_type")); JsonNode targetOrder = displayPayload.path("target_order"); String bookingType = firstText(textAt(targetOrder, "booking_type"), orderTask.targetBookingType()); @@ -1070,11 +1186,6 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService ObjectNode currentValues = currentRoomInformationProjection(orderTask, card); ObjectNode proposedValues = proposedRoomInformationValues(eventType, bookingType, locatorType, locatorValue, displayPayload); ObjectNode finalValues = finalRoomInformationValues(eventType, bookingType, locatorType, locatorValue, currentValues, proposedValues); - ObjectNode confirmedFinalValues = confirmedStableRoomInformationFinalValues(card); - if (confirmedFinalValues != null) { - normalizeRoomInformationDerivedFields(bookingType, confirmedFinalValues); - finalValues = roomInformationDisplayValues(bookingType, confirmedFinalValues, true); - } ObjectNode model = objectMapper.createObjectNode(); model.put("event_type", eventType); @@ -1083,7 +1194,31 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService model.set("proposed_values", EVENT_CANCEL_BOOKING.equals(eventType) ? objectMapper.createObjectNode() : proposedValues); model.set("final_values", finalValues); model.set("change_summary", changeSummary(EVENT_NEW_BOOKING.equals(eventType) ? objectMapper.createObjectNode() : currentValues, finalValues)); - model.set("group_booking_status_options", groupBookingStatusOptions()); + return model; + } + + /** + * 兼容已经持久化为稳定 room_information 结构的任务卡,避免刷新或复核时丢失前端白名单字段。 + */ + private ObjectNode stableRoomInformationDisplayModel( + ReservationV4OrderTaskSnapshot orderTask, + ReservationV4TaskCardSnapshot card, + ObjectNode displayPayload) { + JsonNode roomInformation = displayPayload.path("room_information"); + String eventType = firstText(card.eventType(), firstText(textAt(roomInformation, "event_type"), textAt(displayPayload, "event_type"))); + String bookingType = firstText(textAt(roomInformation, "booking_type"), orderTask.targetBookingType()); + ObjectNode currentValues = roomInformationDisplayValues(bookingType, roomInformation.path("current_values"), true); + ObjectNode proposedValues = roomInformationDisplayValues(bookingType, roomInformation.path("proposed_values"), true); + ObjectNode finalValues = roomInformationDisplayValues(bookingType, roomInformation.path("final_values"), true); + normalizeRoomInformationDerivedFields(bookingType, finalValues); + + ObjectNode model = objectMapper.createObjectNode(); + model.put("event_type", eventType); + model.put("booking_type", bookingType); + model.set("current_values", currentValues); + model.set("proposed_values", proposedValues); + model.set("final_values", finalValues); + model.set("change_summary", changeSummary(currentValues, finalValues)); return model; } diff --git a/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationV4CommandControllerTest.java b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationV4CommandControllerTest.java index 9860a91..7d6a2e6 100644 --- a/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationV4CommandControllerTest.java +++ b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationV4CommandControllerTest.java @@ -1019,6 +1019,146 @@ class ReservationV4CommandControllerTest { .value("/room_information/final_values/room_items/0/room_type_code")); } + @Test + void shouldResolveRoomInformationReviewWithEditableFieldPointerFromTaskDetail() throws Exception { + SeededOrderTask seeded = seedReviewOrderTaskWithBusinessCard( + HOTEL_ID, + "mail-v4-command-review-room-info-field-contract-001", + Instant.parse("2026-07-19T01:22:16Z"), + 990000000000070114L, + ReservationV4TargetResolutionStatus.RESOLVED.name(), + ReservationV4CardStatus.PENDING_CONFIRM.name(), + ReservationV4CardStatus.REVIEW_REQUIRED.name(), + ReservationV4CardType.ROOM_INFORMATION.name(), + "UPDATE_BOOKING", + """ + { + "card_type":"ROOM_INFORMATION", + "event_type":"UPDATE_BOOKING", + "target_order":{"booking_type":"GROUP","locator_type":"GROUP_CODE","locator_value":"GRP-V4-REVIEW-RI-FIELD-001"}, + "business_fields":{ + "event_type":"UPDATE_BOOKING", + "after":{ + "arrival_date":"2026-08-01", + "departure_date":"2026-08-03", + "room_items":[{"room_type_code":"UNKNOWN_TYPE","room_count":2}] + } + } + } + """, + """ + [ + { + "field_path": "room_information.final_values.room_items.0.room_type_code", + "field_pointer": "/room_information/final_values/room_items/0/room_type_code", + "message": "房型代码不在第一版目录中。", + "detail": "room_information.final_values.room_items.0.room_type_code: 房型代码不在第一版目录中。" + } + ] + """); + confirmBasicCard(seeded); + + performAuthorized(mockMvc, adminToken(), get("/api/reservation/order-tasks/{orderTaskId}", seeded.orderTask().id()) + .param("hotel_id", HOTEL_ID)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.business_cards[0].fields[?(@.field_pointer=='/room_information/final_values/room_items/0/room_type_code')].editable") + .value(contains(true))) + .andExpect(jsonPath("$.business_cards[0].fields[?(@.field_pointer=='/room_information/final_values/room_items/0/room_type_code')].write_target") + .value(contains("review_resolution.field_overrides"))); + + performAuthorized(mockMvc, adminToken(), post( + "/api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/review-resolution", + seeded.orderTask().id(), + seeded.businessCard().id()) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "version": 0, + "reason": "按详情页白名单修正房型", + "field_overrides": [ + { + "field_pointer": "/room_information/final_values/room_items/0/room_type_code", + "value": "RM2" + } + ] + } + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.business_cards[0].card_status").value("CONFIRMED")) + .andExpect(jsonPath("$.business_cards[0].confirmed_payload.room_information.final_values.room_items[0].room_type_code") + .value("RM2")) + .andExpect(jsonPath("$.business_cards[0].review_resolution.field_overrides[0].field_pointer") + .value("/room_information/final_values/room_items/0/room_type_code")); + } + + @Test + void shouldResolveRoomInformationReviewWhenDisplayPayloadAlreadyUsesStableModel() throws Exception { + SeededOrderTask seeded = seedReviewOrderTaskWithBusinessCard( + HOTEL_ID, + "mail-v4-command-review-room-info-stable-display-001", + Instant.parse("2026-07-19T01:22:26Z"), + 990000000000070124L, + ReservationV4TargetResolutionStatus.RESOLVED.name(), + ReservationV4CardStatus.PENDING_CONFIRM.name(), + ReservationV4CardStatus.REVIEW_REQUIRED.name(), + ReservationV4CardType.ROOM_INFORMATION.name(), + "UPDATE_BOOKING", + """ + { + "card_type":"ROOM_INFORMATION", + "event_type":"UPDATE_BOOKING", + "room_information":{ + "event_type":"UPDATE_BOOKING", + "booking_type":"GROUP", + "current_values":{}, + "proposed_values":{"room_items":[{"room_type_code":"UNKNOWN_TYPE","room_count":2}]}, + "final_values":{ + "arrival_date":"2026-08-01", + "departure_date":"2026-08-03", + "room_items":[{"room_type_code":"UNKNOWN_TYPE","room_count":2}], + "breakfast_included":true, + "group_booking_status":"TEN", + "group_booking_status_label":"TEN-Tentative" + } + } + } + """, + """ + [ + { + "field_path": "room_information.final_values.room_items.0.room_type_code", + "field_pointer": "/room_information/final_values/room_items/0/room_type_code", + "message": "房型代码不在第一版目录中。", + "detail": "room_information.final_values.room_items.0.room_type_code: 房型代码不在第一版目录中。" + } + ] + """); + confirmBasicCard(seeded); + + performAuthorized(mockMvc, adminToken(), post( + "/api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/review-resolution", + seeded.orderTask().id(), + seeded.businessCard().id()) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "version": 0, + "reason": "修正已稳定展示模型中的房型", + "field_overrides": [ + { + "field_pointer": "/room_information/final_values/room_items/0/room_type_code", + "value": "RM2" + } + ] + } + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.business_cards[0].confirmed_payload.room_information.final_values.room_items[0].room_type_code") + .value("RM2")) + .andExpect(jsonPath("$.business_cards[0].confirmed_payload.room_information.final_values.nights") + .value(2)); + } + @Test void shouldRejectRoomInformationReviewWhenPointerTargetsDerivedReadonlyField() throws Exception { SeededOrderTask seeded = seedReviewOrderTaskWithBusinessCard( diff --git a/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationV4QueryControllerTest.java b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationV4QueryControllerTest.java index 223be8e..7adc994 100644 --- a/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationV4QueryControllerTest.java +++ b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationV4QueryControllerTest.java @@ -526,6 +526,73 @@ class ReservationV4QueryControllerTest { .andExpect(content().string(not(containsString("SHOULD-NOT-LEAK")))); } + @Test + void shouldNotExposeTargetOrderInRoomingListDisplayOrConfirmedPayload() throws Exception { + ReservationV4OrderTaskSnapshot orderTask = seedOrderTaskWithBusinessCardType( + "mail-v4-query-rooming-target-order-safe-001", + Instant.parse("2026-07-18T03:02:45Z"), + "GROUP", + "GROUP_CODE", + "GRP-V4-ROOMING-SAFE-001", + ReservationV4CardType.ROOMING_LIST.name(), + "ROOMING_LIST", + ReservationV4CardStatus.CONFIRMED.name(), + ReservationV4CardStatus.PENDING_CONFIRM.name(), + """ + { + "card_type":"ROOMING_LIST", + "event_type":"ROOMING_LIST", + "target_order":{"locator_value":"SHOULD-NOT-LEAK-ROOMING-DISPLAY"}, + "attachments":[ + { + "name":"rooming-list.xlsx", + "url":"https://oss.example.test/rooming/display.xlsx", + "externalUrl":"https://oss.example.test/rooming/display-external.xlsx" + } + ], + "business_fields":{ + "attachment_ids":["att-rooming-safe-001"], + "rooming_list_action":"CONFIRM_RECEIVED", + "evidence_url":"https://oss.example.test/rooming/evidence.pdf", + "file_references":["https://oss.example.test/rooming/reference.pdf"] + } + } + """, + """ + { + "card_type":"ROOMING_LIST", + "event_type":"ROOMING_LIST", + "target_order":{"locator_value":"SHOULD-NOT-LEAK-ROOMING-CONFIRMED"}, + "attachments":[ + { + "name":"rooming-list-confirmed.xlsx", + "download_url":"https://oss.example.test/rooming/confirmed.xlsx" + } + ], + "business_fields":{ + "attachment_ids":["att-rooming-safe-001"], + "rooming_list_action":"CONFIRM_RECEIVED", + "signedUrl":"https://oss.example.test/rooming/signed.pdf" + } + } + """); + + performAuthorized(mockMvc, adminToken(), get("/api/reservation/order-tasks/{orderTaskId}", orderTask.id()) + .param("hotel_id", HOTEL_ID)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.business_cards[0].card_type").value("ROOMING_LIST")) + .andExpect(jsonPath("$.business_cards[0].display_payload.target_order").doesNotExist()) + .andExpect(jsonPath("$.business_cards[0].confirmed_payload.target_order").doesNotExist()) + .andExpect(jsonPath("$.business_cards[0].display_payload.attachments[0].name").value("rooming-list.xlsx")) + .andExpect(jsonPath("$.business_cards[0].display_payload.attachments[0].url").doesNotExist()) + .andExpect(jsonPath("$.business_cards[0].display_payload.attachments[0].externalUrl").doesNotExist()) + .andExpect(jsonPath("$.business_cards[0].display_payload.business_fields.evidence_url").doesNotExist()) + .andExpect(jsonPath("$.business_cards[0].confirmed_payload.attachments[0].download_url").doesNotExist()) + .andExpect(jsonPath("$.business_cards[0].confirmed_payload.business_fields.signedUrl").doesNotExist()) + .andExpect(content().string(not(containsString("SHOULD-NOT-LEAK-ROOMING")))) + .andExpect(content().string(not(containsString("oss.example.test")))); + } + @Test void shouldReturnReadonlyRoomInformationDisplayModelForCancelBooking() throws Exception { Long orderId = 990000000000777002L; @@ -1107,6 +1174,67 @@ class ReservationV4QueryControllerTest { return orderTask; } + private ReservationV4OrderTaskSnapshot seedOrderTaskWithBusinessCardType( + String externalMessageId, + Instant receivedAt, + String targetBookingType, + String targetLocatorType, + String targetLocatorValue, + String businessCardType, + String businessEventType, + String basicCardStatus, + String businessCardStatus, + String businessDisplayPayloadJson, + String confirmedPayloadJson) { + SourceMessageCaptureResult source = captureSourceMessage(externalMessageId, "V4 Query Business", receivedAt, HOTEL_ID); + LocalDateTime now = LocalDateTime.ofInstant(receivedAt.plusSeconds(10), ZoneOffset.UTC); + ReservationV4OrderTaskSnapshot orderTask = workflowRepository.findOrCreateOrderTask(new ReservationV4OrderTaskDraft( + HOTEL_ID, + source.inboxId(), + 990000000000003001L + Math.abs(externalMessageId.hashCode()), + "order-generic-card-" + externalMessageId, + 1, + null, + targetBookingType, + targetLocatorType, + targetLocatorValue, + ReservationV4TargetResolutionStatus.RESOLVED.name(), + ReservationV4OrderTaskStatus.OPEN.name(), + LocalDateTime.ofInstant(receivedAt, ZoneOffset.UTC), + now)); + insertCard(orderTask, ReservationV4CardType.SOURCE_MESSAGE_DISPLAY.name(), null, 0, 10, + ReservationV4CardStatus.READONLY.name(), null, """ + {"card_type":"SOURCE_MESSAGE_DISPLAY","source_message":{"subject":"V4 Query Business"}} + """); + insertCard(orderTask, ReservationV4CardType.BASIC_INFORMATION.name(), null, 0, 20, + basicCardStatus, reviewStatusFor(basicCardStatus), """ + { + "card_type":"BASIC_INFORMATION", + "order_ref":"order-1", + "basic_information":{"account_code":"QBD_TRAVEL","market_code":"LEISURE","source_code":"TRAVEL_AGENT"} + } + """); + ReservationV4TaskCardSnapshot businessCard = insertCard( + orderTask, + businessCardType, + businessEventType, + 1, + 30, + businessCardStatus, + reviewStatusFor(businessCardStatus), + businessDisplayPayloadJson); + if (confirmedPayloadJson != null) { + workflowRepository.confirmTaskCardWithVersion( + HOTEL_ID, + businessCard.id(), + businessCard.version(), + confirmedPayloadJson, + "v4-query-admin", + now); + } + return orderTask; + } + private void seedConfirmedRoomInformationProjection( Long orderId, String externalMessageId,