From 38661257d1eb0f1f312dee3d40a8ac5e8c7439e7 Mon Sep 17 00:00:00 2001 From: andy Date: Tue, 21 Jul 2026 17:47:25 +0700 Subject: [PATCH] =?UTF-8?q?=E6=94=B6=E5=8F=A3V4=20Trace=E5=8D=A1=E5=90=8E?= =?UTF-8?q?=E7=AB=AF=E5=A5=91=E7=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- PROJECT_STATE.md | 16 +- .../backend-to-frontend-notes.md | 2 +- .../frontend-to-backend-api-requests.md | 2 +- .../M002-v4-agent-callback-field-contract.md | 7 +- ...002-v4-order-task-card-domain-model-cp2.md | 5 +- .../security-access-control-boundary.md | 2 +- ...eservationV4TaskCardFieldOptionResult.java | 17 + .../ReservationV4TaskCardFieldResult.java | 3 + .../ReservationAiV4CallbackValidator.java | 9 +- .../impl/ReservationV4CommandServiceImpl.java | 113 ++++++- .../impl/ReservationV4QueryServiceImpl.java | 137 ++++++++ .../ReservationV4TaskIntakeServiceImpl.java | 34 +- .../impl/ReservationV4TraceFieldPolicy.java | 289 ++++++++++++++++ .../ReservationV4CommandControllerTest.java | 312 +++++++++++++++++- .../ReservationV4QueryControllerTest.java | 71 ++++ .../SuperAgentTaskResultControllerTest.java | 56 +++- 16 files changed, 1054 insertions(+), 21 deletions(-) create mode 100644 server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationV4TaskCardFieldOptionResult.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationV4TraceFieldPolicy.java diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index 538d17f..0725749 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 与任务详情安全边界修复、Room Information 复核 pointer 运行时规则收口、复核 pointer 部署证明与运行时 trace、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 字段策略。复核 pointer 拒绝前会记录 `review_pointer_policy=m002_v4_review_pointer_runtime_fix_v1`,包含 order task、card、incoming pointer、query-side editable pointers、command-side allowed pointers、validation error pointers 和 reject reason,但不记录 payload、邮件正文或附件 URL。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 卡已在 `display_payload.payment_attachments[]` 返回付款凭证附件安全摘要,字段只包含附件 ID、文件名、类型、大小、是否图片、是否理论可预览 / 下载和可选 `external_media_id`,前端在 V4 任务详情 Payment 卡中按当前触发 SourceMessage 的 conversation 附件匹配缩略图、大图预览和非图片下载,匹配优先 `external_media_id` / `externalMediaId`,其次 `attachment_id`,不按文件名猜测;`attachment_ids[]` 第一版仍只读,前端不增删或替换附件集合,Payment 确认只提交 `version`,不提交附件 ID、附件 URL 或完整附件对象;真实 URL 仍只来自 SourceMessage 原文权限链路,权限不足或 conversation 失败时降级展示不可预览 / 不可下载。已确认 Rooming List 任务卡第一版只做事项确认,不做名单解析、附件预览、Excel 生成或 PMS 导入,用户点击“确认卡片”表示已人工处理该 Rooming List 事项;已确认 `REVIEW_REQUIRED` 仍是原业务卡复核态,页面按钮统一叫“确认卡片”,复核态允许编辑当前卡 `fields[]` 白名单内业务字段,问题字段红字提示。后续可继续做测试机 V4 smoke 复测、Rooming List 前端轻量卡展示、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 后端联动、Rooming List 前端轻量事项卡、Room Information 复核 pointer 与任务详情安全边界修复、Room Information 复核 pointer 运行时规则收口、复核 pointer 部署证明与运行时 trace、Account + booking type 过滤 Rate Code 文档口径、Payment 附件预览后端安全摘要与前端预览接入、Trace 卡后端字段契约收口、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 字段策略。Trace 卡后端契约已收口:普通事项内容字段统一为 `trace_items[].text`,不使用 `content`;`department_code` 第一版只允许 `FO`、`HSK`、`FO+HSK`,任务详情字段会返回 `options_source=reservation_v4_trace_department_fixed` 和 `fixed_options[]` 三个固定选项;`EXTRA_BED.target_room_type_code` 只校验当前酒店 Room Type 目录存在,暂不校验当前订单已有房型;确认和复核共用同一套 Trace 字段白名单,`display_payload` / `confirmed_payload` 不返回 `target_order`、邮件正文、附件 URL、raw evidence 或 AI 原始 payload。复核 pointer 拒绝前会记录 `review_pointer_policy=m002_v4_review_pointer_runtime_fix_v1`,包含 order task、card、incoming pointer、query-side editable pointers、command-side allowed pointers、validation error pointers 和 reject reason,但不记录 payload、邮件正文或附件 URL。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。V4 任务详情已支持 ROOMING_LIST 轻量事项卡:页面只显示 “Rooming List / 房表事项”、目标订单线索、人工处理说明和确认按钮,不展示 rows、名单明细、附件预览、导入 / 生成入口、AI payload、邮件正文或附件 URL;PENDING_CONFIRM 确认只提交 `version`,成功后完全使用后端刷新详情,不由前端自行设置 `group_booking_status=DEF`。已确认 Rate Code 下一阶段按 Reservation Account + `booking_type`(GROUP / FIT)过滤和校验,不按全酒店 Rate Code 全量展示;Payment 卡已在 `display_payload.payment_attachments[]` 返回付款凭证附件安全摘要,字段只包含附件 ID、文件名、类型、大小、是否图片、是否理论可预览 / 下载和可选 `external_media_id`,前端在 V4 任务详情 Payment 卡中按当前触发 SourceMessage 的 conversation 附件匹配缩略图、大图预览和非图片下载,匹配优先 `external_media_id` / `externalMediaId`,其次 `attachment_id`,不按文件名猜测;`attachment_ids[]` 第一版仍只读,前端不增删或替换附件集合,Payment 确认只提交 `version`,不提交附件 ID、附件 URL 或完整附件对象;真实 URL 仍只来自 SourceMessage 原文权限链路,权限不足或 conversation 失败时降级展示不可预览 / 不可下载。已确认 `REVIEW_REQUIRED` 仍是原业务卡复核态,页面按钮统一叫“确认卡片”,复核态允许编辑当前卡 `fields[]` 白名单内业务字段,问题字段红字提示。后续可继续做测试机 V4 smoke 复测、Account 范围 Rate Code lookup、真实 PMS / OPERA / OHIP 同步或 SuperAgent 目录供给方案。 | ## 1. 当前 Checkpoint -- 名称:`M002-V4-payment-attachment-preview-frontend` -- 状态:Done,本地已实现 V4 Payment 卡付款凭证附件预览第一版。 -- 目标:V4 任务详情 Payment 卡消费 `display_payload.payment_attachments[]` 安全摘要,图片按当前触发 SourceMessage 的 conversation 附件匹配缩略图和大图预览,非图片提供下载入口;`attachment_ids[]` 保持只读且不进入编辑 / 确认 payload。 -- 边界:本 checkpoint 不改后端、不返回或展示附件 URL 字符串、不做附件选择器、不做 PDF / Word / Excel 内嵌预览、不做付款状态、不做 Rate Code lookup、不做 PMS / OPERA / OHIP、不推进 M011。 -- 联调备注:真实预览 / 下载 URL 仍必须通过 `GET /api/source-messages/{sourceMessageId}/conversation` 在 `SOURCE_MESSAGE_READ + SOURCE_MESSAGE_ORIGINAL_READ` 权限下读取;前端只匹配当前触发 order task 的 SourceMessage,优先 `external_media_id` / `externalMediaId`,其次 `attachment_id`,不按文件名猜测;Payment 普通确认只提交 `{ version }`。 +- 名称:`M002-V4-trace-card-backend-contract-tightening` +- 状态:Done,本地已完成 Trace 卡后端字段契约收口。 +- 目标:统一 Trace 卡字段模型,`trace_items[].text` 作为正式内容字段,`department_code` 固定 `FO` / `HSK` / `FO+HSK`,并让查询、确认和复核共用同一套字段白名单。 +- 边界:本 checkpoint 不做 Department 目录 / lookup API、不做 PMS / OPERA / OHIP、不改前端、不推进 M011;EXTRA_BED 目标房型第一版只校验当前酒店 Room Type 目录存在。 +- 联调备注:Trace 专属卡前端可直接消费 `fields[]` 中 `/trace_items/{index}/...` 指针;确认和复核只提交这些 pointer 对应字段,不提交 `content`、`target_order`、邮件正文、附件 URL 或 raw payload。 ## 2. 当前优先级 @@ -36,7 +36,7 @@ - 后续每完成一个 Feature 或 Checkpoint,需要更新本文件,避免项目状态继续沉淀在聊天记录里。 - M010 Rooming List Excel 生成后端 CP1 和前端 V1 已实现:前端 `/reservation/rooming-lists/new` 上传来源名单和手工字段,后端同步生成 `.xlsx` 直接下载,第一版不落库、不上传 OSS。 - M011 Booking Excel 附件预处理 CP1/CP2/CP3 已实现:后端可排除人员名单类 Excel,按最近 6 个月候选窗口选择实际存在的最新 3 个业务月,抽取 Booking Update / 附加费表高亮行业务 JSON;Debug EML 和 AgentBus dispatch 在各自 include 开关与总开关同时启用时,会在调用 SuperAgent 前追加 `attachment_extractions[]`。测试机 AgentBus 增强已开启;生产链路仍默认关闭,生产开启需单独确认。 -- M002 V4 CP1 当前已完成入站解析和现有任务链路过渡适配;M002 V4 CP2 已完成订单任务与多卡领域模型设计;M002 V4 CP3 已完成 V4 订单任务、多卡和 S10/S99 来源通知表结构与 Repository 基线;M002 V4 CP4 已完成入站写入新模型;M002 V4 CP5 已完成前端查询接口并补齐订单详情 `v4_order_tasks[]` 时间线;M002 V4 CP6 已完成普通卡片确认和 S10/S99 来源通知 ack;M002 V4 CP7 已完成 `REVIEW_REQUIRED` 卡复核解阻和复核场景订单归属确认;M002 V4 CP8 已完成目录校验、V4 卡片 `fields[]` 字段白名单、确认写入白名单收口和嵌套业务字段目录校验;M002 V4 CP11 已完成数据库目录、初始化种子、启动补种子、Account / Room Type / Rate Code lookup API,并把 V4 入站、确认、复核目录校验切换到当前酒店数据库目录;M002 V4 CP12 已完成前端 lookup 接入第一版和 V4 订单任务时间线消费;M002 V4 CP13 目录管理后台 CP1 已完成前后端列表、新增、启用 / 停用闭环;M002 V4 CP14 已完成订单列表 V4 继续处理入口字段和前端入口消费,`GET /api/reservation/orders` 返回 V4 下一步订单任务、卡片、动作类型、动作状态、V4 open 数和统一展示计数 `open_work_item_count`,前端按 V4 优先跳转,并按 `open_work_item_count` 展示待处理数量;V4 业务审计查询已补齐订单任务审计和来源通知 ack 审计两个只读接口;订单详情已补齐并完成前端接入 V4 `order_overview`、`next_v4_action`、`related_source_messages[]` 和 `v4_order_tasks[].cards[]`;V4 普通业务入站已停止双写旧 `workflow_reservation_task`;Room Information 后端展示模型和前端业务化展示第一版已完成;Rooming List 确认触发 Group Booking Status 自动置 `DEF` 已完成;Payment 附件安全摘要后端和前端预览接入均已完成。Account + booking type 过滤 Rate Code 仍是待补齐缺口;真实 PMS 同步和 SuperAgent 目录机器接口仍未完成。 +- M002 V4 CP1 当前已完成入站解析和现有任务链路过渡适配;M002 V4 CP2 已完成订单任务与多卡领域模型设计;M002 V4 CP3 已完成 V4 订单任务、多卡和 S10/S99 来源通知表结构与 Repository 基线;M002 V4 CP4 已完成入站写入新模型;M002 V4 CP5 已完成前端查询接口并补齐订单详情 `v4_order_tasks[]` 时间线;M002 V4 CP6 已完成普通卡片确认和 S10/S99 来源通知 ack;M002 V4 CP7 已完成 `REVIEW_REQUIRED` 卡复核解阻和复核场景订单归属确认;M002 V4 CP8 已完成目录校验、V4 卡片 `fields[]` 字段白名单、确认写入白名单收口和嵌套业务字段目录校验;M002 V4 CP11 已完成数据库目录、初始化种子、启动补种子、Account / Room Type / Rate Code lookup API,并把 V4 入站、确认、复核目录校验切换到当前酒店数据库目录;M002 V4 CP12 已完成前端 lookup 接入第一版和 V4 订单任务时间线消费;M002 V4 CP13 目录管理后台 CP1 已完成前后端列表、新增、启用 / 停用闭环;M002 V4 CP14 已完成订单列表 V4 继续处理入口字段和前端入口消费,`GET /api/reservation/orders` 返回 V4 下一步订单任务、卡片、动作类型、动作状态、V4 open 数和统一展示计数 `open_work_item_count`,前端按 V4 优先跳转,并按 `open_work_item_count` 展示待处理数量;V4 业务审计查询已补齐订单任务审计和来源通知 ack 审计两个只读接口;订单详情已补齐并完成前端接入 V4 `order_overview`、`next_v4_action`、`related_source_messages[]` 和 `v4_order_tasks[].cards[]`;V4 普通业务入站已停止双写旧 `workflow_reservation_task`;Room Information 后端展示模型和前端业务化展示第一版已完成;Rooming List 确认触发 Group Booking Status 自动置 `DEF` 已完成,前端轻量事项确认卡也已完成;Payment 附件安全摘要后端和前端预览接入均已完成。Account + booking type 过滤 Rate Code 仍是待补齐缺口;真实 PMS 同步和 SuperAgent 目录机器接口仍未完成。 - 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 必须先确认;Rooming List 卡第一版只做事项确认;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 d79cb3c..493c9d2 100644 --- a/docs/project/frontend-backend/backend-to-frontend-notes.md +++ b/docs/project/frontend-backend/backend-to-frontend-notes.md @@ -504,7 +504,7 @@ RESERVATION_ROOMING_LIST_GENERATE - 所有按钮应按后端 `availability.confirmable`、`availability.reviewable`、`availability.ackable` 和前端权限共同控制;不可操作原因优先展示 `readonly_reason_message`,否则按 `readonly_reason_code` 做友好映射。 - `fields[].validation_errors` 应展示在对应字段旁边;接口返回 `V4_FIELD_VALIDATION_FAILED` 且 `details[]` 带嵌套路径时,前端应尝试定位到对应 field,定位不到则在当前卡片动作错误区展示。 - `options_source=reservation_v4_account_catalog`、`reservation_v4_room_type_catalog`、`reservation_v4_rate_code_catalog` 时,前端应调用对应 lookup API,不再硬编码固定种子;如果 lookup 返回空列表或 `warnings[]`,前端展示非阻塞提示,但提交时仍以后端目录校验为准。 -- Trace 卡 `trace_items[].department_code` 第一版先固定下拉选项 `FO`、`HSK`、`FO+HSK`,不要调用不存在的 Department lookup API,也不要允许自由文本;正式 Department 目录和后端目录校验后续单独扩展。 +- Trace 卡第一版使用稳定业务展示模型:普通事项内容字段统一为 `trace_items[].text`,不要使用或提交 `trace_items[].content`;`GENERAL` 的可编辑字段为 `/trace_items/{index}/text` 和 `/trace_items/{index}/department_code`,`EXTRA_BED` 的可编辑字段为 `/trace_items/{index}/target_room_type_code`、`/trace_items/{index}/extra_bed_room_count` 和 `/trace_items/{index}/department_code`。`department_code` 固定下拉 `FO`、`HSK`、`FO+HSK`,后端在对应字段返回 `options_source=reservation_v4_trace_department_fixed` 和 `fixed_options[]`;`target_room_type_code` 使用 Room Type lookup,`extra_bed_room_count` 为正整数;确认和复核都只提交 `fields[]` 暴露的白名单 pointer,不要提交 `target_order`、邮件正文、附件 URL、raw evidence 或 Agent 原始 payload。 - 前端不展示 `ai_payload_json`、附件 URL、raw evidence 或 SuperAgent 原始 payload;V4 任务详情页底部 `SOURCE_MESSAGE_DISPLAY` 可展示当前触发该 order task 的 SourceMessage 正文,但必须通过 `GET /api/source-messages/{sourceMessageId}/conversation` 读取并写原文读取审计,不能要求 `GET /api/reservation/order-tasks/{orderTaskId}` 直接返回正文。Payment 卡如展示付款凭证图片,也必须通过同一会话接口获取受控附件 URL:卡片内显示缩略图,点击打开大图预览;非图片只显示文件列表和下载。HTML 邮件优先渲染 `html_body_sanitized`,缺少原文权限或接口失败时降级为安全摘要和“查看邮件会话”入口。 - V4 来源消息卡读取 `attachments`、`uploaded_media`、`file_references` 时,只允许展示附件名称、类型和大小等安全摘要;如果后端 payload 中异常出现 `https://`、`oss://`、`s3://` 等直接 URL 字符串,前端必须替换为“未命名附件”或隐藏,不得把 URL 渲染到普通业务页面。`SOURCE_MESSAGE_DISPLAY` 的邮件正文默认做长度折叠,用户可展开全文。 - V4 主流程不调用旧 V2/V3 草稿、旧任务确认、旧同卡复核接口,也不展示 OPERA 模拟操作入口。 diff --git a/docs/project/frontend-backend/frontend-to-backend-api-requests.md b/docs/project/frontend-backend/frontend-to-backend-api-requests.md index 78c08e7..6f3fbc1 100644 --- a/docs/project/frontend-backend/frontend-to-backend-api-requests.md +++ b/docs/project/frontend-backend/frontend-to-backend-api-requests.md @@ -33,7 +33,7 @@ | `GET /api/reservation/tasks` | 已完成第一版,已补来源邮件会话字段和所属订单状态筛选 | 可以 | `order_status` 按任务所属订单状态过滤;不传时保持当前全部任务列表行为。 | | `GET /api/reservation/orders/{orderId}` | 已完成第一版,已补旧 `tasks[]` 来源邮件会话字段、V4 总览和 V4 `v4_order_tasks[]` 时间线,前端订单详情总览页已接入 | 可以 | 暂无;`include_tasks=false` 时旧 `tasks[]`、V4 `v4_order_tasks[]` 和 `related_source_messages[]` 都返回空数组,`order_overview` 为空快照,`next_v4_action.action_type=NONE`。 | | `GET /api/reservation/tasks/{taskId}` | 已完成第一版,已补任务顶层来源邮件字段和 `fields[]` 3.0 元数据 | 可以 | 当前 Controller 不接收 `hotel_id`;如后续多酒店隔离需要前端显式传酒店上下文,请后端补可选入参或确认按 taskId 全局唯一即可。 | -| `GET /api/reservation/order-tasks/{orderTaskId}` | 已完成第一版;Room Information 后端展示模型已补齐且前端业务化展示已接入;Rooming List 确认自动 DEF 后端联动已完成;复核态字段白名单第一版已随 `fields[]` 返回;Payment 附件安全摘要已补齐 | 可以;Room Information 前端业务化展示已完成,Rooming List 轻量确认可继续进入前端业务化展示,Payment 预览可基于后端安全摘要继续联动 | V4 任务详情页展示顺序为 Basic Information、业务卡、SourceMessage Display;Trace 卡 `department_code` 第一版固定为 `FO` / `HSK` / `FO+HSK` 三个下拉值,不调用 Department lookup,不开放自由输入。Room Information 已由后端返回 `display_payload.room_information`:New 展示最终值,Update 展示 `current_values`、`proposed_values`、`final_values` 和 `change_summary[]`,Cancel 展示本地订单投影只读;Nights 后端按酒店本地日期派生,Breakfast 前端为含早勾选框,Group Booking Status 显示 `TEN-Tentative` / `DEF-Definite` / `INQ-Inquiry`;New Booking 最终订单投影字段 `group_block_name` / `fit_name` 可编辑,默认值可来自 Agent `target_order`,但 Agent 原始 `target_order` 不在普通 `display_payload` / `confirmed_payload` 中暴露,也不被用户编辑回写。`fields[]` 中 Room Information 字段统一使用 `/room_information/final_values/...`,确认 payload 和复核 `field_overrides[]` 均优先使用这些 pointer;`write_target=confirmed_payload` 是前端请求体语义,不是后端表字段名。`REVIEW_REQUIRED` 仍是原业务卡复核态,问题字段红字提示,按钮统一显示“确认卡片”,前端内部调用 `review-resolution`,并以 `fields[].editable` 渲染当前卡白名单字段,不只渲染 missing/error 字段。Rooming List 卡第一版只做事项确认,前端展示标题、状态、目标订单信息和“确认卡片”按钮,不做名单 rows、附件预览、Excel 生成或 PMS 导入;确认 `ROOMING_LIST` 后,如同订单为 Group,后端会自动把已确认 Room Information 快照中的 Group Booking Status 置为 `DEF`,后续刷新任务详情的 `display_payload` 和 `confirmed_payload` 都会显示 DEF,不需要前端自行提交或计算该状态,并可通过订单任务审计看到 `V4_ROOMING_LIST_AUTO_DEF`;当前订单详情 `order_overview` 不返回 Group Booking Status 字段。本接口仍不直接返回邮件正文或附件 URL。来源邮件卡正文限定为当前触发该 V4 order task 的那封 SourceMessage 正文,前端用 `source_message_summary.source_message_id` 调用 `GET /api/source-messages/{sourceMessageId}/conversation` 后定位当前邮件,默认长度折叠并可展开;缺少 `SOURCE_MESSAGE_ORIGINAL_READ` 或会话接口失败时降级展示安全摘要。Payment 卡已返回 `payment_attachments[]` 安全摘要,供前端展示图片缩略图 / 非图片文件列表;`attachment_ids[]` 第一版只读,不支持前端增删、替换或重新选择附件集合;实际大图预览和下载 URL 仍走 SourceMessage conversation。 | +| `GET /api/reservation/order-tasks/{orderTaskId}` | 已完成第一版;Room Information 后端展示模型已补齐且前端业务化展示已接入;Rooming List 确认自动 DEF 后端联动已完成;复核态字段白名单第一版已随 `fields[]` 返回;Payment 附件安全摘要已补齐;Trace 后端稳定字段契约已收口 | 可以;Room Information 前端业务化展示已完成,Rooming List 轻量确认可继续进入前端业务化展示,Payment 预览可基于后端安全摘要继续联动,Trace 专属卡可按 `fields[]` 接入 | V4 任务详情页展示顺序为 Basic Information、业务卡、SourceMessage Display;Trace 卡普通事项内容字段统一为 `trace_items[].text`,不要使用或提交 `content`;`GENERAL` 可编辑 `/trace_items/{index}/text`、`/trace_items/{index}/department_code`,`EXTRA_BED` 可编辑 `/trace_items/{index}/target_room_type_code`、`/trace_items/{index}/extra_bed_room_count`、`/trace_items/{index}/department_code`;`department_code` 第一版固定为 `FO` / `HSK` / `FO+HSK` 三个下拉值,不调用 Department lookup,不开放自由输入,对应字段返回 `options_source=reservation_v4_trace_department_fixed` 和 `fixed_options[]`;`target_room_type_code` 使用 Room Type lookup 且只校验当前酒店目录存在,`extra_bed_room_count` 为正整数。Room Information 已由后端返回 `display_payload.room_information`:New 展示最终值,Update 展示 `current_values`、`proposed_values`、`final_values` 和 `change_summary[]`,Cancel 展示本地订单投影只读;Nights 后端按酒店本地日期派生,Breakfast 前端为含早勾选框,Group Booking Status 显示 `TEN-Tentative` / `DEF-Definite` / `INQ-Inquiry`;New Booking 最终订单投影字段 `group_block_name` / `fit_name` 可编辑,默认值可来自 Agent `target_order`,但 Agent 原始 `target_order` 不在普通 `display_payload` / `confirmed_payload` 中暴露,也不被用户编辑回写。`fields[]` 中 Room Information 字段统一使用 `/room_information/final_values/...`,确认 payload 和复核 `field_overrides[]` 均优先使用这些 pointer;`write_target=confirmed_payload` 是前端请求体语义,不是后端表字段名。`REVIEW_REQUIRED` 仍是原业务卡复核态,问题字段红字提示,按钮统一显示“确认卡片”,前端内部调用 `review-resolution`,并以 `fields[].editable` 渲染当前卡白名单字段,不只渲染 missing/error 字段。Rooming List 卡第一版只做事项确认,前端展示标题、状态、目标订单信息和“确认卡片”按钮,不做名单 rows、附件预览、Excel 生成或 PMS 导入;确认 `ROOMING_LIST` 后,如同订单为 Group,后端会自动把已确认 Room Information 快照中的 Group Booking Status 置为 `DEF`,后续刷新任务详情的 `display_payload` 和 `confirmed_payload` 都会显示 DEF,不需要前端自行提交或计算该状态,并可通过订单任务审计看到 `V4_ROOMING_LIST_AUTO_DEF`;当前订单详情 `order_overview` 不返回 Group Booking Status 字段。本接口仍不直接返回邮件正文或附件 URL。来源邮件卡正文限定为当前触发该 V4 order task 的那封 SourceMessage 正文,前端用 `source_message_summary.source_message_id` 调用 `GET /api/source-messages/{sourceMessageId}/conversation` 后定位当前邮件,默认长度折叠并可展开;缺少 `SOURCE_MESSAGE_ORIGINAL_READ` 或会话接口失败时降级展示安全摘要。Payment 卡已返回 `payment_attachments[]` 安全摘要,供前端展示图片缩略图 / 非图片文件列表;`attachment_ids[]` 第一版只读,不支持前端增删、替换或重新选择附件集合;实际大图预览和下载 URL 仍走 SourceMessage conversation。 | | `PUT /api/reservation/tasks/{taskId}/draft` | 已完成 | 可以 | 当前 Controller 不接收 `hotel_id`;如写操作需要酒店上下文幂等 / 权限校验,请后端补可选入参或请求体字段。 | | `POST /api/reservation/tasks/{taskId}/confirm` | 已完成 | 可以 | 当前 Controller 不接收 `hotel_id`;如写操作需要酒店上下文幂等 / 权限校验,请后端补可选入参或请求体字段。 | | `GET /api/reservation/tasks/{taskId}/audits` | 已完成 | 可以 | 当前 Controller 不接收 `hotel_id`;如审计查询需要酒店上下文隔离,请后端补可选入参。 | diff --git a/docs/project/requirements/M002-v4-agent-callback-field-contract.md b/docs/project/requirements/M002-v4-agent-callback-field-contract.md index 499091b..2034b64 100644 --- a/docs/project/requirements/M002-v4-agent-callback-field-contract.md +++ b/docs/project/requirements/M002-v4-agent-callback-field-contract.md @@ -462,17 +462,18 @@ Fit 条件字段: | --- | --- | --- | --- | | `trace_items[]` | Trace | 是 | 同一订单一张 Trace 卡,卡内多条事项 | | `item_type` | Trace item | 是 | `GENERAL` 或 `EXTRA_BED` | -| `text` | GENERAL | 是,可为 `null` | 普通备注内容 | +| `text` | GENERAL | 是 | 普通备注内容;Trace 普通事项正式字段统一使用 `trace_items[].text`,不使用 `content` | | `department_code` | GENERAL / EXTRA_BED | 是 | 第一版固定为 `FO` / `HSK` / `FO+HSK`,分别表示前厅、客房和前厅 + 客房组合 | -| `target_room_type_code` | EXTRA_BED | 是,可为 `null` | 加床目标房型 | +| `target_room_type_code` | EXTRA_BED | 是 | 加床目标房型;第一版必须存在于当前酒店 Room Type 目录 | | `extra_bed_room_count` | EXTRA_BED | 是 | 加床房间数量 | ### 14.3 规则 +- Trace 普通事项内容字段统一为 `trace_items[].text`;SuperAgent、后端存储和 API 展示均不使用 `trace_items[].content` 作为正式字段。 - EXTRA_BED 不输出自由 `content`;信息系统固定显示 `SET EXTRA BED`。 - EXTRA_BED 不输出 `adult_after_extra_bed`;信息系统按当前 / 基础 Adult +1 计算,页面确认前允许用户纠正。 - Department code 由 Agent 必传。第一版先固定为 `FO`、`HSK`、`FO+HSK` 三个值,不接受自由文本;正式 Department 目录和 lookup API 后续单独扩展。 -- 加床目标房型不在当前订单时,Trace 卡不能确认,并提示用户核对目标房型和订单关联。 +- 加床目标房型第一版只校验是否存在于当前酒店 Room Type 目录;暂不要求必须属于当前订单已有房型。目录不存在时 Trace 卡不能确认,并提示用户核对目标房型。 - 只有订单定位本身不可信时,才触发共享查单门槛阻断整个订单上下文。 ## 15. ROOMING_LIST 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 983c744..e1d0cf9 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 @@ -266,7 +266,8 @@ V4 当前不做 OPERA / PMS 执行,但仍需要保留同订单处理顺序, - 除 Basic Information 必须先确认外,第一版不强制业务卡之间逐张顺序确认;业务卡可独立确认,但页面仍按固定顺序展示。 - 同类型多张业务卡按 `source_event_index` 排序。 - `SOURCE_MESSAGE_DISPLAY` 只读、不阻塞,V4 任务详情页固定放在最下方,用于查看当前触发该订单任务的 SourceMessage 正文和附件摘要。 -- Trace 卡 `trace_items[].department_code` 第一版固定为 `FO`、`HSK`、`FO+HSK` 三个值;前端可先做固定下拉,后端正式 Department 目录、lookup API 和目录校验后续单独 checkpoint 扩展。 +- Trace 卡普通事项内容字段统一使用 `trace_items[].text`,不使用 `trace_items[].content` 作为正式字段;`trace_items[].department_code` 第一版固定为 `FO`、`HSK`、`FO+HSK` 三个值,前端做固定下拉,不调用 Department lookup,也不允许自由文本。 +- Trace `EXTRA_BED` 的 `target_room_type_code` 第一版只校验当前酒店 Room Type 目录存在,暂不要求必须属于当前订单已有房型;当前订单已有房型约束后置。 - Rooming List 卡第一版没有可编辑业务字段;页面展示为轻量事项确认卡,用户点击“确认卡片”仅表示已人工处理当前 Rooming List 事项,不代表名单已解析、Excel 已生成或 PMS 已导入。 - Room Information 卡下一阶段采用业务展示模型,不再只依赖通用 `fields[]` 扁平渲染;具体规则见“Room Information 卡展示模型”。 @@ -592,7 +593,7 @@ V4 任务详情页第一版字段白名单: | `ROOM_INFORMATION` / `NEW_BOOKING` | `group_block_name` 或 `fit_name`、`arrival_date`、`departure_date`、`rate_code`、`room_items[].room_type_code`、`room_items[].room_count`、Group 的 `group_booking_status`、无法从 Fit Rate Code 派生时的 `breakfast_included` | `nights`、Group 固定 `breakfast_included=true`、Fit 可由 Rate Code 派生的 `breakfast_included`、Adult、Block ID、Confirmation Number、Agent 原始 `target_order` | | `ROOM_INFORMATION` / `UPDATE_BOOKING` | 修改后的 `arrival_date`、`departure_date`、`room_items[].room_type_code`、`room_items[].room_count`、Group 的 `group_booking_status`、无法从 Fit Rate Code 派生时的 `breakfast_included` | 当前值、本次变化摘要、`nights` 差异、Rate Code、Adult、Block ID、Confirmation Number、Agent 原始 `target_order` | | `ROOM_INFORMATION` / `CANCEL_BOOKING` | 无;用户只确认取消事项 | 本地订单投影当前值、`nights`、`breakfast_included`、Group Booking Status、Rate Code、房型房量、Block ID、Confirmation Number | -| `TRACE_RESERVATION_NOTES` | `trace_items[].content`、`trace_items[].department_code` | Department 第一版只允许 `FO`、`HSK`、`FO+HSK`,不允许自由文本 | +| `TRACE_RESERVATION_NOTES` | GENERAL:`trace_items[].text`、`trace_items[].department_code`;EXTRA_BED:`trace_items[].target_room_type_code`、`trace_items[].extra_bed_room_count`、`trace_items[].department_code` | `department_code` 第一版只允许 `FO`、`HSK`、`FO+HSK`,字段返回 `fixed_options[]` 三个固定选项,不允许自由文本;`target_room_type_code` 校验当前酒店 Room Type 目录;`extra_bed_room_count` 必须为正整数;不返回或确认 `content`、`target_order`、邮件正文、附件 URL、raw evidence 或 AI 原始 payload | | `ROOMING_LIST` | 无;用户只确认 Rooming List 事项 | 来源邮件正文和附件摘要通过底部 `SOURCE_MESSAGE_DISPLAY` 查看;确认后 Group 自动置为 `DEF` | | `PAYMENT` | 无;用户只确认附件关联事项 | `attachment_ids[]`、`payment_attachments[]` 安全摘要、图片缩略图、文件名、类型、大小、预览 / 下载可用性 | | `SOURCE_MESSAGE_DISPLAY` | 无 | 当前触发该 V4 order task 的 SourceMessage 正文,只读且默认长度折叠,可展开全文 | diff --git a/docs/project/security-access-control-boundary.md b/docs/project/security-access-control-boundary.md index 53f5a5c..17003e9 100644 --- a/docs/project/security-access-control-boundary.md +++ b/docs/project/security-access-control-boundary.md @@ -49,7 +49,7 @@ | `GET /api/reservation/tasks/{taskId}` | `FRONTEND_USER` | 已强制 Bearer 登录 + `RESERVATION_TASK_READ`;按任务实际所属酒店校验访问权 | 保持登录 + `RESERVATION_TASK_READ` + 任务所属酒店访问权 | 只读查询默认不写业务审计 | | `GET /api/reservation/workbench-items` | `FRONTEND_USER` | 已实现 M002 V4 CP5;强制 Bearer 登录 + `RESERVATION_TASK_READ` + 酒店访问权 | 保持登录 + `RESERVATION_TASK_READ` + 酒店访问权;统一返回 V4 业务订单任务和 S10/S99 来源通知摘要 | 只读查询默认不写业务审计;不得返回邮件正文、附件 URL、AI 原始 payload 或来源通知原始 payload;同来源时间下使用 `updated_at` / `created_at` / 数字 ID 稳定排序 | | `GET /api/reservation/order-tasks` | `FRONTEND_USER` | 已实现 M002 V4 CP5;强制 Bearer 登录 + `RESERVATION_TASK_READ` + 酒店访问权 | 保持登录 + `RESERVATION_TASK_READ` + 酒店访问权;只返回 V4 业务订单任务,不返回 S10/S99 来源通知 | 只读查询默认不写业务审计;不得返回 AI 原始 payload;`card_status` 只匹配业务 / 可处理卡,固定来源邮件展示卡不参与筛选 | -| `GET /api/reservation/order-tasks/{orderTaskId}` | `FRONTEND_USER` | 已实现 M002 V4 CP5;强制 Bearer 登录 + `RESERVATION_TASK_READ` + 订单任务所属酒店访问权 | 保持登录 + `RESERVATION_TASK_READ` + 订单任务所属酒店访问权;V4 任务详情页展示顺序为 Basic Information、业务卡、SourceMessage Display;Basic Information 不返回 Agent `target_order`;Room Information 展示模型只返回当前酒店本地订单投影、Agent 白名单字段和系统派生值;普通业务卡 `display_payload` / `confirmed_payload` 会移除 Agent `target_order`、邮件 HTML、raw evidence、附件原始 URL 和 PMS 原始响应等敏感字段;`fields[].write_target` 只返回前端安全语义,不暴露内部列名;Payment 卡可返回付款凭证附件安全摘要;Payment 第一版 `attachment_ids[]` 只读展示,不支持前端增删或替换附件集合 | 只读查询默认不写业务审计;本接口不得直接返回邮件正文、HTML 或附件 URL,来源邮件卡正文和 Payment 图片预览 / 非图片下载必须通过 `GET /api/source-messages/{id}/conversation` 的 SourceMessage 原文权限链路读取;Basic Information、Room Information 展示模型和普通业务卡不得返回 PMS 原始响应、价格明细、AI 原始 payload、Agent 原始 `target_order` 或跨酒店订单值;Payment 安全摘要只能包含 `attachment_id`、`file_name`、`content_type`、`size_bytes`、`is_image`、`preview_available`、`download_available`、可选 `external_media_id` / `unavailable_reason_code`;`external_media_id` 只用于前端匹配会话媒体对象,不是 URL;不得返回 `ai_payload_json`;同批次 `adapter_contract_errors[]` 只返回白名单诊断字段;前端普通业务卡如遇 URL-like 附件字符串必须二次脱敏 | +| `GET /api/reservation/order-tasks/{orderTaskId}` | `FRONTEND_USER` | 已实现 M002 V4 CP5;强制 Bearer 登录 + `RESERVATION_TASK_READ` + 订单任务所属酒店访问权 | 保持登录 + `RESERVATION_TASK_READ` + 订单任务所属酒店访问权;V4 任务详情页展示顺序为 Basic Information、业务卡、SourceMessage Display;Basic Information 不返回 Agent `target_order`;Room Information 展示模型只返回当前酒店本地订单投影、Agent 白名单字段和系统派生值;Trace 卡只返回 `trace_items[].text`、`department_code`、`target_room_type_code`、`extra_bed_room_count` 等白名单字段,不返回 `content`;普通业务卡 `display_payload` / `confirmed_payload` 会移除 Agent `target_order`、邮件 HTML、raw evidence、附件原始 URL 和 PMS 原始响应等敏感字段;`fields[].write_target` 只返回前端安全语义,不暴露内部列名;Payment 卡可返回付款凭证附件安全摘要;Payment 第一版 `attachment_ids[]` 只读展示,不支持前端增删或替换附件集合 | 只读查询默认不写业务审计;本接口不得直接返回邮件正文、HTML 或附件 URL,来源邮件卡正文和 Payment 图片预览 / 非图片下载必须通过 `GET /api/source-messages/{id}/conversation` 的 SourceMessage 原文权限链路读取;Basic Information、Room Information 展示模型、Trace 卡和普通业务卡不得返回 PMS 原始响应、价格明细、AI 原始 payload、Agent 原始 `target_order`、`content`、raw evidence 或跨酒店订单值;Payment 安全摘要只能包含 `attachment_id`、`file_name`、`content_type`、`size_bytes`、`is_image`、`preview_available`、`download_available`、可选 `external_media_id` / `unavailable_reason_code`;`external_media_id` 只用于前端匹配会话媒体对象,不是 URL;不得返回 `ai_payload_json`;同批次 `adapter_contract_errors[]` 只返回白名单诊断字段;前端普通业务卡如遇 URL-like 附件字符串必须二次脱敏 | | `GET /api/reservation/order-tasks/{orderTaskId}/audits` | `FRONTEND_USER` | 已强制 Bearer 登录 + `RESERVATION_AUDIT_READ` + V4 订单任务所属酒店访问权 | 保持登录 + `RESERVATION_AUDIT_READ` + 订单任务所属酒店访问权;返回卡片确认、复核解阻和 `V4_ROOMING_LIST_AUTO_DEF` 自动 DEF 审计摘要 | 查询审计不再写审计;返回快照必须脱敏,不返回原始邮件正文、HTML、附件 URL、AI 原始 payload、token 或 secret | | `GET /api/reservation/source-notifications/{notificationId}` | `FRONTEND_USER` | 已实现 M002 V4 CP5;强制 Bearer 登录 + `RESERVATION_TASK_READ` + 来源通知所属酒店访问权 | 保持登录 + `RESERVATION_TASK_READ` + 来源通知所属酒店访问权 | 只读查询默认不写业务审计;邮件正文和附件读取仍走 SourceMessage 原文权限;不得返回来源通知原始 payload 或附件 URL;前端普通通知卡如遇 URL-like 附件字符串必须二次脱敏 | | `GET /api/reservation/source-notifications/{notificationId}/audits` | `FRONTEND_USER` | 已强制 Bearer 登录 + `RESERVATION_AUDIT_READ` + 来源通知所属酒店访问权 | 保持登录 + `RESERVATION_AUDIT_READ` + 来源通知所属酒店访问权;仅返回 S10/S99 来源通知 ack 审计摘要 | 查询审计不再写审计;返回快照必须脱敏,不返回原始邮件正文、HTML、附件 URL、AI 原始 payload、token 或 secret | diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationV4TaskCardFieldOptionResult.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationV4TaskCardFieldOptionResult.java new file mode 100644 index 0000000..662878e --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationV4TaskCardFieldOptionResult.java @@ -0,0 +1,17 @@ +package cn.nianxx.thhotel.workflows.reservation.common.result; + +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Reservation V4 任务卡字段固定选项结果。用于无独立 lookup API 的小范围固定枚举。 + * + * @param value 选项稳定值,前端提交时使用 + * @param label 选项展示文案 + */ +public record ReservationV4TaskCardFieldOptionResult( + @JsonProperty("value") + String value, + @JsonProperty("label") + String label +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationV4TaskCardFieldResult.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationV4TaskCardFieldResult.java index 360e415..ec995ec 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationV4TaskCardFieldResult.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationV4TaskCardFieldResult.java @@ -17,6 +17,7 @@ import java.util.List; * @param editScope 编辑范围,区分普通确认、人工复核和只读 * @param writeTarget 用户修改值写入目标 * @param optionsSource 选项来源,目录未接口化前使用稳定代码 + * @param fixedOptions 固定选项列表;仅用于无 lookup API 的小范围枚举 * @param rawReadonly 是否来源、派生或诊断字段,只读展示 * @param validationErrors 当前字段对应的后端校验错误 * @param controlHint 给前端的控件补充提示 @@ -39,6 +40,8 @@ public record ReservationV4TaskCardFieldResult( String writeTarget, @JsonProperty("options_source") String optionsSource, + @JsonProperty("fixed_options") + List fixedOptions, @JsonProperty("raw_readonly") Boolean rawReadonly, @JsonProperty("validation_errors") diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationAiV4CallbackValidator.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationAiV4CallbackValidator.java index 8c1f8d0..f9a9687 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationAiV4CallbackValidator.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationAiV4CallbackValidator.java @@ -423,7 +423,7 @@ final class ReservationAiV4CallbackValidator { || !traceItem.has("department_code") || !isStringOrNull(traceItem.path("target_room_type_code")) || !isStringOrNull(traceItem.path("department_code")) - || !isPositiveIntOrNull(traceItem.path("extra_bed_room_count"))) { + || !isNumberOrNull(traceItem.path("extra_bed_room_count"))) { return new ReservationAiV4CallbackIssue("TRACE_EXTRA_BED_ITEM_INVALID", "V4 EXTRA_BED trace item 字段无效。"); } continue; @@ -624,6 +624,13 @@ final class ReservationAiV4CallbackValidator { return node != null && (node.isNull() || node.isTextual()); } + /** + * 判断字段值是否为数字或 null。Trace 加床数量的正整数业务校验由 V4 卡片校验承接,避免可人工修正值被契约层拦截。 + */ + private boolean isNumberOrNull(JsonNode node) { + return node != null && (node.isNull() || node.isNumber()); + } + /** * 判断 JSON 节点是否为布尔 true。 */ 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 5f7ec6f..893cff5 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 @@ -538,6 +538,10 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ overlayRoomInformationPayload(orderTask, card, payload, submittedPayload); return; } + if (isTraceCard(card)) { + overlayTracePayload(payload, submittedPayload); + return; + } overlayBusinessPayload(card, payload, submittedPayload); } @@ -684,6 +688,40 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ } } + /** + * Trace 卡只允许前端写入当前正式字段,不接受 content、target_order 或新增结构。 + */ + private void overlayTracePayload(ObjectNode payload, ObjectNode submittedPayload) { + JsonNode traceItems = payload.path("trace_items"); + if (!traceItems.isArray()) { + return; + } + JsonNode submittedTraceItems = submittedPayload.path("trace_items"); + if (!submittedTraceItems.isArray()) { + submittedTraceItems = submittedPayload.path("business_fields").path("trace_items"); + } + if (!submittedTraceItems.isArray()) { + return; + } + int size = Math.min(traceItems.size(), submittedTraceItems.size()); + for (int index = 0; index < size; index++) { + JsonNode currentItem = traceItems.get(index); + JsonNode submittedItem = submittedTraceItems.get(index); + if (currentItem == null || !currentItem.isObject() || submittedItem == null || !submittedItem.isObject()) { + continue; + } + ObjectNode targetItem = (ObjectNode) currentItem; + for (ReservationV4TraceFieldPolicy.TraceEditableField field : + ReservationV4TraceFieldPolicy.editableFields(currentItem)) { + JsonNode submittedValue = submittedItem.get(field.fieldName()); + JsonNode normalizedValue = ReservationV4TraceFieldPolicy.normalizedScalarValue(field.fieldName(), submittedValue); + if (normalizedValue != null) { + targetItem.set(field.fieldName(), normalizedValue); + } + } + } + } + private boolean isRoomInformationPathWritable( ReservationV4TaskCardSnapshot card, String bookingType, @@ -782,12 +820,26 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ if (ReservationV4CardType.PAYMENT.name().equals(card.cardType()) && "attachment_ids".equals(path.get(0))) { return false; } + if (isTraceCard(card)) { + return isTraceBusinessPathWritable(path); + } if (path.stream().anyMatch(REVIEW_READONLY_ROOT_FIELDS::contains)) { return false; } return wrappedBusinessFields || BUSINESS_WRITABLE_ROOT_FIELDS.contains(path.get(0)); } + private boolean isTraceBusinessPathWritable(List path) { + if (path == null || path.size() != 3 || !"trace_items".equals(path.get(0))) { + return false; + } + String fieldName = path.get(2); + return ReservationV4TraceFieldPolicy.FIELD_TEXT.equals(fieldName) + || ReservationV4TraceFieldPolicy.FIELD_DEPARTMENT_CODE.equals(fieldName) + || ReservationV4TraceFieldPolicy.FIELD_TARGET_ROOM_TYPE_CODE.equals(fieldName) + || ReservationV4TraceFieldPolicy.FIELD_EXTRA_BED_ROOM_COUNT.equals(fieldName); + } + private void validateAndEnrichConfirmedPayload(String hotelId, ReservationV4TaskCardSnapshot card, ObjectNode payload) { List details = new ArrayList<>(); if (ReservationV4CardType.BASIC_INFORMATION.name().equals(card.cardType())) { @@ -851,6 +903,16 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ validateRoomInformationPayload(hotelId, card, payload.path("room_information"), roomInformationFinalValues, details); return; } + if (isTraceCard(card)) { + JsonNode traceItems = ReservationV4TraceFieldPolicy.traceItems(payload); + String fieldPath = payload.path("business_fields").isObject() ? "business_fields.trace_items" : "trace_items"; + ReservationV4TraceFieldPolicy.validateTraceItems( + traceItems, + fieldPath, + details, + code -> directoryService.isKnownRoomTypeCode(hotelId, code)); + return; + } JsonNode businessFields = payload.path("business_fields"); JsonNode fieldRoot = businessFields.isObject() ? businessFields : payload; validateBusinessCatalogFields(hotelId, fieldRoot, "business_fields", details); @@ -953,6 +1015,12 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ validateRoomTypeCode(hotelId, field.getValue(), childPath, details); } else if ("room_items".equals(field.getKey())) { validateRoomItems(hotelId, field.getValue(), childPath, details); + } else if ("trace_items".equals(field.getKey())) { + ReservationV4TraceFieldPolicy.validateTraceItems( + field.getValue(), + childPath, + details, + code -> directoryService.isKnownRoomTypeCode(hotelId, code)); } else { validateBusinessCatalogFields(hotelId, field.getValue(), childPath, details); } @@ -1018,6 +1086,10 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ || EVENT_CANCEL_BOOKING.equals(card.eventType()); } + private boolean isTraceCard(ReservationV4TaskCardSnapshot card) { + return card != null && ReservationV4CardType.TRACE_RESERVATION_NOTES.name().equals(card.cardType()); + } + private String textValue(JsonNode node) { if (node == null || node.isMissingNode() || node.isNull() || !node.isTextual()) { return null; @@ -1056,6 +1128,9 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ if (shouldUseStableRoomInformationPayload(card, displayPayload, submittedPayload, null)) { return roomInformationConfirmedPayloadBase(orderTask, card, displayPayload); } + if (isTraceCard(card)) { + return traceConfirmedPayloadBase(card, displayPayload); + } return displayPayload; } @@ -1070,9 +1145,25 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ if (shouldUseStableRoomInformationPayload(card, displayPayload, null, fieldOverrides)) { return roomInformationConfirmedPayloadBase(orderTask, card, displayPayload); } + if (isTraceCard(card)) { + return traceConfirmedPayloadBase(card, displayPayload); + } return displayPayload; } + /** + * 生成 Trace 确认基础快照,对外只保留正式 trace_items 字段。 + */ + private ObjectNode traceConfirmedPayloadBase(ReservationV4TaskCardSnapshot card, ObjectNode displayPayload) { + ObjectNode payload = objectMapper.createObjectNode(); + payload.put("card_type", ReservationV4CardType.TRACE_RESERVATION_NOTES.name()); + payload.put("event_type", firstText(card.eventType(), textAt(displayPayload, "event_type"))); + payload.set("trace_items", ReservationV4TraceFieldPolicy.sanitizedTraceItems( + objectMapper, + ReservationV4TraceFieldPolicy.traceItems(displayPayload))); + return payload; + } + private boolean shouldUseStableRoomInformationPayload( ReservationV4TaskCardSnapshot card, JsonNode displayPayload, @@ -1711,9 +1802,10 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ throw error(HttpStatus.BAD_REQUEST, "V4_REVIEW_VALUE_INVALID", "复核字段值必须是标量或 null,不能替换对象或数组。"); } boolean stableRoomInformationPointer = isStableRoomInformationReviewPointer(confirmedPayload, pointer); + boolean tracePointer = isTraceReviewPointer(card, confirmedPayload, segments); JsonNode current = findPointerValue(confirmedPayload, segments); if (current == null || current.isMissingNode()) { - if (stableRoomInformationPointer) { + if (stableRoomInformationPointer || tracePointer) { return; } logReviewPointerRejected(card, pointer, "CURRENT_POINTER_MISSING", confirmedPayload); @@ -1750,6 +1842,13 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ ensureRoomInformationReviewPointerWritable(card, confirmedPayload, pointer, segments); return; } + if (isTraceCard(card)) { + if (!ReservationV4TraceFieldPolicy.isWritablePointer(segments, confirmedPayload)) { + logReviewPointerRejected(card, pointer, "TRACE_POINTER_NOT_WRITABLE", confirmedPayload); + throw error(HttpStatus.BAD_REQUEST, "V4_REVIEW_POINTER_NOT_ALLOWED", "复核字段不在当前 Trace 卡允许编辑字段内。"); + } + return; + } if ("business_fields".equals(root)) { return; } @@ -1826,6 +1925,13 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ && pointer.startsWith("/room_information/final_values/"); } + private boolean isTraceReviewPointer( + ReservationV4TaskCardSnapshot card, + ObjectNode confirmedPayload, + List segments) { + return isTraceCard(card) && ReservationV4TraceFieldPolicy.isWritablePointer(segments, confirmedPayload); + } + private List roomInformationQuerySideEditablePointers( ReservationV4TaskCardSnapshot card, ObjectNode confirmedPayload) { @@ -1970,6 +2076,11 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ && pointer.startsWith("/room_information/final_values/")) { return true; } + if (isTraceCard(card) && ReservationV4TraceFieldPolicy.isWritablePointer( + decodeJsonPointer(pointer), + confirmedPayload)) { + return true; + } Set validationPointers = collectValidationErrorPointers(card.validationErrorsJson()); if (validationPointers.contains(pointer)) { return true; 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 41f335f..38bd436 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 @@ -39,6 +39,7 @@ import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4Source import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4SourceNotificationDetailResult; import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4SourceNotificationSummaryResult; import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4TaskCardFieldResult; +import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4TaskCardFieldOptionResult; import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4TaskCardResult; import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4WorkbenchItemResult; import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4WorkbenchListResult; @@ -88,6 +89,10 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService 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 List TRACE_DEPARTMENT_FIXED_OPTIONS = List.of( + new ReservationV4TaskCardFieldOptionResult("FO", "FO"), + new ReservationV4TaskCardFieldOptionResult("HSK", "HSK"), + new ReservationV4TaskCardFieldOptionResult("FO+HSK", "FO+HSK")); private static final String LOCATOR_TYPE_GROUP_CODE = "GROUP_CODE"; private static final String WRITE_TARGET_CONFIRMED_PAYLOAD = "confirmed_payload"; private static final String WRITE_TARGET_REVIEW_FIELD_OVERRIDES = "review_resolution.field_overrides"; @@ -460,6 +465,9 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService if (ReservationV4CardType.PAYMENT.name().equals(card.cardType())) { return paymentDisplayPayload(displayPayload, paymentAttachmentSources); } + if (ReservationV4CardType.TRACE_RESERVATION_NOTES.name().equals(card.cardType())) { + return traceDisplayPayload(card, displayPayload); + } if (!isRoomInformationEventCard(card) || !displayPayload.isObject()) { return safeBusinessPayload(displayPayload); } @@ -482,6 +490,12 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService if (ReservationV4CardType.BASIC_INFORMATION.name().equals(card.cardType())) { return safeBasicInformationPayload(confirmedPayload); } + if (ReservationV4CardType.TRACE_RESERVATION_NOTES.name().equals(card.cardType())) { + if (!hasText(card.confirmedPayloadJson())) { + return NullNode.getInstance(); + } + return traceDisplayPayload(card, confirmedPayload); + } return safeBusinessPayload(confirmedPayload); } @@ -532,6 +546,22 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService return copied; } + /** + * 生成 Trace 卡安全展示 payload;前端只读取 trace_items[].text 等正式字段。 + */ + private JsonNode traceDisplayPayload(ReservationV4TaskCardSnapshot card, JsonNode displayPayload) { + ObjectNode safePayload = objectMapper.createObjectNode(); + safePayload.put("card_type", ReservationV4CardType.TRACE_RESERVATION_NOTES.name()); + safePayload.put("event_type", firstText(card.eventType(), textAt(displayPayload, "event_type"))); + if (card.sourceEventIndex() != null) { + safePayload.put("source_event_index", card.sourceEventIndex()); + } + safePayload.set("trace_items", ReservationV4TraceFieldPolicy.sanitizedTraceItems( + objectMapper, + ReservationV4TraceFieldPolicy.traceItems(displayPayload))); + return safePayload; + } + /** * 生成 Payment 卡展示 payload,补充付款凭证附件安全摘要,不返回任何 URL 字段。 */ @@ -1366,6 +1396,9 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService if (isRoomInformationEventCard(card) && displayPayload.path("room_information").isObject()) { return roomInformationFields(card, displayPayload.path("room_information"), validationErrors, availability); } + if (ReservationV4CardType.TRACE_RESERVATION_NOTES.name().equals(card.cardType())) { + return traceFields(card, displayPayload, validationErrors, availability); + } return businessFields(card, displayPayload, validationErrors, availability); } @@ -2033,6 +2066,77 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService || EVENT_CANCEL_BOOKING.equals(card.eventType()); } + /** + * 构造 Trace 卡专属字段白名单;字段指针固定使用 /trace_items/{index}/...。 + */ + private List traceFields( + ReservationV4TaskCardSnapshot card, + JsonNode displayPayload, + JsonNode validationErrors, + ReservationV4ActionAvailabilityResult availability) { + JsonNode traceItems = displayPayload.path("trace_items"); + if (!traceItems.isArray()) { + traceItems = ReservationV4TraceFieldPolicy.traceItems(displayPayload); + } + if (!traceItems.isArray()) { + return List.of(); + } + List fields = new ArrayList<>(); + for (int index = 0; index < traceItems.size(); index++) { + JsonNode traceItem = traceItems.get(index); + for (ReservationV4TraceFieldPolicy.TraceEditableField editableField : + ReservationV4TraceFieldPolicy.editableFields(traceItem)) { + String fieldPath = "trace_items." + index + "." + editableField.fieldName(); + String pointer = "/trace_items/" + index + "/" + escapeJsonPointer(editableField.fieldName()); + JsonNode value = traceItem == null ? NullNode.getInstance() : traceItem.get(editableField.fieldName()); + if (value == null || value.isMissingNode()) { + value = NullNode.getInstance(); + } + List fieldValidationErrors = traceValidationMessages(validationErrors, pointer, fieldPath); + boolean editable = traceFieldEditable(availability); + fields.add(field( + fieldPath, + pointer, + editableField.displayName(), + value, + editable, + true, + editableField.controlType(), + availability.reviewable() ? "manual_review_only" : "confirm", + availability.reviewable() ? WRITE_TARGET_REVIEW_FIELD_OVERRIDES : WRITE_TARGET_CONFIRMED_PAYLOAD, + editableField.optionsSource(), + traceFixedOptions(editableField), + false, + fieldValidationErrors, + editableField.controlHint())); + } + } + return fields; + } + + private List traceFixedOptions( + ReservationV4TraceFieldPolicy.TraceEditableField editableField) { + if (ReservationV4TraceFieldPolicy.OPTIONS_SOURCE_DEPARTMENT.equals(editableField.optionsSource())) { + return TRACE_DEPARTMENT_FIXED_OPTIONS; + } + return List.of(); + } + + private boolean traceFieldEditable(ReservationV4ActionAvailabilityResult availability) { + return availability.editable(); + } + + private List traceValidationMessages(JsonNode validationErrors, String pointer, String fieldPath) { + List messages = validationMessages(validationErrors, pointer, fieldPath); + if (!messages.isEmpty()) { + return messages; + } + return validationMessages( + validationErrors, + "/business_fields" + pointer, + "business_fields." + fieldPath); + } + private void collectBusinessLeafFields( List fields, ReservationV4TaskCardSnapshot card, @@ -2122,6 +2226,38 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService Boolean rawReadonly, List validationErrors, String controlHint) { + return field( + fieldPath, + fieldPointer, + displayName, + value, + editable, + required, + controlType, + editScope, + writeTarget, + optionsSource, + List.of(), + rawReadonly, + validationErrors, + controlHint); + } + + private ReservationV4TaskCardFieldResult field( + String fieldPath, + String fieldPointer, + String displayName, + JsonNode value, + Boolean editable, + Boolean required, + String controlType, + String editScope, + String writeTarget, + String optionsSource, + List fixedOptions, + Boolean rawReadonly, + List validationErrors, + String controlHint) { return new ReservationV4TaskCardFieldResult( fieldPath, fieldPointer, @@ -2133,6 +2269,7 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService editScope, writeTarget, optionsSource, + fixedOptions == null ? List.of() : fixedOptions, rawReadonly, validationErrors == null ? List.of() : validationErrors, controlHint); 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 5e1aabd..41b635d 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 @@ -214,7 +214,9 @@ public class ReservationV4TaskIntakeServiceImpl implements ReservationV4TaskInta Long orderTaskId, ReservationV4AcceptedEventDraft acceptedEvent, LocalDateTime now) { - ObjectNode displayPayload = objectJson(acceptedEvent.displayPayloadJson()); + ObjectNode displayPayload = normalizedBusinessDisplayPayload( + acceptedEvent.eventType(), + objectJson(acceptedEvent.displayPayloadJson())); List validationDetails = validateBusinessCardDisplayPayload(sourceMessage.hotelId(), displayPayload); boolean reviewRequired = acceptedEvent.manualReviewRequired() || !validationDetails.isEmpty(); workflowRepository.insertTaskCard(new ReservationV4TaskCardDraft( @@ -235,6 +237,22 @@ public class ReservationV4TaskIntakeServiceImpl implements ReservationV4TaskInta )); } + /** + * 归一化业务卡展示快照;Trace 卡只保留正式字段,避免 content 或原始定位字段进入后端存储展示层。 + */ + private ObjectNode normalizedBusinessDisplayPayload(String eventType, ObjectNode displayPayload) { + if (!"TRACE_RESERVATION_NOTES".equals(eventType)) { + return displayPayload; + } + ObjectNode safePayload = objectMapper.createObjectNode(); + safePayload.put("card_type", ReservationV4CardType.TRACE_RESERVATION_NOTES.name()); + safePayload.put("event_type", eventType); + safePayload.set("trace_items", ReservationV4TraceFieldPolicy.sanitizedTraceItems( + objectMapper, + ReservationV4TraceFieldPolicy.traceItems(displayPayload))); + return safePayload; + } + /** * 按 order_ref 保留 V4 event 原始数组顺序。 */ @@ -399,6 +417,14 @@ public class ReservationV4TaskIntakeServiceImpl implements ReservationV4TaskInta */ private List validateBusinessCardDisplayPayload(String hotelId, ObjectNode displayPayload) { List details = new ArrayList<>(); + if (ReservationV4CardType.TRACE_RESERVATION_NOTES.name().equals(textAt(displayPayload, "card_type"))) { + ReservationV4TraceFieldPolicy.validateTraceItems( + ReservationV4TraceFieldPolicy.traceItems(displayPayload), + "trace_items", + details, + code -> directoryService.isKnownRoomTypeCode(hotelId, code)); + return details; + } JsonNode businessFields = displayPayload.path("business_fields"); JsonNode fieldRoot = businessFields.isObject() ? businessFields : displayPayload; validateBusinessCatalogFields(hotelId, fieldRoot, "business_fields", details); @@ -420,6 +446,12 @@ public class ReservationV4TaskIntakeServiceImpl implements ReservationV4TaskInta validateRoomTypeCode(hotelId, field.getValue(), childPath, details); } else if ("room_items".equals(field.getKey())) { validateRoomItems(hotelId, field.getValue(), childPath, details); + } else if ("trace_items".equals(field.getKey())) { + ReservationV4TraceFieldPolicy.validateTraceItems( + field.getValue(), + childPath, + details, + code -> directoryService.isKnownRoomTypeCode(hotelId, code)); } else { validateBusinessCatalogFields(hotelId, field.getValue(), childPath, details); } diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationV4TraceFieldPolicy.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationV4TraceFieldPolicy.java new file mode 100644 index 0000000..ae01989 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationV4TraceFieldPolicy.java @@ -0,0 +1,289 @@ +package cn.nianxx.thhotel.workflows.reservation.service.impl; + +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.NullNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fasterxml.jackson.databind.node.TextNode; +import java.math.BigInteger; +import java.util.List; +import java.util.Set; +import java.util.function.Predicate; + +/** + * Reservation V4 Trace 卡字段策略。统一查询展示、确认写入、复核指针和字段校验的白名单。 + */ +final class ReservationV4TraceFieldPolicy { + + static final String OPTIONS_SOURCE_DEPARTMENT = "reservation_v4_trace_department_fixed"; + static final String OPTIONS_SOURCE_ROOM_TYPE = "reservation_v4_room_type_catalog"; + static final String ITEM_TYPE_GENERAL = "GENERAL"; + static final String ITEM_TYPE_EXTRA_BED = "EXTRA_BED"; + static final String FIELD_TEXT = "text"; + static final String FIELD_DEPARTMENT_CODE = "department_code"; + static final String FIELD_TARGET_ROOM_TYPE_CODE = "target_room_type_code"; + static final String FIELD_EXTRA_BED_ROOM_COUNT = "extra_bed_room_count"; + private static final String FIELD_ITEM_TYPE = "item_type"; + private static final Set ALLOWED_DEPARTMENT_CODES = Set.of("FO", "HSK", "FO+HSK"); + + private ReservationV4TraceFieldPolicy() { + } + + /** + * 从当前 payload 中提取 Trace 事项数组,兼容历史 business_fields 包裹结构。 + */ + static JsonNode traceItems(JsonNode payload) { + if (payload == null || payload.isMissingNode() || payload.isNull()) { + return NullNode.getInstance(); + } + JsonNode businessFields = payload.path("business_fields"); + if (businessFields.isObject()) { + return businessFields.path("trace_items"); + } + return payload.path("trace_items"); + } + + /** + * 生成安全 Trace 事项数组,只保留当前后端正式字段,不透出 content、target_order 或原始证据。 + */ + static ArrayNode sanitizedTraceItems(ObjectMapper objectMapper, JsonNode traceItems) { + ArrayNode sanitizedItems = objectMapper.createArrayNode(); + if (traceItems == null || !traceItems.isArray()) { + return sanitizedItems; + } + for (JsonNode traceItem : traceItems) { + if (traceItem == null || !traceItem.isObject()) { + continue; + } + ObjectNode sanitizedItem = objectMapper.createObjectNode(); + copyScalarField(traceItem, sanitizedItem, FIELD_ITEM_TYPE); + String itemType = textAt(traceItem, FIELD_ITEM_TYPE); + if (ITEM_TYPE_GENERAL.equals(itemType)) { + copyScalarField(traceItem, sanitizedItem, FIELD_TEXT); + copyScalarField(traceItem, sanitizedItem, FIELD_DEPARTMENT_CODE); + } else if (ITEM_TYPE_EXTRA_BED.equals(itemType)) { + copyScalarField(traceItem, sanitizedItem, FIELD_TARGET_ROOM_TYPE_CODE); + copyScalarField(traceItem, sanitizedItem, FIELD_EXTRA_BED_ROOM_COUNT); + copyScalarField(traceItem, sanitizedItem, FIELD_DEPARTMENT_CODE); + } else { + copyScalarField(traceItem, sanitizedItem, FIELD_TEXT); + copyScalarField(traceItem, sanitizedItem, FIELD_TARGET_ROOM_TYPE_CODE); + copyScalarField(traceItem, sanitizedItem, FIELD_EXTRA_BED_ROOM_COUNT); + copyScalarField(traceItem, sanitizedItem, FIELD_DEPARTMENT_CODE); + } + sanitizedItems.add(sanitizedItem); + } + return sanitizedItems; + } + + /** + * 返回指定 Trace item 当前允许前端编辑的字段清单。 + */ + static List editableFields(JsonNode traceItem) { + String itemType = textAt(traceItem, FIELD_ITEM_TYPE); + if (ITEM_TYPE_EXTRA_BED.equals(itemType)) { + return List.of( + new TraceEditableField(FIELD_TARGET_ROOM_TYPE_CODE, "目标房型", "select", + OPTIONS_SOURCE_ROOM_TYPE, "Room Type 使用酒店目录 lookup,不能自由输入。"), + new TraceEditableField(FIELD_EXTRA_BED_ROOM_COUNT, "加床房间数", "number", + null, "加床房间数必须是正整数。"), + new TraceEditableField(FIELD_DEPARTMENT_CODE, "Department Code", "select", + OPTIONS_SOURCE_DEPARTMENT, "固定选项:FO / HSK / FO+HSK。")); + } + return List.of( + new TraceEditableField(FIELD_TEXT, "Trace 内容", "textarea", + null, "普通 Trace 事项内容,以 trace_items[].text 为准。"), + new TraceEditableField(FIELD_DEPARTMENT_CODE, "Department Code", "select", + OPTIONS_SOURCE_DEPARTMENT, "固定选项:FO / HSK / FO+HSK。")); + } + + /** + * 判断复核或确认写入 pointer 是否属于 Trace 卡当前正式字段白名单。 + */ + static boolean isWritablePointer(List segments, JsonNode payload) { + if (segments == null || segments.size() != 3 || !"trace_items".equals(segments.get(0))) { + return false; + } + Integer index = parseArrayIndex(segments.get(1)); + if (index == null) { + return false; + } + JsonNode traceItems = traceItems(payload); + if (!traceItems.isArray() || index >= traceItems.size()) { + return false; + } + String fieldName = segments.get(2); + for (TraceEditableField field : editableFields(traceItems.get(index))) { + if (field.fieldName().equals(fieldName)) { + return true; + } + } + return false; + } + + /** + * 校验 Trace 字段值,字段路径由调用方决定是否带 business_fields 前缀。 + */ + static void validateTraceItems( + JsonNode traceItems, + String fieldPath, + List details, + Predicate roomTypeExists) { + if (traceItems == null || traceItems.isMissingNode() || traceItems.isNull()) { + details.add(fieldPath + ": Trace 事项不能为空。"); + return; + } + if (!traceItems.isArray() || traceItems.isEmpty()) { + details.add(fieldPath + ": Trace 事项必须是非空数组。"); + return; + } + for (int index = 0; index < traceItems.size(); index++) { + JsonNode item = traceItems.get(index); + String itemPath = fieldPath + "." + index; + if (item == null || !item.isObject()) { + details.add(itemPath + ": Trace 事项必须是对象。"); + continue; + } + String itemType = textAt(item, FIELD_ITEM_TYPE); + if (!ITEM_TYPE_GENERAL.equals(itemType) && !ITEM_TYPE_EXTRA_BED.equals(itemType)) { + details.add(itemPath + ".item_type: Trace item_type 只能为 GENERAL 或 EXTRA_BED。"); + continue; + } + validateDepartmentCode(item, itemPath, details); + if (ITEM_TYPE_GENERAL.equals(itemType)) { + validateRequiredText(item, FIELD_TEXT, itemPath + "." + FIELD_TEXT, "Trace 内容", details); + } else { + validateTargetRoomTypeCode(item, itemPath, details, roomTypeExists); + validateExtraBedRoomCount(item, itemPath, details); + } + } + } + + /** + * 判断 Department Code 是否属于第一版固定三值。 + */ + static boolean isAllowedDepartmentCode(String departmentCode) { + return departmentCode != null && ALLOWED_DEPARTMENT_CODES.contains(departmentCode); + } + + private static void validateDepartmentCode(JsonNode item, String itemPath, List details) { + String departmentCode = textAt(item, FIELD_DEPARTMENT_CODE); + if (!isAllowedDepartmentCode(departmentCode)) { + details.add(itemPath + "." + FIELD_DEPARTMENT_CODE + ": Department Code 只允许 FO、HSK、FO+HSK。"); + } + } + + private static void validateTargetRoomTypeCode( + JsonNode item, + String itemPath, + List details, + Predicate roomTypeExists) { + String code = textAt(item, FIELD_TARGET_ROOM_TYPE_CODE); + if (code == null) { + details.add(itemPath + "." + FIELD_TARGET_ROOM_TYPE_CODE + ": 加床目标房型不能为空。"); + return; + } + if (roomTypeExists != null && !roomTypeExists.test(code)) { + details.add(itemPath + "." + FIELD_TARGET_ROOM_TYPE_CODE + ": 房型代码不在第一版目录中。"); + } + } + + private static void validateExtraBedRoomCount(JsonNode item, String itemPath, List details) { + JsonNode value = item.get(FIELD_EXTRA_BED_ROOM_COUNT); + if (value == null + || value.isNull() + || !value.isIntegralNumber() + || value.bigIntegerValue().compareTo(BigInteger.ZERO) <= 0 + || value.bigIntegerValue().compareTo(BigInteger.valueOf(Integer.MAX_VALUE)) > 0) { + details.add(itemPath + "." + FIELD_EXTRA_BED_ROOM_COUNT + ": 加床房间数必须为正整数。"); + } + } + + private static void validateRequiredText( + JsonNode item, + String fieldName, + String fieldPath, + String displayName, + List details) { + if (textAt(item, fieldName) == null) { + details.add(fieldPath + ": " + displayName + "不能为空。"); + } + } + + private static void copyScalarField(JsonNode source, ObjectNode target, String fieldName) { + JsonNode value = source.get(fieldName); + JsonNode normalizedValue = normalizedScalarValue(fieldName, value); + if (normalizedValue == null) { + return; + } + target.set(fieldName, normalizedValue); + } + + /** + * 归一化 Trace 稳定代码字段,避免查询、确认和复核因为前后空格产生目录校验或展示差异。 + */ + static JsonNode normalizedScalarValue(String fieldName, JsonNode value) { + if (value == null || value.isMissingNode() || value.isContainerNode()) { + return null; + } + if (value.isTextual() && isStableCodeField(fieldName)) { + return TextNode.valueOf(value.asText().trim()); + } + return value; + } + + private static boolean isStableCodeField(String fieldName) { + return FIELD_ITEM_TYPE.equals(fieldName) + || FIELD_DEPARTMENT_CODE.equals(fieldName) + || FIELD_TARGET_ROOM_TYPE_CODE.equals(fieldName); + } + + private static String textAt(JsonNode node, String fieldName) { + if (node == null || node.isMissingNode()) { + return null; + } + JsonNode value = node.get(fieldName); + if (value == null || value.isNull() || !value.isTextual()) { + return null; + } + String text = value.asText().trim(); + return text.isEmpty() ? null : text; + } + + 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.valueOf(segment); + } catch (NumberFormatException exception) { + return null; + } + } + + /** + * Trace 前端可编辑字段元数据。 + * + * @param fieldName 字段名 + * @param displayName 中文展示名 + * @param controlType 前端控件类型 + * @param optionsSource 选项来源 + * @param controlHint 控件提示 + */ + record TraceEditableField( + String fieldName, + String displayName, + String controlType, + String optionsSource, + String controlHint) { + } +} 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 9d88089..63c196f 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 @@ -556,6 +556,11 @@ class ReservationV4CommandControllerTest { "mail-v4-command-confirm-unknown-trace-target-room-001", Instant.parse("2026-07-19T01:13:05Z"), null, + "GROUP", + "GROUP_CODE", + "GRP-V4-COMMAND-TRACE-UNKNOWN-ROOM-001", + ReservationV4CardType.TRACE_RESERVATION_NOTES.name(), + "TRACE_RESERVATION_NOTES", """ {"card_type":"TRACE_RESERVATION_NOTES","event_type":"TRACE_RESERVATION_NOTES","business_fields":{"order_ref":"ORDER-COMMAND","event_type":"TRACE_RESERVATION_NOTES","trace_items":[{"item_type":"EXTRA_BED","text":"Please add one extra bed.","target_room_type_code":"UNKNOWN_ROOM","extra_bed_room_count":1,"department_code":"FO"}]}} """); @@ -572,7 +577,217 @@ class ReservationV4CommandControllerTest { """)) .andExpect(status().isBadRequest()) .andExpect(jsonPath("$.error_code").value("V4_FIELD_VALIDATION_FAILED")) - .andExpect(jsonPath("$.details[0]").value("business_fields.trace_items.0.target_room_type_code: 房型代码不在第一版目录中。")); + .andExpect(jsonPath("$.details[0]").value("trace_items.0.target_room_type_code: 房型代码不在第一版目录中。")); + } + + @Test + void shouldRejectBusinessCardConfirmWhenTraceDepartmentCodeInvalid() throws Exception { + SeededOrderTask seeded = seedOrderTaskWithBusinessCard( + HOTEL_ID, + "mail-v4-command-confirm-invalid-trace-department-001", + Instant.parse("2026-07-19T01:13:06Z"), + null, + "GROUP", + "GROUP_CODE", + "GRP-V4-COMMAND-TRACE-DEPARTMENT-001", + ReservationV4CardType.TRACE_RESERVATION_NOTES.name(), + "TRACE_RESERVATION_NOTES", + """ + {"card_type":"TRACE_RESERVATION_NOTES","event_type":"TRACE_RESERVATION_NOTES","business_fields":{"order_ref":"ORDER-COMMAND","event_type":"TRACE_RESERVATION_NOTES","trace_items":[{"item_type":"GENERAL","text":"Guest requests quiet rooms.","department_code":"SALES"}]}} + """); + 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("trace_items.0.department_code: Department Code 只允许 FO、HSK、FO+HSK。")); + } + + @Test + void shouldRejectBusinessCardConfirmWhenTraceExtraBedRoomCountNotPositive() throws Exception { + SeededOrderTask seeded = seedOrderTaskWithBusinessCard( + HOTEL_ID, + "mail-v4-command-confirm-invalid-trace-extra-bed-count-001", + Instant.parse("2026-07-19T01:13:07Z"), + null, + "GROUP", + "GROUP_CODE", + "GRP-V4-COMMAND-TRACE-EXTRA-BED-COUNT-001", + ReservationV4CardType.TRACE_RESERVATION_NOTES.name(), + "TRACE_RESERVATION_NOTES", + """ + {"card_type":"TRACE_RESERVATION_NOTES","event_type":"TRACE_RESERVATION_NOTES","business_fields":{"order_ref":"ORDER-COMMAND","event_type":"TRACE_RESERVATION_NOTES","trace_items":[{"item_type":"EXTRA_BED","target_room_type_code":"TWN","extra_bed_room_count":0,"department_code":"FO+HSK"}]}} + """); + 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("trace_items.0.extra_bed_room_count: 加床房间数必须为正整数。")); + } + + @Test + void shouldRejectBusinessCardConfirmWhenTraceExtraBedRoomCountDecimal() throws Exception { + SeededOrderTask seeded = seedOrderTaskWithBusinessCard( + HOTEL_ID, + "mail-v4-command-confirm-decimal-trace-extra-bed-count-001", + Instant.parse("2026-07-19T01:13:07Z"), + null, + "GROUP", + "GROUP_CODE", + "GRP-V4-COMMAND-TRACE-EXTRA-BED-COUNT-DECIMAL-001", + ReservationV4CardType.TRACE_RESERVATION_NOTES.name(), + "TRACE_RESERVATION_NOTES", + """ + {"card_type":"TRACE_RESERVATION_NOTES","event_type":"TRACE_RESERVATION_NOTES","business_fields":{"order_ref":"ORDER-COMMAND","event_type":"TRACE_RESERVATION_NOTES","trace_items":[{"item_type":"EXTRA_BED","target_room_type_code":"TWN","extra_bed_room_count":1.5,"department_code":"FO+HSK"}]}} + """); + 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("trace_items.0.extra_bed_room_count: 加床房间数必须为正整数。")); + } + + @Test + void shouldNormalizeTraceStableCodesWhenConfirming() throws Exception { + SeededOrderTask seeded = seedOrderTaskWithBusinessCard( + HOTEL_ID, + "mail-v4-command-confirm-trace-normalize-codes-001", + Instant.parse("2026-07-19T01:13:07Z"), + null, + "GROUP", + "GROUP_CODE", + "GRP-V4-COMMAND-TRACE-NORMALIZE-CODES-001", + ReservationV4CardType.TRACE_RESERVATION_NOTES.name(), + "TRACE_RESERVATION_NOTES", + """ + {"card_type":"TRACE_RESERVATION_NOTES","event_type":"TRACE_RESERVATION_NOTES","business_fields":{"order_ref":"ORDER-COMMAND","event_type":"TRACE_RESERVATION_NOTES","trace_items":[{"item_type":"EXTRA_BED","target_room_type_code":" TWN ","extra_bed_room_count":1,"department_code":" FO+HSK "}]}} + """); + 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": { + "trace_items": [ + { + "item_type": "EXTRA_BED", + "target_room_type_code": " DBL ", + "extra_bed_room_count": 2, + "department_code": " FO " + } + ] + } + } + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.business_cards[0].confirmed_payload.trace_items[0].target_room_type_code") + .value("DBL")) + .andExpect(jsonPath("$.business_cards[0].confirmed_payload.trace_items[0].department_code") + .value("FO")); + } + + @Test + void shouldConfirmTraceCardWithOnlyWhitelistedFields() throws Exception { + SeededOrderTask seeded = seedOrderTaskWithBusinessCard( + HOTEL_ID, + "mail-v4-command-confirm-trace-whitelist-001", + Instant.parse("2026-07-19T01:13:08Z"), + null, + "GROUP", + "GROUP_CODE", + "GRP-V4-COMMAND-TRACE-WHITELIST-001", + ReservationV4CardType.TRACE_RESERVATION_NOTES.name(), + "TRACE_RESERVATION_NOTES", + """ + { + "card_type":"TRACE_RESERVATION_NOTES", + "event_type":"TRACE_RESERVATION_NOTES", + "target_order":{"locator_value":"SHOULD-NOT-PERSIST-TRACE-TARGET"}, + "business_fields":{ + "trace_items":[ + {"item_type":"GENERAL","text":"Original trace","department_code":"FO","content":"SHOULD-NOT-PERSIST-CONTENT"}, + {"item_type":"EXTRA_BED","target_room_type_code":"TWN","extra_bed_room_count":1,"department_code":"FO+HSK","content":"SHOULD-NOT-PERSIST-EXTRA-CONTENT","attachment_url":"https://oss.example.test/trace-confirm-secret.png"} + ] + } + } + """); + 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": { + "target_order": {"locator_value": "SHOULD-NOT-PERSIST-SUBMITTED-TARGET"}, + "trace_items": [ + { + "item_type": "GENERAL", + "text": "Updated trace", + "department_code": "HSK", + "content": "SHOULD-NOT-PERSIST-SUBMITTED-CONTENT" + }, + { + "item_type": "EXTRA_BED", + "target_room_type_code": "DBL", + "extra_bed_room_count": 2, + "department_code": "FO", + "content": "SHOULD-NOT-PERSIST-SUBMITTED-EXTRA-CONTENT", + "attachment_url": "https://oss.example.test/trace-submitted-secret.png" + } + ] + } + } + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.business_cards[0].confirmed_payload.trace_items[0].text") + .value("Updated trace")) + .andExpect(jsonPath("$.business_cards[0].confirmed_payload.trace_items[0].department_code") + .value("HSK")) + .andExpect(jsonPath("$.business_cards[0].confirmed_payload.trace_items[1].target_room_type_code") + .value("DBL")) + .andExpect(jsonPath("$.business_cards[0].confirmed_payload.trace_items[1].extra_bed_room_count") + .value(2)) + .andExpect(jsonPath("$.business_cards[0].confirmed_payload.trace_items[1].department_code") + .value("FO")) + .andExpect(jsonPath("$.business_cards[0].confirmed_payload.target_order").doesNotExist()) + .andExpect(jsonPath("$.business_cards[0].confirmed_payload.trace_items[0].content").doesNotExist()) + .andExpect(jsonPath("$.business_cards[0].confirmed_payload.trace_items[1].content").doesNotExist()) + .andExpect(jsonPath("$.business_cards[0].confirmed_payload.trace_items[1].attachment_url").doesNotExist()) + .andExpect(content().string(not(containsString("SHOULD-NOT-PERSIST")))) + .andExpect(content().string(not(containsString("https://oss.example.test")))); } @Test @@ -1266,6 +1481,95 @@ class ReservationV4CommandControllerTest { .value("RM2")); } + @Test + void shouldAcceptEditableTraceReviewFieldPointersReturnedByTaskDetail() throws Exception { + SeededOrderTask seeded = seedReviewOrderTaskWithBusinessCard( + HOTEL_ID, + "mail-v4-command-review-trace-pointer-runtime-001", + Instant.parse("2026-07-19T01:22:28Z"), + 990000000000070126L, + ReservationV4TargetResolutionStatus.RESOLVED.name(), + ReservationV4CardStatus.PENDING_CONFIRM.name(), + ReservationV4CardStatus.REVIEW_REQUIRED.name(), + ReservationV4CardType.TRACE_RESERVATION_NOTES.name(), + "TRACE_RESERVATION_NOTES", + """ + { + "card_type":"TRACE_RESERVATION_NOTES", + "event_type":"TRACE_RESERVATION_NOTES", + "target_order":{"locator_value":"SHOULD-NOT-PERSIST-TRACE"}, + "business_fields":{ + "event_type":"TRACE_RESERVATION_NOTES", + "trace_items":[ + { + "item_type":"GENERAL", + "text":"Guest requests quiet rooms.", + "department_code":"SALES", + "content":"SHOULD-NOT-PERSIST-CONTENT" + } + ] + } + } + """, + """ + [ + { + "field_path": "trace_items.0.department_code", + "field_pointer": "/trace_items/0/department_code", + "message": "Department Code 只允许 FO、HSK、FO+HSK。", + "detail": "trace_items.0.department_code: Department Code 只允许 FO、HSK、FO+HSK。" + } + ] + """); + 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("Trace 详情返回的可复核字段必须能直接提交给 review-resolution") + .isNotEmpty(); + org.assertj.core.api.Assertions.assertThat(overrides.toString()) + .contains("/trace_items/0/text") + .contains("/trace_items/0/department_code") + .doesNotContain("/business_fields/"); + + ObjectNode request = objectMapper.createObjectNode(); + request.put("version", 0); + request.put("reason", "回归验证 Trace 详情页可编辑 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.trace_items[0].text") + .value("UPDATED")) + .andExpect(jsonPath("$.business_cards[0].confirmed_payload.trace_items[0].department_code") + .value("FO")) + .andExpect(jsonPath("$.business_cards[0].confirmed_payload.trace_items[0].content").doesNotExist()) + .andExpect(jsonPath("$.business_cards[0].confirmed_payload.target_order").doesNotExist()); + } + @Test void shouldResolveEditableRoomInformationPointerWhenLeafMissingInPayload() throws Exception { SeededOrderTask seeded = seedReviewOrderTaskWithBusinessCard( @@ -2111,6 +2415,12 @@ class ReservationV4CommandControllerTest { if (pointer.endsWith("/room_count")) { return objectMapper.getNodeFactory().numberNode(2); } + if (pointer.endsWith("/extra_bed_room_count")) { + return objectMapper.getNodeFactory().numberNode(1); + } + if (pointer.endsWith("/department_code")) { + return objectMapper.getNodeFactory().textNode("FO"); + } if (pointer.endsWith("/group_booking_status")) { return objectMapper.getNodeFactory().textNode("TEN"); } diff --git a/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationV4QueryControllerTest.java b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationV4QueryControllerTest.java index cefe51c..95251e2 100644 --- a/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationV4QueryControllerTest.java +++ b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationV4QueryControllerTest.java @@ -789,6 +789,77 @@ class ReservationV4QueryControllerTest { .andExpect(content().string(not(containsString("att-payment-real-external-001")))); } + @Test + void shouldReturnStableTraceFieldsAndSafeDisplayPayload() throws Exception { + ReservationV4OrderTaskSnapshot orderTask = seedOrderTaskWithBusinessCardType( + "mail-v4-query-trace-fields-001", + Instant.parse("2026-07-18T02:45:00Z"), + "GROUP", + "GROUP_CODE", + "GRP-V4-TRACE-FIELDS-001", + ReservationV4CardType.TRACE_RESERVATION_NOTES.name(), + "TRACE_RESERVATION_NOTES", + ReservationV4CardStatus.CONFIRMED.name(), + ReservationV4CardStatus.PENDING_CONFIRM.name(), + """ + { + "card_type":"TRACE_RESERVATION_NOTES", + "event_type":"TRACE_RESERVATION_NOTES", + "target_order":{"locator_value":"SHOULD-NOT-LEAK-TRACE"}, + "business_fields":{ + "order_ref":"ORDER-TRACE", + "event_type":"TRACE_RESERVATION_NOTES", + "trace_items":[ + { + "item_type":"GENERAL", + "text":"Guest requests quiet rooms.", + "content":"SHOULD-NOT-LEAK-CONTENT", + "department_code":"FO" + }, + { + "item_type":"EXTRA_BED", + "content":"SHOULD-NOT-LEAK-EXTRA-BED-CONTENT", + "target_room_type_code":"TWN", + "extra_bed_room_count":1, + "department_code":"FO+HSK", + "raw_evidence":"SHOULD-NOT-LEAK-RAW", + "attachment_url":"https://oss.example.test/trace-secret.png" + } + ] + } + } + """, + null); + + performAuthorized(mockMvc, adminToken(), get("/api/reservation/order-tasks/{orderTaskId}", orderTask.id()) + .param("hotel_id", HOTEL_ID)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.business_cards[0].display_payload.trace_items[0].text") + .value("Guest requests quiet rooms.")) + .andExpect(jsonPath("$.business_cards[0].display_payload.trace_items[1].target_room_type_code") + .value("TWN")) + .andExpect(jsonPath("$.business_cards[0].display_payload.trace_items[1].extra_bed_room_count") + .value(1)) + .andExpect(jsonPath("$.business_cards[0].display_payload.business_fields").doesNotExist()) + .andExpect(jsonPath("$.business_cards[0].display_payload.target_order").doesNotExist()) + .andExpect(jsonPath("$.business_cards[0].display_payload.trace_items[0].content").doesNotExist()) + .andExpect(jsonPath("$.business_cards[0].display_payload.trace_items[1].content").doesNotExist()) + .andExpect(jsonPath("$.business_cards[0].fields[?(@.field_pointer=='/trace_items/0/text')].editable") + .value(contains(true))) + .andExpect(jsonPath("$.business_cards[0].fields[?(@.field_pointer=='/trace_items/0/text')].control_type") + .value(contains("textarea"))) + .andExpect(jsonPath("$.business_cards[0].fields[?(@.field_pointer=='/trace_items/0/department_code')].options_source") + .value(contains("reservation_v4_trace_department_fixed"))) + .andExpect(content().string(containsString( + "\"fixed_options\":[{\"value\":\"FO\",\"label\":\"FO\"},{\"value\":\"HSK\",\"label\":\"HSK\"},{\"value\":\"FO+HSK\",\"label\":\"FO+HSK\"}]"))) + .andExpect(jsonPath("$.business_cards[0].fields[?(@.field_pointer=='/trace_items/1/target_room_type_code')].options_source") + .value(contains("reservation_v4_room_type_catalog"))) + .andExpect(jsonPath("$.business_cards[0].fields[?(@.field_pointer=='/trace_items/1/extra_bed_room_count')].control_type") + .value(contains("number"))) + .andExpect(content().string(not(containsString("SHOULD-NOT-LEAK")))) + .andExpect(content().string(not(containsString("https://oss.example.test")))); + } + @Test void shouldReturnReadonlyRoomInformationDisplayModelForCancelBooking() throws Exception { Long orderId = 990000000000777002L; 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 e06af61..362dfce 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 @@ -1383,10 +1383,64 @@ class SuperAgentTaskResultControllerTest { LIMIT 1 """, String.class, source.inboxId()); assertThat(traceValidationErrors) - .contains("business_fields.trace_items.0.target_room_type_code") + .contains("trace_items.0.target_room_type_code") .contains("房型代码不在第一版目录中"); } + @Test + void shouldMarkV4TraceReviewRequiredWhenDepartmentCodeInvalid() throws Exception { + SourceMessageCaptureResult source = captureSourceMessage("mail-v4-trace-invalid-department-001"); + String body = v4CancelTraceRoomingListBody("mail-v4-trace-invalid-department-001") + .replace("GRP-V4-CTR-001", "GRP-V4-TRACE-INVALID-DEPARTMENT-001") + .replace("\"department_code\": \"FO\"", "\"department_code\": \"SALES\""); + + mockMvc.perform(signedPost(body, "nonce-v4-trace-invalid-department-001")) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.accepted_count").value(3)); + + String traceValidationErrors = jdbcTemplate.queryForObject(""" + SELECT validation_errors_json + FROM workflow_reservation_v4_task_card + WHERE source_message_id = ? + AND card_type = 'TRACE_RESERVATION_NOTES' + AND card_status = 'REVIEW_REQUIRED' + AND review_status = 'PENDING' + LIMIT 1 + """, String.class, source.inboxId()); + assertThat(traceValidationErrors) + .contains("trace_items.0.department_code") + .contains("Department Code 只允许 FO、HSK、FO+HSK。"); + } + + @Test + void shouldMarkV4TraceReviewRequiredWhenExtraBedRoomCountInvalid() throws Exception { + SourceMessageCaptureResult source = captureSourceMessage("mail-v4-trace-invalid-extra-bed-count-001"); + String body = v4CancelTraceRoomingListBody("mail-v4-trace-invalid-extra-bed-count-001") + .replace("GRP-V4-CTR-001", "GRP-V4-TRACE-INVALID-EXTRA-BED-COUNT-001") + .replace("\"item_type\": \"GENERAL\"", "\"item_type\": \"EXTRA_BED\"") + .replace("\"text\": \"Guest requests quiet rooms.\"", "\"text\": \"Please add one extra bed.\"") + .replace("\"department_code\": \"FO\"", "\"target_room_type_code\": \"TWN\",\n" + + " \"extra_bed_room_count\": 0,\n" + + " \"department_code\": \"FO\""); + + mockMvc.perform(signedPost(body, "nonce-v4-trace-invalid-extra-bed-count-001")) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.accepted_count").value(3)); + + String traceValidationErrors = jdbcTemplate.queryForObject(""" + SELECT validation_errors_json + FROM workflow_reservation_v4_task_card + WHERE source_message_id = ? + AND card_type = 'TRACE_RESERVATION_NOTES' + AND card_status = 'REVIEW_REQUIRED' + AND review_status = 'PENDING' + LIMIT 1 + """, String.class, source.inboxId()); + assertThat(traceValidationErrors) + .contains("trace_items.0.extra_bed_room_count") + .contains("加床房间数必须为正整数"); + } + @Test void shouldCreateV4CancelTraceAndRoomingListTasksInEventOrder() throws Exception { SourceMessageCaptureResult source = captureSourceMessage("mail-v4-cancel-trace-rooming-001");