diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index 7e1ebe0..87ce7a0 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -5,12 +5,12 @@ | 最近更新 | 2026-07-19 | | 当前分支 | `feature/huangting` | | 当前阶段 | M002 V4 入站、多卡模型、持久化基线、入站写入、查询接口、卡片确认、复核解阻和目录校验并行 | -| 当前重点 | M002 V4 CP8 已开放固定种子目录校验和 V4 卡片 fields[] 字段白名单;后续准备做 V4 前端页面或真实目录 / PMS lookup | +| 当前重点 | M002 V4 CP8 已开放固定种子目录校验、V4 卡片 fields[] 字段白名单、确认写入白名单收口和嵌套目录校验;后续准备做 V4 前端页面或真实目录 / PMS lookup | ## 1. 当前 Checkpoint - 名称:`M002-V4-CP8-controlled-directory-and-card-fields-whitelist-v1` -- 状态:Done,已实现 V4 Account 固定目录校验、Market / Source 派生、Room Type / Rate Code 第一版固定种子校验和 V4 卡片 `fields[]` 字段白名单。 +- 状态:Done,已实现 V4 Account 固定目录校验、Market / Source 派生、Room Type / Rate Code 第一版固定种子校验、V4 卡片 `fields[]` 字段白名单、确认写入白名单收口和嵌套业务字段目录校验。 - 目标:让 V4 入站、确认和复核写操作具备第一版目录边界,并让前端从 `GET /api/reservation/order-tasks/{orderTaskId}` 的卡片 `fields[]` 获取可展示 / 可编辑字段。 - 边界:CP8 不实现真实 PMS 目录、Rate Code 配置中心、通用 lookup API、前端页面、普通任务任意切换订单、真实 OPERA / OHIP 和历史数据迁移。 @@ -34,7 +34,7 @@ - `docs/import/` 下按日期导入的资料是输入材料,不等同于当前权威开发契约;当前开发应优先看 `docs/project/README.md` 标记为当前有效或权威契约的文档。 - 后续每完成一个 Feature 或 Checkpoint,需要更新本文件,避免项目状态继续沉淀在聊天记录里。 - M010 Rooming List Excel 生成后端 CP1 和前端 V1 已实现:前端 `/reservation/rooming-lists/new` 上传来源名单和手工字段,后端同步生成 `.xlsx` 直接下载,第一版不落库、不上传 OSS。 -- M002 V4 CP1 当前已完成入站解析和现有任务链路过渡适配;M002 V4 CP2 已完成订单任务与多卡领域模型设计;M002 V4 CP3 已完成 V4 订单任务、多卡和 S10/S99 来源通知表结构与 Repository 基线;M002 V4 CP4 已完成入站写入新模型;M002 V4 CP5 已完成前端查询接口;M002 V4 CP6 已完成普通卡片确认和 S10/S99 来源通知 ack;M002 V4 CP7 已完成 `REVIEW_REQUIRED` 卡复核解阻和复核场景订单归属确认;M002 V4 CP8 已完成固定种子目录校验和 V4 卡片 `fields[]` 字段白名单。V4 前端页面仍未完成代码实现。 +- M002 V4 CP1 当前已完成入站解析和现有任务链路过渡适配;M002 V4 CP2 已完成订单任务与多卡领域模型设计;M002 V4 CP3 已完成 V4 订单任务、多卡和 S10/S99 来源通知表结构与 Repository 基线;M002 V4 CP4 已完成入站写入新模型;M002 V4 CP5 已完成前端查询接口;M002 V4 CP6 已完成普通卡片确认和 S10/S99 来源通知 ack;M002 V4 CP7 已完成 `REVIEW_REQUIRED` 卡复核解阻和复核场景订单归属确认;M002 V4 CP8 已完成固定种子目录校验、V4 卡片 `fields[]` 字段白名单、确认写入白名单收口和嵌套业务字段目录校验。V4 前端页面仍未完成代码实现。 - M002 V4 CP2 已确认:V4 工作台统一列表草案为 `/api/reservation/workbench-items`,业务订单任务接口新开 `/api/reservation/order-tasks/**`,S10/S99 来源通知详情草案为 `/api/reservation/source-notifications/{notificationId}`;S10/S99 使用来源通知模型,不再挂隐藏技术订单;`FIT + BOOKING_CODE` 不建 ACTIVE 唯一约束,匹配多条进人工复核;Basic Information 必须先确认;Account / Market / Source 第一版使用固定种子数据;旧 V2/V3 任务详情和草稿确认接口后续可逐步废弃。 ## 5. Next Steps diff --git a/docs/project/frontend-backend/backend-to-frontend-notes.md b/docs/project/frontend-backend/backend-to-frontend-notes.md index 352d410..5da7c1e 100644 --- a/docs/project/frontend-backend/backend-to-frontend-notes.md +++ b/docs/project/frontend-backend/backend-to-frontend-notes.md @@ -58,7 +58,7 @@ | `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 会话接口。CP8 起每张 V4 任务卡返回 `fields[]`,前端应以该字段白名单渲染可编辑控件。 | | `GET /api/reservation/source-notifications/{notificationId}` | 查询 V4 S10/S99 来源通知详情 | 必须带 Bearer token,需要 `RESERVATION_TASK_READ`,后端按来源通知实际酒店校验访问权;只返回通知摘要、来源邮件通知卡、会话摘要和 `availability`;不返回订单任务、业务卡、邮件正文、附件 URL 或原始 AI payload。 | -| `POST /api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/confirm` | 确认 V4 订单任务卡 | 必须带 Bearer token,需要 `RESERVATION_TASK_CONFIRM`,请求 JSON 带 `version`,可选 `confirmed_payload`;Basic Information 必须先确认,业务卡第一版不强制逐张顺序确认;确认前会做 Account / Room Type / Rate Code 第一版目录校验,失败返回 `V4_FIELD_VALIDATION_FAILED`;确认后卡片 `CONFIRMED`、写 `confirmed_payload_json/confirmed_at/confirmed_by` 并锁定,重复确认返回错误;成功返回刷新后的订单任务详情。 | +| `POST /api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/confirm` | 确认 V4 订单任务卡 | 必须带 Bearer token,需要 `RESERVATION_TASK_CONFIRM`,请求 JSON 带 `version`,可选 `confirmed_payload`;Basic Information 必须先确认,业务卡第一版不强制逐张顺序确认;前端只提交当前卡 `fields[]` 中可编辑字段,后端以展示快照为基准合并,未开放字段会被忽略;确认前会做 Account / Room Type / Rate Code 第一版目录校验,嵌套字段错误会返回如 `business_fields.after.room_items.0.room_type_code` 的路径,失败返回 `V4_FIELD_VALIDATION_FAILED`;确认后卡片 `CONFIRMED`、写 `confirmed_payload_json/confirmed_at/confirmed_by` 并锁定,重复确认返回错误;成功返回刷新后的订单任务详情。 | | `POST /api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/review-resolution` | V4 复核解阻并确认卡片 | 必须带 Bearer token,需要 `RESERVATION_MANUAL_REVIEW_RESOLVE`,仅用于 `card_status=REVIEW_REQUIRED`;请求 JSON 带 `version`,可选 `field_overrides[]` 和 `reason`;订单任务归属未解决时 `confirmed_order_id` 必填,且必须是当前酒店下真实可见订单;目录错误字段可按 `validation_errors_json` / `fields[].validation_errors` 指向的 pointer 修正;成功后卡片 `CONFIRMED`、`review_status=RESOLVED`,写 `review_resolution_json/confirmed_payload_json/confirmed_at/confirmed_by` 并返回刷新后的订单任务详情。 | | `POST /api/reservation/source-notifications/{notificationId}/ack` | 确认 V4 S10/S99 来源通知已读 / 已处理 | 必须带 Bearer token,需要 `RESERVATION_TASK_CONFIRM`,请求 JSON 带 `version`;仅允许 `route_code=S10/S99`;确认后 `notification_status=ACKED`,写 `ack_by/ack_at`,成功返回刷新后的来源通知详情;重复 ack 返回当前已确认状态且不新增审计;该动作不创建订单、不参与订单阻塞。 | | `GET /api/reservation/orders/{orderId}` | 查询订单详情与任务时间线 | 必须带 Bearer token,需要 `RESERVATION_ORDER_READ`,后端按订单所属酒店做访问校验;`include_tasks=false` 可只取订单摘要;时间线按后端队列顺序返回,前端不要自行按创建时间重排;`tasks[]` 已返回来源邮件会话摘要字段和 V3 路由字段;隐藏技术订单详情不可作为普通订单页打开。 | @@ -473,6 +473,8 @@ RESERVATION_ROOMING_LIST_GENERATE - M002 V4 CP3 已新增 V4 订单任务、任务卡、S10/S99 来源通知三张表和 Repository 基线;M002 V4 CP4 已把正式 V4 回调写入这些表;M002 V4 CP5 已开放查询;M002 V4 CP6 已开放普通卡片确认和 S10/S99 ack;M002 V4 CP7 已开放复核解阻与复核场景订单归属确认;M002 V4 CP8 已开放固定种子目录校验和 V4 任务卡 `fields[]` 字段白名单。 - V4 订单任务和卡片 `availability` 已新增 `reviewable`。当前语义:`REVIEW_REQUIRED` 卡如果未被 Basic Information 或前置订单任务阻塞,会返回 `read_only=false`、`editable=true`、`confirmable=false`、`reviewable=true`、`readonly_reason_code=PROCESSABLE`;前端应调用 `review-resolution`,不要调用普通 `confirm`。 - CP8 起 V4 入站阶段也会做目录校验:Account 缺失或不存在时 Basic Information 卡直接 `REVIEW_REQUIRED`;业务卡已有 `room_items[].room_type_code` 或 `rate_code` 但不在固定目录时,业务卡也会直接 `REVIEW_REQUIRED`,错误会回显在 `fields[].validation_errors`。 +- CP8 确认接口也按 `fields[]` 白名单收口:前端可以只提交用户修改过的可编辑字段,不建议整包回传 `display_payload`。后端会从当前卡展示快照生成确认快照,并只合并可写叶子字段;来源邮件、路由、`target_order`、`order_ref`、`manual_review`、校验诊断字段以及前端额外注入字段不会写入 `confirmed_payload_json`。 +- 业务卡目录校验会递归检查 `business_fields` 下的嵌套结构。例如 `UPDATE_BOOKING` 的房型可能位于 `/business_fields/after/room_items/0/room_type_code`,错误详情会使用 `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":"/business_fields/room_items/0/pms_room_type_code","value":"RM2"}]}`。`confirmed_order_id` 在订单任务归属未解决时必填;如果订单任务已经绑定订单且 `target_resolution_status=RESOLVED`,只能不传或传当前同一个订单 ID,不能借该接口切换到其它订单。`field_pointer` 必须来自当前卡允许编辑的 `basic_information.*` 或 `business_fields.*` 叶子字段;展示 payload 有 `missing_fields[]` 时只提交清单里的 pointer,没有显式清单时只提交当前值为 `null` / 空字符串的未解决叶子字段;如果是目录校验错误,也可以提交后端 `fields[].validation_errors` 对应的字段 pointer。前端不要提交来源邮件、路由、`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 为只读派生字段。业务卡会按展示 payload 里的业务叶子字段返回字段白名单,例如 `/room_items/0/room_type_code` 或 `/business_fields/room_items/0/pms_room_type_code`;前端不要自行补未返回字段。 - V4 CP8 仍没有独立目录 lookup API。前端第一版可先按后端文档固定种子渲染 Account / Room Type / Rate Code 选项;后续真实目录或管理后台目录接入时再切换为 lookup。 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 5febd3b..9426c34 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 @@ -610,9 +610,9 @@ POST /api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/confirm 请求要点: - 请求 JSON 必须携带 `version`;`confirmed_payload` 可选,未传时后端使用当前展示 payload 作为确认快照。 -- 只提交当前卡允许编辑的字段或当前卡确认快照。 +- 前端只应提交当前卡 `fields[]` 中可编辑字段。后端确认时以当前卡展示快照为基准合并 `confirmed_payload`,未出现在展示快照 / 字段白名单中的字段会被忽略,不会写入 `confirmed_payload_json`。 - Basic Information 确认时 `basic_information.account_code` 必须是第一版 Account 目录值;后端确认前会派生 `account_name`、`market_code` 和 `source_code` 写入 `confirmed_payload_json`。 -- 业务卡确认时,第一版会校验已有 `rate_code`、`room_items[].room_type_code` 是否在固定目录中;失败返回 `V4_FIELD_VALIDATION_FAILED`。 +- 业务卡确认时,第一版会递归校验已有 `rate_code`、`room_items[].room_type_code` 是否在固定目录中;`UPDATE_BOOKING` 等嵌套结构会返回类似 `business_fields.after.room_items.0.room_type_code` 的错误路径,失败返回 `V4_FIELD_VALIDATION_FAILED`。 - 不提交草稿。 - 必须带 `version` 做并发校验。 - 后端确认后卡片 `CONFIRMED` 并锁定。 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 074d764..256af21 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 @@ -84,6 +84,16 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ "task_subtype", "task_type", "validation_errors"); + private static final Set BUSINESS_WRITABLE_ROOT_FIELDS = Set.of( + "arrival_date", + "attachment_ids", + "booking_scenario", + "departure_date", + "evidence_url", + "guest_name", + "rate_code", + "room_items", + "trace_items"); private final ReservationV4WorkflowRepository workflowRepository; private final ReservationV4SourceNotificationRepository sourceNotificationRepository; @@ -466,13 +476,138 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ } private ObjectNode confirmedPayloadObject(ReservationV4TaskCardSnapshot card, JsonNode confirmedPayload) { + ObjectNode payload = mutableDisplayPayload(card); if (confirmedPayload != null && !confirmedPayload.isNull() && !confirmedPayload.isMissingNode()) { if (!confirmedPayload.isObject()) { throw error(HttpStatus.BAD_REQUEST, "V4_CONFIRMED_PAYLOAD_NOT_OBJECT", "确认 payload 必须是对象结构。"); } - return ((ObjectNode) confirmedPayload).deepCopy(); + overlayConfirmedPayload(card, payload, (ObjectNode) confirmedPayload); } - return mutableDisplayPayload(card); + return payload; + } + + /** + * 按当前卡片白名单合并前端确认值,避免未开放字段污染 confirmed_payload_json。 + */ + private void overlayConfirmedPayload(ReservationV4TaskCardSnapshot card, ObjectNode payload, ObjectNode submittedPayload) { + if (ReservationV4CardType.BASIC_INFORMATION.name().equals(card.cardType())) { + overlayBasicInformationPayload(payload, submittedPayload); + return; + } + overlayBusinessPayload(payload, submittedPayload); + } + + /** + * Basic Information 第一版只允许前端改 Account Code,其它字段由后端展示快照或目录派生。 + */ + private void overlayBasicInformationPayload(ObjectNode payload, ObjectNode submittedPayload) { + ObjectNode basicInformation = ensureBasicInformationObject(payload); + JsonNode submittedBasic = submittedPayload.path("basic_information"); + JsonNode accountCode = submittedBasic.isObject() + ? submittedBasic.get("account_code") + : submittedPayload.get("account_code"); + if (accountCode != null && !accountCode.isMissingNode() && !accountCode.isContainerNode()) { + basicInformation.set("account_code", accountCode); + } + } + + /** + * 业务卡只合并展示快照中已经存在且后端允许编辑的叶子字段,不接受新增字段或整体替换对象 / 数组。 + */ + private void overlayBusinessPayload(ObjectNode payload, ObjectNode submittedPayload) { + JsonNode businessFields = payload.path("business_fields"); + JsonNode submittedBusinessFields = submittedPayload.path("business_fields"); + JsonNode submittedRoot = submittedBusinessFields.isObject() ? submittedBusinessFields : submittedPayload; + if (businessFields.isObject()) { + overlayEditableBusinessLeaves((ObjectNode) businessFields, submittedRoot, List.of(), true); + return; + } + overlayEditableBusinessLeaves(payload, submittedRoot, List.of(), false); + } + + private void overlayEditableBusinessLeaves( + ObjectNode target, + JsonNode submitted, + List path, + boolean wrappedBusinessFields) { + if (submitted == null || !submitted.isObject()) { + return; + } + Iterator> fields = target.fields(); + while (fields.hasNext()) { + Map.Entry field = fields.next(); + List childPath = appendPath(path, field.getKey()); + if (!isBusinessPathWritable(childPath, wrappedBusinessFields)) { + continue; + } + JsonNode submittedValue = submitted.get(field.getKey()); + overlayEditableBusinessValue(target, field.getKey(), field.getValue(), submittedValue, childPath, wrappedBusinessFields); + } + } + + private void overlayEditableBusinessArray( + ArrayNode target, + JsonNode submitted, + List path, + boolean wrappedBusinessFields) { + if (submitted == null || !submitted.isArray()) { + return; + } + int size = Math.min(target.size(), submitted.size()); + for (int index = 0; index < size; index++) { + List childPath = appendPath(path, String.valueOf(index)); + overlayEditableBusinessValue(target, index, target.get(index), submitted.get(index), childPath, wrappedBusinessFields); + } + } + + private void overlayEditableBusinessValue( + ObjectNode parent, + String fieldName, + JsonNode currentValue, + JsonNode submittedValue, + List path, + boolean wrappedBusinessFields) { + if (currentValue != null && currentValue.isObject()) { + overlayEditableBusinessLeaves((ObjectNode) currentValue, submittedValue, path, wrappedBusinessFields); + return; + } + if (currentValue != null && currentValue.isArray()) { + overlayEditableBusinessArray((ArrayNode) currentValue, submittedValue, path, wrappedBusinessFields); + return; + } + if (submittedValue != null && !submittedValue.isMissingNode() && !submittedValue.isContainerNode()) { + parent.set(fieldName, submittedValue); + } + } + + private void overlayEditableBusinessValue( + ArrayNode parent, + int index, + JsonNode currentValue, + JsonNode submittedValue, + List path, + boolean wrappedBusinessFields) { + if (currentValue != null && currentValue.isObject()) { + overlayEditableBusinessLeaves((ObjectNode) currentValue, submittedValue, path, wrappedBusinessFields); + return; + } + if (currentValue != null && currentValue.isArray()) { + overlayEditableBusinessArray((ArrayNode) currentValue, submittedValue, path, wrappedBusinessFields); + return; + } + if (submittedValue != null && !submittedValue.isMissingNode() && !submittedValue.isContainerNode()) { + parent.set(index, submittedValue); + } + } + + private boolean isBusinessPathWritable(List path, boolean wrappedBusinessFields) { + if (path == null || path.isEmpty()) { + return false; + } + if (path.stream().anyMatch(REVIEW_READONLY_ROOT_FIELDS::contains)) { + return false; + } + return wrappedBusinessFields || BUSINESS_WRITABLE_ROOT_FIELDS.contains(path.get(0)); } private void validateAndEnrichConfirmedPayload(ReservationV4TaskCardSnapshot card, ObjectNode payload) { @@ -531,34 +666,59 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ private void validateBusinessCardPayload(ObjectNode payload, List details) { JsonNode businessFields = payload.path("business_fields"); JsonNode fieldRoot = businessFields.isObject() ? businessFields : payload; - validateRateCode(fieldRoot.path("rate_code"), details); - validateRoomItems(fieldRoot.path("room_items"), details); + validateBusinessCatalogFields(fieldRoot, "business_fields", details); } - private void validateRateCode(JsonNode rateCode, List details) { - String code = textValue(rateCode); - if (code != null && !directoryService.isKnownRateCode(code)) { - details.add("business_fields.rate_code: Rate Code 不在第一版目录中。"); + private void validateBusinessCatalogFields(JsonNode node, String fieldPath, List details) { + if (node == null || node.isMissingNode() || node.isNull()) { + return; + } + if (node.isObject()) { + Iterator> fields = node.fields(); + while (fields.hasNext()) { + Map.Entry field = fields.next(); + String childPath = fieldPath + "." + field.getKey(); + if ("rate_code".equals(field.getKey())) { + validateRateCode(field.getValue(), childPath, details); + } else if ("room_items".equals(field.getKey())) { + validateRoomItems(field.getValue(), childPath, details); + } else { + validateBusinessCatalogFields(field.getValue(), childPath, details); + } + } + return; + } + if (node.isArray()) { + for (int index = 0; index < node.size(); index++) { + validateBusinessCatalogFields(node.get(index), fieldPath + "." + index, details); + } } } - private void validateRoomItems(JsonNode roomItems, List details) { + private void validateRateCode(JsonNode rateCode, String fieldPath, List details) { + String code = textValue(rateCode); + if (code != null && !directoryService.isKnownRateCode(code)) { + details.add(fieldPath + ": Rate Code 不在第一版目录中。"); + } + } + + private void validateRoomItems(JsonNode roomItems, String fieldPath, List details) { if (roomItems == null || roomItems.isMissingNode() || roomItems.isNull()) { return; } if (!roomItems.isArray()) { - details.add("business_fields.room_items: 房型明细必须是数组。"); + details.add(fieldPath + ": 房型明细必须是数组。"); return; } for (int index = 0; index < roomItems.size(); index++) { JsonNode item = roomItems.get(index); if (item == null || !item.isObject()) { - details.add("business_fields.room_items." + index + ": 房型明细必须是对象。"); + details.add(fieldPath + "." + index + ": 房型明细必须是对象。"); continue; } String roomTypeCode = textAt(item, "room_type_code"); if (roomTypeCode != null && !directoryService.isKnownRoomTypeCode(roomTypeCode)) { - details.add("business_fields.room_items." + index + ".room_type_code: 房型代码不在第一版目录中。"); + details.add(fieldPath + "." + index + ".room_type_code: 房型代码不在第一版目录中。"); } } } @@ -670,7 +830,7 @@ 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, segments); + ensureReviewPointerInsideEditableContainer(card, confirmedPayload, segments); if (value != null && value.isContainerNode()) { throw error(HttpStatus.BAD_REQUEST, "V4_REVIEW_VALUE_INVALID", "复核字段值必须是标量或 null,不能替换对象或数组。"); } @@ -688,6 +848,7 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ private void ensureReviewPointerInsideEditableContainer( ReservationV4TaskCardSnapshot card, + ObjectNode confirmedPayload, List segments) { if (segments.size() < 2) { throw error(HttpStatus.BAD_REQUEST, "V4_REVIEW_POINTER_NOT_ALLOWED", "复核字段不在当前卡允许编辑字段内。"); @@ -699,6 +860,12 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ } return; } + if ("business_fields".equals(root)) { + return; + } + if (!confirmedPayload.path("business_fields").isObject() && BUSINESS_WRITABLE_ROOT_FIELDS.contains(root)) { + return; + } if (!"business_fields".equals(root)) { throw error(HttpStatus.BAD_REQUEST, "V4_REVIEW_POINTER_NOT_ALLOWED", "复核字段不在当前业务卡允许编辑字段内。"); } @@ -774,6 +941,13 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ } } + private List appendPath(List path, String segment) { + List appended = new ArrayList<>(path.size() + 1); + appended.addAll(path); + appended.add(segment); + return appended; + } + /** * 判断复核字段是否属于本次允许修正的字段范围,优先使用显式缺失字段清单。 */ diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationV4TaskIntakeServiceImpl.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationV4TaskIntakeServiceImpl.java index fc1e16a..19babd4 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationV4TaskIntakeServiceImpl.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationV4TaskIntakeServiceImpl.java @@ -24,6 +24,7 @@ import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.ObjectNode; import java.time.LocalDateTime; import java.util.ArrayList; +import java.util.Iterator; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -400,35 +401,60 @@ public class ReservationV4TaskIntakeServiceImpl implements ReservationV4TaskInta List details = new ArrayList<>(); JsonNode businessFields = displayPayload.path("business_fields"); JsonNode fieldRoot = businessFields.isObject() ? businessFields : displayPayload; - validateRateCode(fieldRoot.path("rate_code"), details); - validateRoomItems(fieldRoot.path("room_items"), details); + validateBusinessCatalogFields(fieldRoot, "business_fields", details); return details; } - private void validateRateCode(JsonNode rateCode, List details) { - String code = trimToNull(rateCode == null || !rateCode.isTextual() ? null : rateCode.asText()); - if (code != null && !directoryService.isKnownRateCode(code)) { - details.add("business_fields.rate_code: Rate Code 不在第一版目录中。"); + private void validateBusinessCatalogFields(JsonNode node, String fieldPath, List details) { + if (node == null || node.isMissingNode() || node.isNull()) { + return; + } + if (node.isObject()) { + Iterator> fields = node.fields(); + while (fields.hasNext()) { + Map.Entry field = fields.next(); + String childPath = fieldPath + "." + field.getKey(); + if ("rate_code".equals(field.getKey())) { + validateRateCode(field.getValue(), childPath, details); + } else if ("room_items".equals(field.getKey())) { + validateRoomItems(field.getValue(), childPath, details); + } else { + validateBusinessCatalogFields(field.getValue(), childPath, details); + } + } + return; + } + if (node.isArray()) { + for (int index = 0; index < node.size(); index++) { + validateBusinessCatalogFields(node.get(index), fieldPath + "." + index, details); + } } } - private void validateRoomItems(JsonNode roomItems, List details) { + private void validateRateCode(JsonNode rateCode, String fieldPath, List details) { + String code = trimToNull(rateCode == null || !rateCode.isTextual() ? null : rateCode.asText()); + if (code != null && !directoryService.isKnownRateCode(code)) { + details.add(fieldPath + ": Rate Code 不在第一版目录中。"); + } + } + + private void validateRoomItems(JsonNode roomItems, String fieldPath, List details) { if (roomItems == null || roomItems.isMissingNode() || roomItems.isNull()) { return; } if (!roomItems.isArray()) { - details.add("business_fields.room_items: 房型明细必须是数组。"); + details.add(fieldPath + ": 房型明细必须是数组。"); return; } for (int index = 0; index < roomItems.size(); index++) { JsonNode item = roomItems.get(index); if (item == null || !item.isObject()) { - details.add("business_fields.room_items." + index + ": 房型明细必须是对象。"); + details.add(fieldPath + "." + index + ": 房型明细必须是对象。"); continue; } String roomTypeCode = trimToNull(textAt(item, "room_type_code")); if (roomTypeCode != null && !directoryService.isKnownRoomTypeCode(roomTypeCode)) { - details.add("business_fields.room_items." + index + ".room_type_code: 房型代码不在第一版目录中。"); + details.add(fieldPath + "." + index + ".room_type_code: 房型代码不在第一版目录中。"); } } } 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 46965d0..62605a1 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 @@ -160,8 +160,7 @@ class ReservationV4CommandControllerTest { .andExpect(status().isOk()) .andExpect(jsonPath("$.basic_information_card.card_status").value("CONFIRMED")) .andExpect(jsonPath("$.basic_information_card.confirmed_by").value("v4-command-admin")) - .andExpect(jsonPath("$.basic_information_card.confirmed_payload.group_code") - .value("GRP-V4-COMMAND-001")) + .andExpect(jsonPath("$.basic_information_card.confirmed_payload.group_code").doesNotExist()) .andExpect(jsonPath("$.basic_information_card.confirmed_payload.basic_information.market_code") .value("LEISURE")) .andExpect(jsonPath("$.basic_information_card.confirmed_payload.basic_information.source_code") @@ -178,7 +177,8 @@ class ReservationV4CommandControllerTest { { "version": 0, "confirmed_payload": { - "room_items": [{"room_type_code": "TWN", "room_count": 2}] + "room_items": [{"room_type_code": "TWN", "room_count": 2}], + "injected_debug_field": "SHOULD-NOT-PERSIST" } } """)) @@ -187,6 +187,7 @@ class ReservationV4CommandControllerTest { .andExpect(jsonPath("$.business_cards[0].card_status").value("CONFIRMED")) .andExpect(jsonPath("$.business_cards[0].confirmed_payload.room_items[0].room_type_code") .value("TWN")) + .andExpect(jsonPath("$.business_cards[0].confirmed_payload.injected_debug_field").doesNotExist()) .andExpect(jsonPath("$.business_cards[0].availability.confirmable").value(false)) .andExpect(jsonPath("$.business_cards[0].availability.readonly_reason_code").value("CARD_LOCKED")); assertAuditCount("V4_CARD_CONFIRM", "v4-command-admin", seeded.orderTask().id().toString(), 2); @@ -219,6 +220,84 @@ class ReservationV4CommandControllerTest { .andExpect(jsonPath("$.details[0]").value("basic_information.account_code: Account Code 不在信息系统目录中。")); } + @Test + void shouldRejectBusinessCardConfirmWhenNestedRoomTypeCodeUnknown() throws Exception { + SeededOrderTask seeded = seedOrderTaskWithBusinessCard( + HOTEL_ID, + "mail-v4-command-confirm-unknown-nested-room-001", + Instant.parse("2026-07-19T01:13:00Z"), + null, + """ + {"card_type":"ROOM_INFORMATION","event_type":"UPDATE_BOOKING","business_fields":{"order_ref":"ORDER-COMMAND","event_type":"UPDATE_BOOKING","after":{"room_items":[{"room_type_code":"UNKNOWN_ROOM","room_count":2}]}}} + """); + confirmBasicCard(seeded); + + performAuthorized(mockMvc, adminToken(), post("/api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/confirm", + seeded.orderTask().id(), + seeded.businessCard().id()) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "version": 0 + } + """)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error_code").value("V4_FIELD_VALIDATION_FAILED")) + .andExpect(jsonPath("$.details[0]").value("business_fields.after.room_items.0.room_type_code: 房型代码不在第一版目录中。")); + } + + @Test + void shouldConfirmWrappedBusinessFieldsAndDropInjectedFields() throws Exception { + SeededOrderTask seeded = seedOrderTaskWithBusinessCard( + HOTEL_ID, + "mail-v4-command-confirm-wrapped-whitelist-001", + Instant.parse("2026-07-19T01:13:10Z"), + null, + """ + {"card_type":"ROOM_INFORMATION","event_type":"UPDATE_BOOKING","business_fields":{"order_ref":"ORDER-COMMAND","event_type":"UPDATE_BOOKING","after":{"room_items":[{"room_type_code":"TWN","room_count":2}]}}} + """); + confirmBasicCard(seeded); + + performAuthorized(mockMvc, adminToken(), post("/api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/confirm", + seeded.orderTask().id(), + seeded.businessCard().id()) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "version": 0, + "confirmed_payload": { + "business_fields": { + "order_ref": "MUTATED-ORDER", + "event_type": "CANCEL_BOOKING", + "after": { + "room_items": [ + { + "room_type_code": "DBL", + "room_count": 1, + "injected_debug_field": "SHOULD-NOT-PERSIST" + } + ] + }, + "injected_debug_field": "SHOULD-NOT-PERSIST" + } + } + } + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.business_cards[0].confirmed_payload.business_fields.order_ref") + .value("ORDER-COMMAND")) + .andExpect(jsonPath("$.business_cards[0].confirmed_payload.business_fields.event_type") + .value("UPDATE_BOOKING")) + .andExpect(jsonPath("$.business_cards[0].confirmed_payload.business_fields.after.room_items[0].room_type_code") + .value("DBL")) + .andExpect(jsonPath("$.business_cards[0].confirmed_payload.business_fields.after.room_items[0].room_count") + .value(1)) + .andExpect(jsonPath("$.business_cards[0].confirmed_payload.business_fields.after.room_items[0].injected_debug_field") + .doesNotExist()) + .andExpect(jsonPath("$.business_cards[0].confirmed_payload.business_fields.injected_debug_field") + .doesNotExist()); + } + @Test void shouldRejectRepeatedCardConfirm() throws Exception { SeededOrderTask seeded = seedOrderTask( @@ -957,6 +1036,17 @@ class ReservationV4CommandControllerTest { String externalMessageId, Instant receivedAt, Long orderId) { + return seedOrderTaskWithBusinessCard(hotelId, externalMessageId, receivedAt, orderId, """ + {"card_type":"ROOM_INFORMATION","event_type":"NEW_BOOKING","room_items":[{"room_type_code":"TWN","room_count":2}]} + """); + } + + private SeededOrderTask seedOrderTaskWithBusinessCard( + String hotelId, + String externalMessageId, + Instant receivedAt, + Long orderId, + String businessDisplayPayloadJson) { SourceMessageCaptureResult source = captureSourceMessage( hotelId, externalMessageId, @@ -989,9 +1079,7 @@ class ReservationV4CommandControllerTest { """); ReservationV4TaskCardSnapshot businessCard = insertCard(orderTask, hotelId, ReservationV4CardType.ROOM_INFORMATION.name(), "NEW_BOOKING", 1, 30, - ReservationV4CardStatus.PENDING_CONFIRM.name(), null, """ - {"card_type":"ROOM_INFORMATION","event_type":"NEW_BOOKING","room_items":[{"room_type_code":"TWN","room_count":2}]} - """); + ReservationV4CardStatus.PENDING_CONFIRM.name(), null, businessDisplayPayloadJson); return new SeededOrderTask(orderTask, sourceCard, basicCard, businessCard); } diff --git a/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/SuperAgentTaskResultControllerTest.java b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/SuperAgentTaskResultControllerTest.java index 4d2215d..a6babfa 100644 --- a/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/SuperAgentTaskResultControllerTest.java +++ b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/SuperAgentTaskResultControllerTest.java @@ -1256,6 +1256,29 @@ class SuperAgentTaskResultControllerTest { .contains("房型代码不在第一版目录中"); } + @Test + void shouldMarkV4UpdateBookingReviewRequiredWhenNestedRoomTypeCodeUnknown() throws Exception { + SourceMessageCaptureResult source = captureSourceMessage("mail-v4-update-unknown-nested-room-001"); + String body = v4UpdateWithUnknownNestedRoomTypeBody("mail-v4-update-unknown-nested-room-001"); + + mockMvc.perform(signedPost(body, "nonce-v4-update-unknown-nested-room-001")) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.accepted_count").value(1)); + + String roomValidationErrors = jdbcTemplate.queryForObject(""" + SELECT validation_errors_json + FROM workflow_reservation_v4_task_card + WHERE source_message_id = ? + AND card_type = 'ROOM_INFORMATION' + AND card_status = 'REVIEW_REQUIRED' + AND review_status = 'PENDING' + LIMIT 1 + """, String.class, source.inboxId()); + assertThat(roomValidationErrors) + .contains("business_fields.after.room_items.0.room_type_code") + .contains("房型代码不在第一版目录中"); + } + @Test void shouldCreateV4CancelTraceAndRoomingListTasksInEventOrder() throws Exception { SourceMessageCaptureResult source = captureSourceMessage("mail-v4-cancel-trace-rooming-001"); @@ -4349,6 +4372,53 @@ class SuperAgentTaskResultControllerTest { """.formatted(externalSourceMessageId); } + private String v4UpdateWithUnknownNestedRoomTypeBody(String externalSourceMessageId) { + return """ + { + "route_code": null, + "source_message": { + "source_message_id": "%s", + "conversation_id": "thread-v4-update-unknown-room-001", + "subject": "Update booking room type", + "sender": "agent@example.test", + "sent_at": "2026-07-18T02:10:00Z", + "body": "Please update room type.", + "body_content_type": "text/plain", + "attachments": [] + }, + "order_contexts": [ + { + "order_ref": "order-1", + "basic_information": { + "account_code": "QBD_TRAVEL", + "manual_review": null + } + } + ], + "message_events": [ + { + "order_ref": "order-1", + "event_type": "UPDATE_BOOKING", + "target_order": { + "booking_type": "GROUP", + "locator_type": "GROUP_CODE", + "locator_value": "GRP-V4-UPD-UNKNOWN-ROOM-001" + }, + "after": { + "room_items": [ + { + "room_type_code": "UNKNOWN_ROOM", + "room_count": 2 + } + ] + }, + "manual_review": null + } + ] + } + """.formatted(externalSourceMessageId); + } + private String[] createReadyTaskWithTwoOperaOperations( String externalMessageId, String nonce,