修复V4复核指针运行时白名单一致性

This commit is contained in:
andy
2026-07-21 12:16:19 +07:00
parent f228c2b704
commit 4bf53768ad
7 changed files with 366 additions and 76 deletions

View File

@@ -4,16 +4,16 @@
| --- | --- | | --- | --- |
| 最近更新 | 2026-07-21 | | 最近更新 | 2026-07-21 |
| 当前分支 | `feature/huangting` | | 当前分支 | `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 后端联动、Room Information 复核 pointer 与任务详情安全边界修复、Account + booking type 过滤 Rate Code 文档口径、Payment 附件预览文档口径、Rooming List 事项确认卡文档口径,以及 V4 复核态卡片交互和字段白名单文档口径 | | 当前阶段 | 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 与任务详情安全边界修复、Room Information 复核 pointer 运行时规则收口、Account + booking type 过滤 Rate Code 文档口径、Payment 附件预览文档口径、Rooming List 事项确认卡文档口径,以及 V4 复核态卡片交互和字段白名单文档口径 |
| 当前重点 | M002 V4 已停止普通业务入站双写旧 `workflow_reservation_task`V4 后新业务主线只写 V4 order task / cards / source notificationDebug 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 目录供给方案。 | | 当前重点 | M002 V4 已停止普通业务入站双写旧 `workflow_reservation_task`V4 后新业务主线只写 V4 order task / cards / source notificationDebug 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` 这类安全语义,不暴露内部列名;查询侧 `fields[].editable` 和命令侧 `review-resolution` 复核 pointer 校验已共用同一套 Room Information 字段策略。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 ## 1. 当前 Checkpoint
- 名称:`M002-V4-review-required-pointer-and-api-safety-fix` - 名称:`M002-V4-review-pointer-runtime-fix`
- 状态Backend Done修复 Room Information 稳定模型复核 pointer 与任务详情安全边界问题 - 状态Backend Done Room Information 详情 fields 白名单和复核命令 pointer 校验收口到同一套字段策略
- 目标:`fields[]``editable=true``write_target=review_resolution.field_overrides` 的 Room Information pointer 必须可被 `review-resolution` 接收;普通业务卡详情不暴露 Agent `target_order`、邮件 HTML、raw evidence、附件原始 URL 或 PMS 原始响应。 - 目标:`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。 - 边界:本 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。 - 联调备注Source conversation 接口仍是带 `SOURCE_MESSAGE_ORIGINAL_READ` 的原文权限接口,可返回 `html_body``html_body_sanitized`V4 task detail 本身不得返回 raw HTML。若测试机仍出现 `V4_REVIEW_POINTER_NOT_ALLOWED`,后端日志应出现 `review_pointer_policy=m002_v4_review_pointer_runtime_fix_v1` marker没有该 marker 通常说明运行包未包含本次修复路径。
## 2. 当前优先级 ## 2. 当前优先级

View File

@@ -542,7 +542,7 @@ RESERVATION_ROOMING_LIST_GENERATE
- CP8 / Room Information 展示模型后,确认接口按 `fields[]` 白名单收口:前端可以只提交用户修改过的可编辑字段,不建议整包回传 `display_payload`。后端会从当前卡展示快照生成确认快照,并只合并可写叶子字段;来源邮件、路由、`target_order``order_ref``manual_review`、校验诊断字段以及前端额外注入字段不会写入内部确认快照。 - CP8 / Room Information 展示模型后,确认接口按 `fields[]` 白名单收口:前端可以只提交用户修改过的可编辑字段,不建议整包回传 `display_payload`。后端会从当前卡展示快照生成确认快照,并只合并可写叶子字段;来源邮件、路由、`target_order``order_ref``manual_review`、校验诊断字段以及前端额外注入字段不会写入内部确认快照。
- 业务卡目录校验会递归检查稳定模型或历史兼容结构。例如 Room Information 新结构的房型位于 `/room_information/final_values/room_items/0/room_type_code`,错误详情会使用 `room_information.final_values.room_items.0.room_type_code`;历史兼容 `UPDATE_BOOKING` 的房型可能仍使用 `business_fields.after.room_items.0.room_type_code`。前端展示错误时优先用 `fields[].validation_errors`,接口 400 时可直接展示 `details[]` - 业务卡目录校验会递归检查稳定模型或历史兼容结构。例如 Room Information 新结构的房型位于 `/room_information/final_values/room_items/0/room_type_code`,错误详情会使用 `room_information.final_values.room_items.0.room_type_code`;历史兼容 `UPDATE_BOOKING` 的房型可能仍使用 `business_fields.after.room_items.0.room_type_code`。前端展示错误时优先用 `fields[].validation_errors`,接口 400 时可直接展示 `details[]`
- `review-resolution` 请求示例:`{"version":0,"reason":"确认房型映射","confirmed_order_id":"123456","field_overrides":[{"field_pointer":"/room_information/final_values/room_items/0/room_type_code","value":"RM2"}]}``confirmed_order_id` 在订单任务归属未解决时必填;如果订单任务已经绑定订单且 `target_resolution_status=RESOLVED`,只能不传或传当前同一个订单 ID不能借该接口切换到其它订单。`field_pointer` 必须来自当前卡 `fields[]` 中可编辑的 `basic_information.*``room_information.final_values.*` 或历史兼容 `business_fields.*` 叶子字段;复核态允许编辑当前卡业务白名单内字段,不再限定只能改空值、`missing_fields[]` 或目录错误字段。前端不要提交来源邮件、路由、`target_order``order_ref`、缺失字段清单、`manual_review`、raw evidence、校验诊断字段也不能替换整个对象 / 数组。 - `review-resolution` 请求示例:`{"version":0,"reason":"确认房型映射","confirmed_order_id":"123456","field_overrides":[{"field_pointer":"/room_information/final_values/room_items/0/room_type_code","value":"RM2"}]}``confirmed_order_id` 在订单任务归属未解决时必填;如果订单任务已经绑定订单且 `target_resolution_status=RESOLVED`,只能不传或传当前同一个订单 ID不能借该接口切换到其它订单。`field_pointer` 必须来自当前卡 `fields[]` 中可编辑的 `basic_information.*``room_information.final_values.*` 或历史兼容 `business_fields.*` 叶子字段;复核态允许编辑当前卡业务白名单内字段,不再限定只能改空值、`missing_fields[]` 或目录错误字段。前端不要提交来源邮件、路由、`target_order``order_ref`、缺失字段清单、`manual_review`、raw evidence、校验诊断字段也不能替换整个对象 / 数组。
- V4 `fields[]` 第一版字段说明Basic Information 固定返回 `/basic_information/account_code``/basic_information/market_code``/basic_information/source_code`;其中 Account `control_type=select``options_source=reservation_v4_account_catalog`Market / Source 为只读派生字段。Room Information 字段统一返回 `/room_information/final_values/...`,例如 `/room_information/final_values/arrival_date``/room_information/final_values/room_items/0/room_type_code``REVIEW_REQUIRED` 状态下只要字段仍在当前卡业务白名单内且未被前置阻塞,就会返回 `editable=true` 并允许 `review-resolution` 提交同一个 pointer前端不要自行补未返回字段。 - V4 `fields[]` 第一版字段说明Basic Information 固定返回 `/basic_information/account_code``/basic_information/market_code``/basic_information/source_code`;其中 Account `control_type=select``options_source=reservation_v4_account_catalog`Market / Source 为只读派生字段。Room Information 字段统一返回 `/room_information/final_values/...`,例如 `/room_information/final_values/arrival_date``/room_information/final_values/room_items/0/room_type_code``REVIEW_REQUIRED` 状态下只要字段仍在当前卡业务白名单内且未被前置阻塞,就会返回 `editable=true` 并允许 `review-resolution` 提交同一个 pointer查询侧 editable 计算和命令侧 pointer 校验共用同一套 Room Information 字段策略,测试机如仍出现 `V4_REVIEW_POINTER_NOT_ALLOWED`,可让后端日志检索 `review_pointer_policy=m002_v4_review_pointer_runtime_fix_v1` 判断是否运行到新包路径;前端不要自行补未返回字段。
- V4 CP11 已开放独立目录 lookup API。前端应使用 `GET /api/reservation/lookups/accounts``GET /api/reservation/lookups/room-types``GET /api/reservation/lookups/rate-codes` 渲染 Account / Room Type / Rate Code 选项;用户提交确认或复核时只提交稳定 `code`,不要提交显示名、派生 Market / Source 或目录完整对象后端确认前仍会重新校验目录。Rate Code 下一阶段依赖 Account + `booking_type`:前端需在 Account 已选 / 已确认且能取得当前业务 event `booking_type` 后再请求 Rate CodeAccount 改变后清空或重新校验已选 Rate Code缺失条件时禁用或空态不硬编码 OWNER RATE Excel。`keyword` 查不到只表示当前筛选无结果,不能仅凭 `items=[]` 判断目录未初始化,应结合 `catalog_source``catalog_version``warnings[]` - V4 CP11 已开放独立目录 lookup API。前端应使用 `GET /api/reservation/lookups/accounts``GET /api/reservation/lookups/room-types``GET /api/reservation/lookups/rate-codes` 渲染 Account / Room Type / Rate Code 选项;用户提交确认或复核时只提交稳定 `code`,不要提交显示名、派生 Market / Source 或目录完整对象后端确认前仍会重新校验目录。Rate Code 下一阶段依赖 Account + `booking_type`:前端需在 Account 已选 / 已确认且能取得当前业务 event `booking_type` 后再请求 Rate CodeAccount 改变后清空或重新校验已选 Rate Code缺失条件时禁用或空态不硬编码 OWNER RATE Excel。`keyword` 查不到只表示当前筛选无结果,不能仅凭 `items=[]` 判断目录未初始化,应结合 `catalog_source``catalog_version``warnings[]`
- M002 V4 CP12 前端已接入上述三个 lookup APIV4 多卡详情页会按当前卡 `fields[].options_source` 拉取目录选项,空 `items[]``stale=true``warnings[]` 作为非阻塞提示展示Account 选择后只展示目录返回的 `market_code` / `source_code` 辅助确认,确认 / 复核请求仍只提交用户选择的 code。 - M002 V4 CP12 前端已接入上述三个 lookup APIV4 多卡详情页会按当前卡 `fields[].options_source` 拉取目录选项,空 `items[]``stale=true``warnings[]` 作为非阻塞提示展示Account 选择后只展示目录返回的 `market_code` / `source_code` 辅助确认,确认 / 复核请求仍只提交用户选择的 code。
- V4 新模型确认口径是不保存后端草稿、卡片最终确认后锁定、技术异常不进入用户可处理卡、当前不生成 OPERA 模拟操作。Basic Information 必须先确认;其它业务卡第一版不强制逐张顺序确认。现有 V3 `draft``confirm``manual-review-resolutions` 和 OPERA 模拟接口仍只代表旧链路能力,不能直接等同 V4 多卡最终接口。 - V4 新模型确认口径是不保存后端草稿、卡片最终确认后锁定、技术异常不进入用户可处理卡、当前不生成 OPERA 模拟操作。Basic Information 必须先确认;其它业务卡第一版不强制逐张顺序确认。现有 V3 `draft``confirm``manual-review-resolutions` 和 OPERA 模拟接口仍只代表旧链路能力,不能直接等同 V4 多卡最终接口。

View File

@@ -174,6 +174,7 @@ V4 新数据不再提供后端草稿保存。前端可以在页面本地维护
- CP11 起确认和复核都会校验当前酒店数据库目录字段Basic Information 的 `account_code` 必须来自当前酒店 ACTIVE Account 目录,通过后后端派生 `market_code` / `source_code` - CP11 起确认和复核都会校验当前酒店数据库目录字段Basic Information 的 `account_code` 必须来自当前酒店 ACTIVE Account 目录,通过后后端派生 `market_code` / `source_code`
- 通过校验后卡片直接进入 `CONFIRMED`,不再进入 V3 `READY` 状态。 - 通过校验后卡片直接进入 `CONFIRMED`,不再进入 V3 `READY` 状态。
- `field_overrides[].field_pointer` 必须是当前卡 `fields[]` 白名单中允许编辑的 RFC 6901 JSON Pointer。`REVIEW_REQUIRED` 是整张原业务卡的复核状态,前端仍在原卡片内展示业务表单,问题字段用红字 / `validation_errors` 强调;用户可修改当前卡业务白名单内字段,不再限定只能改空值、`missing_fields[]` 或目录错误字段。 - `field_overrides[].field_pointer` 必须是当前卡 `fields[]` 白名单中允许编辑的 RFC 6901 JSON Pointer。`REVIEW_REQUIRED` 是整张原业务卡的复核状态,前端仍在原卡片内展示业务表单,问题字段用红字 / `validation_errors` 强调;用户可修改当前卡业务白名单内字段,不再限定只能改空值、`missing_fields[]` 或目录错误字段。
- Room Information 卡的 `fields[].editable``review-resolution` pointer 校验必须共用同一套字段策略;只要详情接口返回 `editable=true``write_target=review_resolution.field_overrides`,同一个 pointer 就不得再因为白名单不一致返回 `V4_REVIEW_POINTER_NOT_ALLOWED`。如果仍被拒绝,应优先排查运行包是否包含 `M002-V4-review-pointer-runtime-fix`,后端日志 marker 为 `review_pointer_policy=m002_v4_review_pointer_runtime_fix_v1`
- 来源消息、路由、订单定位关系、诊断、缺失字段清单、`manual_review`、raw evidence 等只读字段不得提交。 - 来源消息、路由、订单定位关系、诊断、缺失字段清单、`manual_review`、raw evidence 等只读字段不得提交。
- 如果订单任务归属未解决,复核请求必须提交 `confirmed_order_id`;后端按当前订单任务酒店校验该订单存在、非逻辑删除且不是系统隐藏订单。 - 如果订单任务归属未解决,复核请求必须提交 `confirmed_order_id`;后端按当前订单任务酒店校验该订单存在、非逻辑删除且不是系统隐藏订单。
- 如果订单任务已经有 `order_id``target_resolution_status=RESOLVED`,复核请求不能提交不同的 `confirmed_order_id`,否则返回 `V4_ORDER_REBIND_NOT_ALLOWED`;普通任务任意切换订单继续后置。 - 如果订单任务已经有 `order_id``target_resolution_status=RESOLVED`,复核请求不能提交不同的 `confirmed_order_id`,否则返回 `V4_ORDER_REBIND_NOT_ALLOWED`;普通任务任意切换订单继续后置。

View File

@@ -47,6 +47,8 @@ import java.util.Locale;
import java.util.Map; import java.util.Map;
import java.util.Objects; import java.util.Objects;
import java.util.Set; import java.util.Set;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
@@ -57,6 +59,7 @@ import org.springframework.transaction.annotation.Transactional;
@Service @Service
public class ReservationV4CommandServiceImpl implements ReservationV4CommandService { public class ReservationV4CommandServiceImpl implements ReservationV4CommandService {
private static final Logger log = LoggerFactory.getLogger(ReservationV4CommandServiceImpl.class);
private static final String ACTOR_TYPE_USER = "USER"; 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_CARD_CONFIRM = "V4_CARD_CONFIRM";
private static final String ACTION_V4_CARD_REVIEW_RESOLVE = "V4_CARD_REVIEW_RESOLVE"; private static final String ACTION_V4_CARD_REVIEW_RESOLVE = "V4_CARD_REVIEW_RESOLVE";
@@ -110,16 +113,6 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
"rate_code", "rate_code",
"room_items", "room_items",
"trace_items"); "trace_items");
private static final Set<String> ROOM_INFORMATION_READONLY_FINAL_FIELDS = Set.of(
"adult",
"adults",
"adult_count",
"block_id",
"confirmation_number",
"group_booking_status_label",
"nights",
"target_order");
private final ReservationV4WorkflowRepository workflowRepository; private final ReservationV4WorkflowRepository workflowRepository;
private final ReservationV4SourceNotificationRepository sourceNotificationRepository; private final ReservationV4SourceNotificationRepository sourceNotificationRepository;
private final ReservationAiWorkflowRepository auditRepository; private final ReservationAiWorkflowRepository auditRepository;
@@ -692,42 +685,8 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
String bookingType, String bookingType,
List<String> path, List<String> path,
JsonNode finalValues) { JsonNode finalValues) {
if (path == null || path.isEmpty() || EVENT_CANCEL_BOOKING.equals(card.eventType())) { return ReservationV4RoomInformationFieldPolicy.isWritableFinalValuePath(
return false; card.eventType(), bookingType, path, finalValues);
}
if (path.stream().anyMatch(ROOM_INFORMATION_READONLY_FINAL_FIELDS::contains)) {
return false;
}
if (path.size() == 1) {
String fieldName = path.get(0);
if ("group_block_name".equals(fieldName)) {
return BOOKING_TYPE_GROUP.equals(bookingType) && EVENT_NEW_BOOKING.equals(card.eventType());
}
if ("fit_name".equals(fieldName)) {
return BOOKING_TYPE_FIT.equals(bookingType);
}
if ("arrival_date".equals(fieldName) || "departure_date".equals(fieldName)) {
return true;
}
if ("rate_code".equals(fieldName)) {
return EVENT_NEW_BOOKING.equals(card.eventType());
}
if ("breakfast_included".equals(fieldName)) {
JsonNode currentBreakfast = finalValues == null ? null : finalValues.path("breakfast_included");
return BOOKING_TYPE_FIT.equals(bookingType) && isMissingOrNull(currentBreakfast);
}
if ("group_booking_status".equals(fieldName)) {
return BOOKING_TYPE_GROUP.equals(bookingType);
}
if ("room_items".equals(fieldName)) {
return true;
}
return false;
}
if (path.size() == 3 && "room_items".equals(path.get(0)) && parseArrayIndex(path.get(1)) != null) {
return "room_type_code".equals(path.get(2)) || "room_count".equals(path.get(2));
}
return false;
} }
private void overlayEditableBusinessLeaves( private void overlayEditableBusinessLeaves(
@@ -1717,18 +1676,21 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
if (segments.stream().anyMatch(REVIEW_READONLY_ROOT_FIELDS::contains)) { if (segments.stream().anyMatch(REVIEW_READONLY_ROOT_FIELDS::contains)) {
throw error(HttpStatus.BAD_REQUEST, "V4_REVIEW_POINTER_READONLY", "该复核字段为只读字段,不允许修改。"); throw error(HttpStatus.BAD_REQUEST, "V4_REVIEW_POINTER_READONLY", "该复核字段为只读字段,不允许修改。");
} }
ensureReviewPointerInsideEditableContainer(card, confirmedPayload, segments); ensureReviewPointerInsideEditableContainer(card, confirmedPayload, pointer, segments);
if (value != null && value.isContainerNode()) { if (value != null && value.isContainerNode()) {
throw error(HttpStatus.BAD_REQUEST, "V4_REVIEW_VALUE_INVALID", "复核字段值必须是标量或 null不能替换对象或数组。"); throw error(HttpStatus.BAD_REQUEST, "V4_REVIEW_VALUE_INVALID", "复核字段值必须是标量或 null不能替换对象或数组。");
} }
JsonNode current = findPointerValue(confirmedPayload, segments); JsonNode current = findPointerValue(confirmedPayload, segments);
if (current == null || current.isMissingNode()) { if (current == null || current.isMissingNode()) {
logReviewPointerRejected(card, pointer, "CURRENT_POINTER_MISSING", confirmedPayload);
throw error(HttpStatus.BAD_REQUEST, "V4_REVIEW_POINTER_NOT_ALLOWED", "复核字段不在当前卡允许编辑字段内。"); throw error(HttpStatus.BAD_REQUEST, "V4_REVIEW_POINTER_NOT_ALLOWED", "复核字段不在当前卡允许编辑字段内。");
} }
if (current.isContainerNode()) { if (current.isContainerNode()) {
logReviewPointerRejected(card, pointer, "CURRENT_POINTER_CONTAINER", confirmedPayload);
throw error(HttpStatus.BAD_REQUEST, "V4_REVIEW_POINTER_NOT_ALLOWED", "复核字段只能指向当前卡允许编辑的叶子字段。"); throw error(HttpStatus.BAD_REQUEST, "V4_REVIEW_POINTER_NOT_ALLOWED", "复核字段只能指向当前卡允许编辑的叶子字段。");
} }
if (!isReviewPointerAllowedForResolution(card, confirmedPayload, pointer, current)) { if (!isReviewPointerAllowedForResolution(card, confirmedPayload, pointer, current)) {
logReviewPointerRejected(card, pointer, "POINTER_RULE_REJECTED", confirmedPayload);
throw error(HttpStatus.BAD_REQUEST, "V4_REVIEW_POINTER_NOT_ALLOWED", "复核字段不在当前卡允许编辑字段内。"); throw error(HttpStatus.BAD_REQUEST, "V4_REVIEW_POINTER_NOT_ALLOWED", "复核字段不在当前卡允许编辑字段内。");
} }
} }
@@ -1736,19 +1698,22 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
private void ensureReviewPointerInsideEditableContainer( private void ensureReviewPointerInsideEditableContainer(
ReservationV4TaskCardSnapshot card, ReservationV4TaskCardSnapshot card,
ObjectNode confirmedPayload, ObjectNode confirmedPayload,
String pointer,
List<String> segments) { List<String> segments) {
if (segments.size() < 2) { if (segments.size() < 2) {
logReviewPointerRejected(card, pointer, "POINTER_TOO_SHALLOW", confirmedPayload);
throw error(HttpStatus.BAD_REQUEST, "V4_REVIEW_POINTER_NOT_ALLOWED", "复核字段不在当前卡允许编辑字段内。"); throw error(HttpStatus.BAD_REQUEST, "V4_REVIEW_POINTER_NOT_ALLOWED", "复核字段不在当前卡允许编辑字段内。");
} }
String root = segments.get(0); String root = segments.get(0);
if (ReservationV4CardType.BASIC_INFORMATION.name().equals(card.cardType())) { if (ReservationV4CardType.BASIC_INFORMATION.name().equals(card.cardType())) {
if (!"basic_information".equals(root)) { if (!"basic_information".equals(root)) {
logReviewPointerRejected(card, pointer, "BASIC_INFORMATION_ROOT_MISMATCH", confirmedPayload);
throw error(HttpStatus.BAD_REQUEST, "V4_REVIEW_POINTER_NOT_ALLOWED", "复核字段不在当前 Basic Information 卡允许编辑字段内。"); throw error(HttpStatus.BAD_REQUEST, "V4_REVIEW_POINTER_NOT_ALLOWED", "复核字段不在当前 Basic Information 卡允许编辑字段内。");
} }
return; return;
} }
if ("room_information".equals(root) && isStableRoomInformationPayload(confirmedPayload)) { if ("room_information".equals(root) && isStableRoomInformationPayload(confirmedPayload)) {
ensureRoomInformationReviewPointerWritable(card, confirmedPayload, segments); ensureRoomInformationReviewPointerWritable(card, confirmedPayload, pointer, segments);
return; return;
} }
if ("business_fields".equals(root)) { if ("business_fields".equals(root)) {
@@ -1758,6 +1723,7 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
return; return;
} }
if (!"business_fields".equals(root)) { if (!"business_fields".equals(root)) {
logReviewPointerRejected(card, pointer, "BUSINESS_ROOT_NOT_WRITABLE", confirmedPayload);
throw error(HttpStatus.BAD_REQUEST, "V4_REVIEW_POINTER_NOT_ALLOWED", "复核字段不在当前业务卡允许编辑字段内。"); throw error(HttpStatus.BAD_REQUEST, "V4_REVIEW_POINTER_NOT_ALLOWED", "复核字段不在当前业务卡允许编辑字段内。");
} }
} }
@@ -1765,21 +1731,63 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
private void ensureRoomInformationReviewPointerWritable( private void ensureRoomInformationReviewPointerWritable(
ReservationV4TaskCardSnapshot card, ReservationV4TaskCardSnapshot card,
ObjectNode confirmedPayload, ObjectNode confirmedPayload,
String pointer,
List<String> segments) { List<String> segments) {
if (segments.size() < 3 || !"final_values".equals(segments.get(1))) { if (segments.size() < 3 || !"final_values".equals(segments.get(1))) {
logReviewPointerRejected(card, pointer, "ROOM_INFORMATION_NOT_FINAL_VALUES", confirmedPayload);
throw error(HttpStatus.BAD_REQUEST, "V4_REVIEW_POINTER_NOT_ALLOWED", "复核字段不在当前 Room Information 卡允许编辑字段内。"); throw error(HttpStatus.BAD_REQUEST, "V4_REVIEW_POINTER_NOT_ALLOWED", "复核字段不在当前 Room Information 卡允许编辑字段内。");
} }
List<String> finalValuePath = segments.subList(2, segments.size()); List<String> finalValuePath = segments.subList(2, segments.size());
if (finalValuePath.stream().anyMatch(ROOM_INFORMATION_READONLY_FINAL_FIELDS::contains)) { ReservationV4RoomInformationFieldPolicy.RoomInformationWriteDecision decision =
ReservationV4RoomInformationFieldPolicy.finalValueWriteDecision(
card.eventType(),
textAt(confirmedPayload.path("room_information"), "booking_type"),
finalValuePath,
confirmedPayload.path("room_information").path("final_values"));
if (decision.readonly()) {
logReviewPointerRejected(card, pointer, decision.reasonCode(), confirmedPayload);
throw error(HttpStatus.BAD_REQUEST, "V4_REVIEW_POINTER_READONLY", "该复核字段为只读字段,不允许修改。"); throw error(HttpStatus.BAD_REQUEST, "V4_REVIEW_POINTER_READONLY", "该复核字段为只读字段,不允许修改。");
} }
String bookingType = textAt(confirmedPayload.path("room_information"), "booking_type"); if (!decision.writable()) {
JsonNode finalValues = confirmedPayload.path("room_information").path("final_values"); logReviewPointerRejected(card, pointer, decision.reasonCode(), confirmedPayload);
if (!isRoomInformationPathWritable(card, bookingType, finalValuePath, finalValues)) {
throw error(HttpStatus.BAD_REQUEST, "V4_REVIEW_POINTER_NOT_ALLOWED", "复核字段不在当前 Room Information 卡允许编辑字段内。"); throw error(HttpStatus.BAD_REQUEST, "V4_REVIEW_POINTER_NOT_ALLOWED", "复核字段不在当前 Room Information 卡允许编辑字段内。");
} }
} }
private void logReviewPointerRejected(
ReservationV4TaskCardSnapshot card,
String pointer,
String reasonCode,
ObjectNode confirmedPayload) {
if (!log.isWarnEnabled()) {
return;
}
log.warn(
"V4 review pointer rejected. review_pointer_policy=m002_v4_review_pointer_runtime_fix_v1, "
+ "card_id={}, card_type={}, event_type={}, card_status={}, review_status={}, pointer={}, "
+ "reason_code={}, stable_room_information_payload={}",
card.id(),
card.cardType(),
card.eventType(),
card.cardStatus(),
card.reviewStatus(),
safeLogPointer(pointer),
reasonCode,
isStableRoomInformationPayload(confirmedPayload));
}
private String safeLogPointer(String pointer) {
if (pointer == null) {
return null;
}
String sanitized = pointer
.replace('\r', '_')
.replace('\n', '_')
.replace('\t', '_');
int maxLength = 256;
return sanitized.length() <= maxLength ? sanitized : sanitized.substring(0, maxLength) + "...";
}
private JsonNode findPointerValue(JsonNode root, List<String> segments) { private JsonNode findPointerValue(JsonNode root, List<String> segments) {
JsonNode current = root; JsonNode current = root;
for (String segment : segments) { for (String segment : segments) {

View File

@@ -1591,28 +1591,35 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService
} }
List<ReservationV4TaskCardFieldResult> fields = new ArrayList<>(); List<ReservationV4TaskCardFieldResult> fields = new ArrayList<>();
if (BOOKING_TYPE_GROUP.equals(bookingType) && EVENT_NEW_BOOKING.equals(eventType)) { if (BOOKING_TYPE_GROUP.equals(bookingType) && EVENT_NEW_BOOKING.equals(eventType)) {
addRoomInformationField(fields, "group_block_name", "Group Block Name", finalValues, validationErrors, availability, addRoomInformationField(fields, eventType, bookingType, "group_block_name", "Group Block Name",
finalValues, validationErrors, availability,
true, "text", null); true, "text", null);
} }
if (BOOKING_TYPE_FIT.equals(bookingType) && (EVENT_NEW_BOOKING.equals(eventType) || EVENT_UPDATE_BOOKING.equals(eventType))) { if (BOOKING_TYPE_FIT.equals(bookingType) && (EVENT_NEW_BOOKING.equals(eventType) || EVENT_UPDATE_BOOKING.equals(eventType))) {
addRoomInformationField(fields, "fit_name", "Fit Name", finalValues, validationErrors, availability, addRoomInformationField(fields, eventType, bookingType, "fit_name", "Fit Name",
finalValues, validationErrors, availability,
true, "text", null); true, "text", null);
} }
addRoomInformationField(fields, "arrival_date", "入住日期", finalValues, validationErrors, availability, addRoomInformationField(fields, eventType, bookingType, "arrival_date", "入住日期",
finalValues, validationErrors, availability,
true, "date", null); true, "date", null);
addRoomInformationField(fields, "departure_date", "离店日期", finalValues, validationErrors, availability, addRoomInformationField(fields, eventType, bookingType, "departure_date", "离店日期",
finalValues, validationErrors, availability,
true, "date", null); true, "date", null);
if (EVENT_NEW_BOOKING.equals(eventType)) { if (EVENT_NEW_BOOKING.equals(eventType)) {
addRoomInformationField(fields, "rate_code", "Rate Code", finalValues, validationErrors, availability, addRoomInformationField(fields, eventType, bookingType, "rate_code", "Rate Code",
finalValues, validationErrors, availability,
true, "select", "reservation_v4_rate_code_catalog"); true, "select", "reservation_v4_rate_code_catalog");
} }
addRoomItemsFields(fields, finalValues.path("room_items"), validationErrors, availability); addRoomItemsFields(fields, eventType, bookingType, finalValues, validationErrors, availability);
if (BOOKING_TYPE_FIT.equals(bookingType) && !finalValues.hasNonNull("breakfast_included")) { if (BOOKING_TYPE_FIT.equals(bookingType) && !finalValues.hasNonNull("breakfast_included")) {
addRoomInformationField(fields, "breakfast_included", "含早", finalValues, validationErrors, availability, addRoomInformationField(fields, eventType, bookingType, "breakfast_included", "含早",
finalValues, validationErrors, availability,
true, "checkbox", null); true, "checkbox", null);
} }
if (BOOKING_TYPE_GROUP.equals(bookingType)) { if (BOOKING_TYPE_GROUP.equals(bookingType)) {
addRoomInformationField(fields, "group_booking_status", "Group Booking Status", finalValues, validationErrors, availability, addRoomInformationField(fields, eventType, bookingType, "group_booking_status", "Group Booking Status",
finalValues, validationErrors, availability,
true, "select", "reservation_v4_group_booking_status_fixed"); true, "select", "reservation_v4_group_booking_status_fixed");
} }
return fields; return fields;
@@ -1620,9 +1627,12 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService
private void addRoomItemsFields( private void addRoomItemsFields(
List<ReservationV4TaskCardFieldResult> fields, List<ReservationV4TaskCardFieldResult> fields,
JsonNode roomItems, String eventType,
String bookingType,
JsonNode finalValues,
JsonNode validationErrors, JsonNode validationErrors,
ReservationV4ActionAvailabilityResult availability) { ReservationV4ActionAvailabilityResult availability) {
JsonNode roomItems = finalValues.path("room_items");
if (roomItems == null || !roomItems.isArray()) { if (roomItems == null || !roomItems.isArray()) {
return; return;
} }
@@ -1631,15 +1641,19 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService
if (item == null || !item.isObject()) { if (item == null || !item.isObject()) {
continue; continue;
} }
addRoomItemField(fields, index, "room_type_code", "房型代码", item, validationErrors, availability, addRoomItemField(fields, eventType, bookingType, index, "room_type_code", "房型代码",
item, finalValues, validationErrors, availability,
true, "select", "reservation_v4_room_type_catalog"); true, "select", "reservation_v4_room_type_catalog");
addRoomItemField(fields, index, "room_count", "房间数", item, validationErrors, availability, addRoomItemField(fields, eventType, bookingType, index, "room_count", "房间数",
item, finalValues, validationErrors, availability,
true, "number", null); true, "number", null);
} }
} }
private void addRoomInformationField( private void addRoomInformationField(
List<ReservationV4TaskCardFieldResult> fields, List<ReservationV4TaskCardFieldResult> fields,
String eventType,
String bookingType,
String fieldName, String fieldName,
String displayName, String displayName,
JsonNode finalValues, JsonNode finalValues,
@@ -1650,12 +1664,13 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService
String optionsSource) { String optionsSource) {
String pointer = "/room_information/final_values/" + escapeJsonPointer(fieldName); String pointer = "/room_information/final_values/" + escapeJsonPointer(fieldName);
String fieldPath = "room_information.final_values." + fieldName; String fieldPath = "room_information.final_values." + fieldName;
List<String> finalValuePath = List.of(fieldName);
fields.add(field( fields.add(field(
fieldPath, fieldPath,
pointer, pointer,
displayName, displayName,
finalValues.path(fieldName), finalValues.path(fieldName),
roomInformationFieldEditable(availability, validationErrors, pointer, finalValues.path(fieldName)), roomInformationFieldEditable(availability, eventType, bookingType, finalValuePath, finalValues),
required, required,
controlType, controlType,
availability.reviewable() ? "manual_review_only" : "confirm", availability.reviewable() ? "manual_review_only" : "confirm",
@@ -1668,10 +1683,13 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService
private void addRoomItemField( private void addRoomItemField(
List<ReservationV4TaskCardFieldResult> fields, List<ReservationV4TaskCardFieldResult> fields,
String eventType,
String bookingType,
int index, int index,
String fieldName, String fieldName,
String displayName, String displayName,
JsonNode roomItem, JsonNode roomItem,
JsonNode finalValues,
JsonNode validationErrors, JsonNode validationErrors,
ReservationV4ActionAvailabilityResult availability, ReservationV4ActionAvailabilityResult availability,
boolean required, boolean required,
@@ -1679,12 +1697,13 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService
String optionsSource) { String optionsSource) {
String pointer = "/room_information/final_values/room_items/" + index + "/" + escapeJsonPointer(fieldName); String pointer = "/room_information/final_values/room_items/" + index + "/" + escapeJsonPointer(fieldName);
String fieldPath = "room_information.final_values.room_items." + index + "." + fieldName; String fieldPath = "room_information.final_values.room_items." + index + "." + fieldName;
List<String> finalValuePath = List.of("room_items", String.valueOf(index), fieldName);
fields.add(field( fields.add(field(
fieldPath, fieldPath,
pointer, pointer,
displayName, displayName,
roomItem.path(fieldName), roomItem.path(fieldName),
roomInformationFieldEditable(availability, validationErrors, pointer, roomItem.path(fieldName)), roomInformationFieldEditable(availability, eventType, bookingType, finalValuePath, finalValues),
required, required,
controlType, controlType,
availability.reviewable() ? "manual_review_only" : "confirm", availability.reviewable() ? "manual_review_only" : "confirm",
@@ -1697,13 +1716,15 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService
private boolean roomInformationFieldEditable( private boolean roomInformationFieldEditable(
ReservationV4ActionAvailabilityResult availability, ReservationV4ActionAvailabilityResult availability,
JsonNode validationErrors, String eventType,
String pointer, String bookingType,
JsonNode currentValue) { List<String> finalValuePath,
JsonNode finalValues) {
if (!availability.editable()) { if (!availability.editable()) {
return false; return false;
} }
return true; return ReservationV4RoomInformationFieldPolicy.isWritableFinalValuePath(
eventType, bookingType, finalValuePath, finalValues);
} }
private Set<String> validationErrorPointers(JsonNode validationErrors) { private Set<String> validationErrorPointers(JsonNode validationErrors) {

View File

@@ -0,0 +1,144 @@
package cn.nianxx.thhotel.workflows.reservation.service.impl;
import com.fasterxml.jackson.databind.JsonNode;
import java.util.List;
import java.util.Set;
/**
* Reservation V4 Room Information 字段写入策略。
* 查询侧 fields[] 和命令侧确认 / 复核共用本类,避免前端看到可编辑但后端拒绝同一 pointer。
*/
final class ReservationV4RoomInformationFieldPolicy {
private static final String EVENT_NEW_BOOKING = "NEW_BOOKING";
private static final String EVENT_CANCEL_BOOKING = "CANCEL_BOOKING";
private static final String BOOKING_TYPE_GROUP = "GROUP";
private static final String BOOKING_TYPE_FIT = "FIT";
private static final Set<String> READONLY_FINAL_FIELDS = Set.of(
"adult",
"adults",
"adult_count",
"block_id",
"confirmation_number",
"group_booking_status_label",
"nights",
"target_order");
private ReservationV4RoomInformationFieldPolicy() {
}
/**
* 判断 Room Information final_values 下的路径是否允许用户写入。
*/
static boolean isWritableFinalValuePath(
String eventType,
String bookingType,
List<String> path,
JsonNode finalValues) {
return finalValueWriteDecision(eventType, bookingType, path, finalValues).writable();
}
/**
* 返回 Room Information final_values 写入决策,用于运行时诊断日志。
*/
static RoomInformationWriteDecision finalValueWriteDecision(
String eventType,
String bookingType,
List<String> path,
JsonNode finalValues) {
if (path == null || path.isEmpty()) {
return new RoomInformationWriteDecision(false, false, "PATH_EMPTY");
}
if (EVENT_CANCEL_BOOKING.equals(eventType)) {
return new RoomInformationWriteDecision(false, false, "CANCEL_BOOKING_READONLY");
}
if (isReadonlyFinalValuePath(path)) {
return new RoomInformationWriteDecision(false, true, "DERIVED_OR_SYSTEM_FIELD_READONLY");
}
if (path.size() == 1) {
return topLevelWriteDecision(eventType, bookingType, path.get(0), finalValues);
}
if (path.size() == 3 && "room_items".equals(path.get(0)) && parseArrayIndex(path.get(1)) != null) {
boolean writable = "room_type_code".equals(path.get(2)) || "room_count".equals(path.get(2));
return new RoomInformationWriteDecision(writable, false,
writable ? "ALLOWED" : "ROOM_ITEM_FIELD_NOT_WRITABLE");
}
return new RoomInformationWriteDecision(false, false, "PATH_NOT_IN_ROOM_INFORMATION_WHITELIST");
}
/**
* 判断路径是否命中 Room Information 系统派生或只读字段。
*/
static boolean isReadonlyFinalValuePath(List<String> path) {
return path != null && path.stream().anyMatch(READONLY_FINAL_FIELDS::contains);
}
private static RoomInformationWriteDecision topLevelWriteDecision(
String eventType,
String bookingType,
String fieldName,
JsonNode finalValues) {
if ("group_block_name".equals(fieldName)) {
boolean writable = BOOKING_TYPE_GROUP.equals(bookingType) && EVENT_NEW_BOOKING.equals(eventType);
return new RoomInformationWriteDecision(writable, false,
writable ? "ALLOWED" : "GROUP_BLOCK_NAME_ONLY_NEW_GROUP");
}
if ("fit_name".equals(fieldName)) {
boolean writable = BOOKING_TYPE_FIT.equals(bookingType);
return new RoomInformationWriteDecision(writable, false,
writable ? "ALLOWED" : "FIT_NAME_ONLY_FIT");
}
if ("arrival_date".equals(fieldName) || "departure_date".equals(fieldName)) {
return new RoomInformationWriteDecision(true, false, "ALLOWED");
}
if ("rate_code".equals(fieldName)) {
boolean writable = EVENT_NEW_BOOKING.equals(eventType);
return new RoomInformationWriteDecision(writable, false,
writable ? "ALLOWED" : "RATE_CODE_ONLY_NEW_BOOKING");
}
if ("breakfast_included".equals(fieldName)) {
JsonNode currentBreakfast = finalValues == null ? null : finalValues.path("breakfast_included");
boolean writable = BOOKING_TYPE_FIT.equals(bookingType) && isMissingOrNull(currentBreakfast);
return new RoomInformationWriteDecision(writable, false,
writable ? "ALLOWED" : "BREAKFAST_ONLY_FIT_WHEN_NOT_DERIVED");
}
if ("group_booking_status".equals(fieldName)) {
boolean writable = BOOKING_TYPE_GROUP.equals(bookingType);
return new RoomInformationWriteDecision(writable, false,
writable ? "ALLOWED" : "GROUP_BOOKING_STATUS_ONLY_GROUP");
}
if ("room_items".equals(fieldName)) {
return new RoomInformationWriteDecision(true, false, "ALLOWED");
}
return new RoomInformationWriteDecision(false, false, "TOP_LEVEL_FIELD_NOT_WRITABLE");
}
private static boolean isMissingOrNull(JsonNode node) {
return node == null || node.isMissingNode() || node.isNull();
}
private static Integer parseArrayIndex(String segment) {
if (segment == null || segment.isBlank()) {
return null;
}
if (segment.length() > 1 && segment.startsWith("0")) {
return null;
}
for (int i = 0; i < segment.length(); i++) {
if (!Character.isDigit(segment.charAt(i))) {
return null;
}
}
try {
return Integer.parseInt(segment);
} catch (NumberFormatException exception) {
return null;
}
}
/**
* Room Information 字段写入决策reasonCode 只用于安全日志和排查,不作为前端契约字段。
*/
record RoomInformationWriteDecision(boolean writable, boolean readonly, String reasonCode) {
}
}

View File

@@ -34,6 +34,10 @@ import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationV4TargetR
import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationReviewStatus; import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationReviewStatus;
import cn.nianxx.thhotel.workflows.reservation.repository.ReservationV4SourceNotificationRepository; import cn.nianxx.thhotel.workflows.reservation.repository.ReservationV4SourceNotificationRepository;
import cn.nianxx.thhotel.workflows.reservation.repository.ReservationV4WorkflowRepository; import cn.nianxx.thhotel.workflows.reservation.repository.ReservationV4WorkflowRepository;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import java.time.Instant; import java.time.Instant;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.ZoneOffset; import java.time.ZoneOffset;
@@ -47,6 +51,7 @@ import org.springframework.http.MediaType;
import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.context.ActiveProfiles; import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
@SpringBootTest( @SpringBootTest(
classes = ThHotelApplication.class, classes = ThHotelApplication.class,
@@ -91,6 +96,9 @@ class ReservationV4CommandControllerTest {
@Autowired @Autowired
private JdbcTemplate jdbcTemplate; private JdbcTemplate jdbcTemplate;
@Autowired
private ObjectMapper objectMapper;
private String adminToken; private String adminToken;
private String noPermissionToken; private String noPermissionToken;
@@ -1091,6 +1099,86 @@ class ReservationV4CommandControllerTest {
.value("/room_information/final_values/room_items/0/room_type_code")); .value("/room_information/final_values/room_items/0/room_type_code"));
} }
@Test
void shouldAcceptEditableReviewFieldPointersReturnedByTaskDetail() throws Exception {
SeededOrderTask seeded = seedReviewOrderTaskWithBusinessCard(
HOTEL_ID,
"mail-v4-command-review-pointer-runtime-001",
Instant.parse("2026-07-19T01:22:18Z"),
990000000000070116L,
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-RUNTIME-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);
MvcResult detailResult = performAuthorized(mockMvc, adminToken(),
get("/api/reservation/order-tasks/{orderTaskId}", seeded.orderTask().id())
.param("hotel_id", HOTEL_ID))
.andExpect(status().isOk())
.andReturn();
JsonNode detail = objectMapper.readTree(detailResult.getResponse().getContentAsString());
ArrayNode overrides = objectMapper.createArrayNode();
for (JsonNode field : detail.path("business_cards").get(0).path("fields")) {
if (!field.path("editable").asBoolean(false)
|| !"review_resolution.field_overrides".equals(field.path("write_target").asText())) {
continue;
}
String pointer = field.path("field_pointer").asText();
ObjectNode override = objectMapper.createObjectNode();
override.put("field_pointer", pointer);
override.set("value", reviewOverrideValue(pointer));
overrides.add(override);
}
org.assertj.core.api.Assertions.assertThat(overrides)
.as("任务详情返回的可复核字段必须能直接提交给 review-resolution")
.isNotEmpty();
org.assertj.core.api.Assertions.assertThat(overrides.toString())
.contains("/room_information/final_values/room_items/0/room_type_code");
ObjectNode request = objectMapper.createObjectNode();
request.put("version", 0);
request.put("reason", "回归验证详情页可编辑 pointer 与命令侧白名单一致");
request.set("field_overrides", 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(objectMapper.writeValueAsString(request)))
.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"));
}
@Test @Test
void shouldResolveRoomInformationReviewWhenDisplayPayloadAlreadyUsesStableModel() throws Exception { void shouldResolveRoomInformationReviewWhenDisplayPayloadAlreadyUsesStableModel() throws Exception {
SeededOrderTask seeded = seedReviewOrderTaskWithBusinessCard( SeededOrderTask seeded = seedReviewOrderTaskWithBusinessCard(
@@ -1770,6 +1858,34 @@ class ReservationV4CommandControllerTest {
.andExpect(status().isOk()); .andExpect(status().isOk());
} }
private JsonNode reviewOverrideValue(String pointer) {
if (pointer.endsWith("/group_block_name")) {
return objectMapper.getNodeFactory().textNode("GRP-V4-REVIEW-RUNTIME-001");
}
if (pointer.endsWith("/arrival_date")) {
return objectMapper.getNodeFactory().textNode("2026-08-01");
}
if (pointer.endsWith("/departure_date")) {
return objectMapper.getNodeFactory().textNode("2026-08-03");
}
if (pointer.endsWith("/rate_code")) {
return objectMapper.getNodeFactory().textNode("GROUP");
}
if (pointer.endsWith("/room_type_code")) {
return objectMapper.getNodeFactory().textNode("RM2");
}
if (pointer.endsWith("/room_count")) {
return objectMapper.getNodeFactory().numberNode(2);
}
if (pointer.endsWith("/group_booking_status")) {
return objectMapper.getNodeFactory().textNode("TEN");
}
if (pointer.endsWith("/breakfast_included")) {
return objectMapper.getNodeFactory().booleanNode(true);
}
return objectMapper.getNodeFactory().textNode("UPDATED");
}
private void confirmRoomInformationBusinessCard(SeededOrderTask seeded, String groupBookingStatus) throws Exception { private void confirmRoomInformationBusinessCard(SeededOrderTask seeded, String groupBookingStatus) throws Exception {
String groupBookingStatusFragment = groupBookingStatus == null String groupBookingStatusFragment = groupBookingStatus == null
? "" ? ""