diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index 01d8996..bd32c33 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 后端联动、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 目录供给方案。 | +| 当前阶段 | 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 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` 这类安全语义,不暴露内部列名;查询侧 `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 -- 名称:`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 原始响应。 +- 名称:`M002-V4-review-pointer-runtime-fix` +- 状态: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 原始响应。 - 边界:本 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. 当前优先级 diff --git a/docs/project/frontend-backend/backend-to-frontend-notes.md b/docs/project/frontend-backend/backend-to-frontend-notes.md index 5d07967..08346b8 100644 --- a/docs/project/frontend-backend/backend-to-frontend-notes.md +++ b/docs/project/frontend-backend/backend-to-frontend-notes.md @@ -542,7 +542,7 @@ RESERVATION_ROOMING_LIST_GENERATE - 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[]`。 - `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 Code,Account 改变后清空或重新校验已选 Rate Code;缺失条件时禁用或空态,不硬编码 OWNER RATE Excel。`keyword` 查不到只表示当前筛选无结果,不能仅凭 `items=[]` 判断目录未初始化,应结合 `catalog_source`、`catalog_version` 和 `warnings[]`。 - M002 V4 CP12 前端已接入上述三个 lookup API:V4 多卡详情页会按当前卡 `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 多卡最终接口。 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 84b7eba..1709452 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 @@ -174,6 +174,7 @@ V4 新数据不再提供后端草稿保存。前端可以在页面本地维护 - CP11 起确认和复核都会校验当前酒店数据库目录字段;Basic Information 的 `account_code` 必须来自当前酒店 ACTIVE Account 目录,通过后后端派生 `market_code` / `source_code`。 - 通过校验后卡片直接进入 `CONFIRMED`,不再进入 V3 `READY` 状态。 - `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 等只读字段不得提交。 - 如果订单任务归属未解决,复核请求必须提交 `confirmed_order_id`;后端按当前订单任务酒店校验该订单存在、非逻辑删除且不是系统隐藏订单。 - 如果订单任务已经有 `order_id` 且 `target_resolution_status=RESOLVED`,复核请求不能提交不同的 `confirmed_order_id`,否则返回 `V4_ORDER_REBIND_NOT_ALLOWED`;普通任务任意切换订单继续后置。 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 2e3f028..0a0e5f6 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 @@ -47,6 +47,8 @@ import java.util.Locale; import java.util.Map; import java.util.Objects; import java.util.Set; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -57,6 +59,7 @@ import org.springframework.transaction.annotation.Transactional; @Service 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 ACTION_V4_CARD_CONFIRM = "V4_CARD_CONFIRM"; private static final String ACTION_V4_CARD_REVIEW_RESOLVE = "V4_CARD_REVIEW_RESOLVE"; @@ -110,16 +113,6 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ "rate_code", "room_items", "trace_items"); - private static final Set 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 ReservationV4SourceNotificationRepository sourceNotificationRepository; private final ReservationAiWorkflowRepository auditRepository; @@ -692,42 +685,8 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ String bookingType, List path, JsonNode finalValues) { - if (path == null || path.isEmpty() || EVENT_CANCEL_BOOKING.equals(card.eventType())) { - return false; - } - 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; + return ReservationV4RoomInformationFieldPolicy.isWritableFinalValuePath( + card.eventType(), bookingType, path, finalValues); } private void overlayEditableBusinessLeaves( @@ -1717,18 +1676,21 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ if (segments.stream().anyMatch(REVIEW_READONLY_ROOT_FIELDS::contains)) { throw error(HttpStatus.BAD_REQUEST, "V4_REVIEW_POINTER_READONLY", "该复核字段为只读字段,不允许修改。"); } - ensureReviewPointerInsideEditableContainer(card, confirmedPayload, segments); + ensureReviewPointerInsideEditableContainer(card, confirmedPayload, pointer, segments); if (value != null && value.isContainerNode()) { throw error(HttpStatus.BAD_REQUEST, "V4_REVIEW_VALUE_INVALID", "复核字段值必须是标量或 null,不能替换对象或数组。"); } JsonNode current = findPointerValue(confirmedPayload, segments); if (current == null || current.isMissingNode()) { + logReviewPointerRejected(card, pointer, "CURRENT_POINTER_MISSING", confirmedPayload); throw error(HttpStatus.BAD_REQUEST, "V4_REVIEW_POINTER_NOT_ALLOWED", "复核字段不在当前卡允许编辑字段内。"); } if (current.isContainerNode()) { + logReviewPointerRejected(card, pointer, "CURRENT_POINTER_CONTAINER", confirmedPayload); throw error(HttpStatus.BAD_REQUEST, "V4_REVIEW_POINTER_NOT_ALLOWED", "复核字段只能指向当前卡允许编辑的叶子字段。"); } if (!isReviewPointerAllowedForResolution(card, confirmedPayload, pointer, current)) { + logReviewPointerRejected(card, pointer, "POINTER_RULE_REJECTED", confirmedPayload); throw error(HttpStatus.BAD_REQUEST, "V4_REVIEW_POINTER_NOT_ALLOWED", "复核字段不在当前卡允许编辑字段内。"); } } @@ -1736,19 +1698,22 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ private void ensureReviewPointerInsideEditableContainer( ReservationV4TaskCardSnapshot card, ObjectNode confirmedPayload, + String pointer, List segments) { if (segments.size() < 2) { + logReviewPointerRejected(card, pointer, "POINTER_TOO_SHALLOW", confirmedPayload); throw error(HttpStatus.BAD_REQUEST, "V4_REVIEW_POINTER_NOT_ALLOWED", "复核字段不在当前卡允许编辑字段内。"); } String root = segments.get(0); if (ReservationV4CardType.BASIC_INFORMATION.name().equals(card.cardType())) { 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 卡允许编辑字段内。"); } return; } if ("room_information".equals(root) && isStableRoomInformationPayload(confirmedPayload)) { - ensureRoomInformationReviewPointerWritable(card, confirmedPayload, segments); + ensureRoomInformationReviewPointerWritable(card, confirmedPayload, pointer, segments); return; } if ("business_fields".equals(root)) { @@ -1758,6 +1723,7 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ return; } if (!"business_fields".equals(root)) { + logReviewPointerRejected(card, pointer, "BUSINESS_ROOT_NOT_WRITABLE", confirmedPayload); throw error(HttpStatus.BAD_REQUEST, "V4_REVIEW_POINTER_NOT_ALLOWED", "复核字段不在当前业务卡允许编辑字段内。"); } } @@ -1765,21 +1731,63 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ private void ensureRoomInformationReviewPointerWritable( ReservationV4TaskCardSnapshot card, ObjectNode confirmedPayload, + String pointer, List segments) { 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 卡允许编辑字段内。"); } List 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", "该复核字段为只读字段,不允许修改。"); } - String bookingType = textAt(confirmedPayload.path("room_information"), "booking_type"); - JsonNode finalValues = confirmedPayload.path("room_information").path("final_values"); - if (!isRoomInformationPathWritable(card, bookingType, finalValuePath, finalValues)) { + if (!decision.writable()) { + logReviewPointerRejected(card, pointer, decision.reasonCode(), confirmedPayload); 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 segments) { JsonNode current = root; for (String segment : segments) { 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 8ccfaff..a547e0d 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 @@ -1591,28 +1591,35 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService } List fields = new ArrayList<>(); 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); } 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); } - addRoomInformationField(fields, "arrival_date", "入住日期", finalValues, validationErrors, availability, + addRoomInformationField(fields, eventType, bookingType, "arrival_date", "入住日期", + finalValues, validationErrors, availability, true, "date", null); - addRoomInformationField(fields, "departure_date", "离店日期", finalValues, validationErrors, availability, + addRoomInformationField(fields, eventType, bookingType, "departure_date", "离店日期", + finalValues, validationErrors, availability, true, "date", null); 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"); } - 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")) { - addRoomInformationField(fields, "breakfast_included", "含早", finalValues, validationErrors, availability, + addRoomInformationField(fields, eventType, bookingType, "breakfast_included", "含早", + finalValues, validationErrors, availability, true, "checkbox", null); } 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"); } return fields; @@ -1620,9 +1627,12 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService private void addRoomItemsFields( List fields, - JsonNode roomItems, + String eventType, + String bookingType, + JsonNode finalValues, JsonNode validationErrors, ReservationV4ActionAvailabilityResult availability) { + JsonNode roomItems = finalValues.path("room_items"); if (roomItems == null || !roomItems.isArray()) { return; } @@ -1631,15 +1641,19 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService if (item == null || !item.isObject()) { 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"); - addRoomItemField(fields, index, "room_count", "房间数", item, validationErrors, availability, + addRoomItemField(fields, eventType, bookingType, index, "room_count", "房间数", + item, finalValues, validationErrors, availability, true, "number", null); } } private void addRoomInformationField( List fields, + String eventType, + String bookingType, String fieldName, String displayName, JsonNode finalValues, @@ -1650,12 +1664,13 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService String optionsSource) { String pointer = "/room_information/final_values/" + escapeJsonPointer(fieldName); String fieldPath = "room_information.final_values." + fieldName; + List finalValuePath = List.of(fieldName); fields.add(field( fieldPath, pointer, displayName, finalValues.path(fieldName), - roomInformationFieldEditable(availability, validationErrors, pointer, finalValues.path(fieldName)), + roomInformationFieldEditable(availability, eventType, bookingType, finalValuePath, finalValues), required, controlType, availability.reviewable() ? "manual_review_only" : "confirm", @@ -1668,10 +1683,13 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService private void addRoomItemField( List fields, + String eventType, + String bookingType, int index, String fieldName, String displayName, JsonNode roomItem, + JsonNode finalValues, JsonNode validationErrors, ReservationV4ActionAvailabilityResult availability, boolean required, @@ -1679,12 +1697,13 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService String optionsSource) { String pointer = "/room_information/final_values/room_items/" + index + "/" + escapeJsonPointer(fieldName); String fieldPath = "room_information.final_values.room_items." + index + "." + fieldName; + List finalValuePath = List.of("room_items", String.valueOf(index), fieldName); fields.add(field( fieldPath, pointer, displayName, roomItem.path(fieldName), - roomInformationFieldEditable(availability, validationErrors, pointer, roomItem.path(fieldName)), + roomInformationFieldEditable(availability, eventType, bookingType, finalValuePath, finalValues), required, controlType, availability.reviewable() ? "manual_review_only" : "confirm", @@ -1697,13 +1716,15 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService private boolean roomInformationFieldEditable( ReservationV4ActionAvailabilityResult availability, - JsonNode validationErrors, - String pointer, - JsonNode currentValue) { + String eventType, + String bookingType, + List finalValuePath, + JsonNode finalValues) { if (!availability.editable()) { return false; } - return true; + return ReservationV4RoomInformationFieldPolicy.isWritableFinalValuePath( + eventType, bookingType, finalValuePath, finalValues); } private Set validationErrorPointers(JsonNode validationErrors) { diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationV4RoomInformationFieldPolicy.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationV4RoomInformationFieldPolicy.java new file mode 100644 index 0000000..f80dcab --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationV4RoomInformationFieldPolicy.java @@ -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 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 path, + JsonNode finalValues) { + return finalValueWriteDecision(eventType, bookingType, path, finalValues).writable(); + } + + /** + * 返回 Room Information final_values 写入决策,用于运行时诊断日志。 + */ + static RoomInformationWriteDecision finalValueWriteDecision( + String eventType, + String bookingType, + List 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 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) { + } +} 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 7d6a2e6..d194f69 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 @@ -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.repository.ReservationV4SourceNotificationRepository; 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.LocalDateTime; import java.time.ZoneOffset; @@ -47,6 +51,7 @@ import org.springframework.http.MediaType; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; @SpringBootTest( classes = ThHotelApplication.class, @@ -91,6 +96,9 @@ class ReservationV4CommandControllerTest { @Autowired private JdbcTemplate jdbcTemplate; + @Autowired + private ObjectMapper objectMapper; + private String adminToken; private String noPermissionToken; @@ -1091,6 +1099,86 @@ class ReservationV4CommandControllerTest { .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 void shouldResolveRoomInformationReviewWhenDisplayPayloadAlreadyUsesStableModel() throws Exception { SeededOrderTask seeded = seedReviewOrderTaskWithBusinessCard( @@ -1770,6 +1858,34 @@ class ReservationV4CommandControllerTest { .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 { String groupBookingStatusFragment = groupBookingStatus == null ? ""