diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index a97f23f..865a63e 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 卡展示付款凭证附件时,`attachment_ids[]` 第一版只读,前端只展示并确认卡片,不增删或替换附件集合,图片在卡片内显示缩略图并点击大图预览,非图片统一文件列表 + 下载,附件外链仍走 SourceMessage 原文权限链路;已确认 Rooming List 任务卡第一版只做事项确认,不做名单解析、附件预览、Excel 生成或 PMS 导入,用户点击“确认卡片”表示已人工处理该 Rooming List 事项;已确认 `REVIEW_REQUIRED` 仍是原业务卡复核态,页面按钮统一叫“确认卡片”,复核态允许编辑当前卡 `fields[]` 白名单内业务字段,问题字段红字提示。后续可继续做测试机 V4 smoke 复测、Rooming List 前端轻量卡展示、Payment 附件预览、Account 范围 Rate Code lookup、真实 PMS / OPERA / OHIP 同步或 SuperAgent 目录供给方案。 | +| 当前阶段 | M002 V4 入站、多卡模型、持久化基线、入站写入、查询接口、卡片确认、复核解阻、目录校验、订单详情 V4 总览、DB 目录、Lookup API、前端 lookup 接入、目录管理后台 CP1 前后端、订单列表 V4 继续处理入口 / open count 收口、V4 业务审计查询、停止旧任务双写、Debug EML V4 profile 对齐、Room Information 后端展示模型与前端业务化展示、V4 任务详情 smoke 修复、Rooming List 确认自动 DEF 后端联动、Room Information 复核 pointer 与任务详情安全边界修复、Room Information 复核 pointer 运行时规则收口、复核 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`,`attachment_ids[]` 第一版仍只读,前端只展示并确认卡片,不增删或替换附件集合,附件缩略图、大图和非图片下载真实 URL 仍走 SourceMessage 原文权限链路;已确认 Rooming List 任务卡第一版只做事项确认,不做名单解析、附件预览、Excel 生成或 PMS 导入,用户点击“确认卡片”表示已人工处理该 Rooming List 事项;已确认 `REVIEW_REQUIRED` 仍是原业务卡复核态,页面按钮统一叫“确认卡片”,复核态允许编辑当前卡 `fields[]` 白名单内业务字段,问题字段红字提示。后续可继续做测试机 V4 smoke 复测、Payment 前端联动、Rooming List 前端轻量卡展示、Account 范围 Rate Code lookup、真实 PMS / OPERA / OHIP 同步或 SuperAgent 目录供给方案。 | ## 1. 当前 Checkpoint -- 名称:`M002-V4-review-pointer-command-policy-runtime-fix` -- 状态:In Progress,正在收口 Room Information 复核 pointer 查询侧 / 命令侧白名单一致性。 -- 目标:当 `GET /api/reservation/order-tasks/{orderTaskId}` 返回 `editable=true` 且 `write_target=review_resolution.field_overrides` 的 Room Information pointer 时,`POST /review-resolution` 原样提交同 pointer 不应再返回 `V4_REVIEW_POINTER_NOT_ALLOWED`;如果字段级值或目录校验失败,应返回对应字段级错误。 -- 边界:本 checkpoint 不处理 `build_commit=UNKNOWN`,不做 Payment 附件预览、不做 Rate Code lookup、不做 PMS / OPERA / OHIP、不推进 M011。 -- 联调备注:Source conversation 接口仍是带 `SOURCE_MESSAGE_ORIGINAL_READ` 的原文权限接口,可返回 `html_body` 和 `html_body_sanitized`;V4 task detail 本身不得返回 raw HTML。若测试机仍出现 `V4_REVIEW_POINTER_NOT_ALLOWED`,直接检索日志 `review_pointer_policy=m002_v4_review_pointer_runtime_fix_v1`,日志会输出 query-side editable pointers、command-side allowed pointers、validation error pointers、incoming pointer、card type / event type / status、`display_payload_has_room_information_final_values`、稳定 Room Information payload 判断和 reject reason,且不得输出 payload、邮件正文或附件 URL。 +- 名称:`M002-V4-payment-attachment-preview-backend` +- 状态:In Progress,正在实现 Payment 卡付款凭证附件安全摘要。 +- 目标:`GET /api/reservation/order-tasks/{orderTaskId}` 对 V4 Payment 卡返回 `display_payload.payment_attachments[]` 安全摘要,`attachment_ids[]` 保持只读,确认接口不接受前端替换附件集合。 +- 边界:本 checkpoint 不做前端、不返回附件 URL / 签名 URL / OSS URL、不做 Payment 附件编辑、不做付款状态、不做 Rate Code lookup、不做 PMS / OPERA / OHIP、不推进 M011。 +- 联调备注:Payment 缩略图、大图预览和非图片下载真实 URL 仍必须通过 `GET /api/source-messages/{sourceMessageId}/conversation` 在 `SOURCE_MESSAGE_READ + SOURCE_MESSAGE_ORIGINAL_READ` 权限下读取;V4 task detail 本身只返回附件 ID、文件名、类型、大小、图片标记、预览 / 下载可用性和可选 `external_media_id`。 ## 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` 已完成。Account + booking type 过滤 Rate Code、Payment 附件预览均已作为下一阶段文档口径确认,但后端 lookup / 校验、Payment 附件安全摘要和前端联动尚未实现;真实 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 仍是待补齐缺口;Payment 前端缩略图 / 下载联动、真实 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 bdaf4ef..d79cb3c 100644 --- a/docs/project/frontend-backend/backend-to-frontend-notes.md +++ b/docs/project/frontend-backend/backend-to-frontend-notes.md @@ -56,7 +56,7 @@ | `GET /api/reservation/tasks` | 查询任务列表 / 工作台 | 必须带 Bearer token,需要 `RESERVATION_TASK_READ`;未传 `order_id` 时按来源消息接收时间倒序,传 `order_id` 时按同订单队列顺序正序;用 `can_process` 和 `readonly_reason_code` 控制入口按钮;列表不返回 AI 原始 payload、邮件正文或附件 URL;已返回来源邮件会话摘要字段,并支持 `order_status` 按任务所属订单状态筛选;旧 S000/S999 和 V3 S10/S99 以 `task_type=SOURCE_MESSAGE_ONLY` 只读任务返回,列表已透出 `result_type`、`ai_task_type`、`route_code`、`system_process_category`。V4 S10/S99 不再进入该旧任务表,应从 V4 工作台来源通知接口展示。 | | `GET /api/reservation/workbench-items` | 查询 V4 工作台统一列表 | 必须带 Bearer token,需要 `RESERVATION_TASK_READ`;返回 V4 业务订单任务和 S10/S99 来源通知混排摘要;支持 `hotel_id`、`item_type`、`keyword`、`page_num`、`page_size`;默认按 `source_received_at` 倒序,同一来源时间下按 `updated_at`、`created_at`、数字 `target_id` 倒序;列表不返回邮件正文、附件 URL、`ai_payload_json` 或来源通知原始 payload。 | | `GET /api/reservation/order-tasks` | 查询 V4 业务订单任务列表 | 必须带 Bearer token,需要 `RESERVATION_TASK_READ`;只返回 V4 业务订单任务,不包含 S10/S99 来源通知;支持 `hotel_id`、`order_id`、`order_task_status`、`card_status`、`keyword`、`page_num`、`page_size`;`order_task_status` 非 `OPEN` / `COMPLETED` 返回 400,`card_status` 非 V4 卡状态返回 400;`card_status` 只筛业务 / 可处理卡,固定来源邮件展示卡不参与筛选。 | -| `GET /api/reservation/order-tasks/{orderTaskId}` | 查询 V4 订单任务详情 | 必须带 Bearer token,需要 `RESERVATION_TASK_READ`,后端按订单任务实际酒店校验访问权;返回 `order_task`、`source_message_summary`、`source_message_card`、`basic_information_card`、`business_cards[]`、`card_counts`、`adapter_contract_errors[]` 和 `availability`;来源摘要按酒店过滤,邮件正文和附件仍走 SourceMessage 会话接口。V4 任务详情页展示顺序固定为 Basic Information、业务卡、SourceMessage Display;来源邮件卡位于页面最下方,正文限定为当前触发该 order task 的 SourceMessage 正文,前端用 `source_message_summary.source_message_id` 调用 `GET /api/source-messages/{sourceMessageId}/conversation` 后定位当前邮件。Payment 卡下一阶段可返回 `payment_attachments[]` 安全摘要用于展示凭证附件,但本接口不得返回附件 URL;图片缩略图 / 大图和非图片下载 URL 仍通过 SourceMessage 会话权限链路取得。CP8 起每张 V4 任务卡返回 `fields[]`,前端应以该字段白名单渲染可编辑控件;`write_target` 只返回 `confirmed_payload`、`review_resolution.field_overrides`、`none` 等前端安全语义,不暴露内部列名。Room Information 卡已新增 `display_payload.room_information` 稳定展示模型,前端优先读取 `current_values` / `proposed_values` / `final_values` / `change_summary[]`,不要再从 Agent raw payload、`business_fields` 或 `target_order` 自行推导业务展示;Basic Information 以及普通业务卡的 `display_payload` / `confirmed_payload` 不返回 Agent `target_order`,普通业务卡也会移除邮件 HTML、raw evidence、附件原始 URL 和 PMS 原始响应等敏感字段。 | +| `GET /api/reservation/order-tasks/{orderTaskId}` | 查询 V4 订单任务详情 | 必须带 Bearer token,需要 `RESERVATION_TASK_READ`,后端按订单任务实际酒店校验访问权;返回 `order_task`、`source_message_summary`、`source_message_card`、`basic_information_card`、`business_cards[]`、`card_counts`、`adapter_contract_errors[]` 和 `availability`;来源摘要按酒店过滤,邮件正文和附件仍走 SourceMessage 会话接口。V4 任务详情页展示顺序固定为 Basic Information、业务卡、SourceMessage Display;来源邮件卡位于页面最下方,正文限定为当前触发该 order task 的 SourceMessage 正文,前端用 `source_message_summary.source_message_id` 调用 `GET /api/source-messages/{sourceMessageId}/conversation` 后定位当前邮件。Payment 卡已返回 `display_payload.payment_attachments[]` 安全摘要用于展示凭证附件,字段只包含 `attachment_id`、`file_name`、`content_type`、`size_bytes`、`is_image`、`preview_available`、`download_available`、可选 `external_media_id` / `unavailable_reason_code`;后端只按当前 SourceMessage 包级附件 ID / 同酒店媒体 `external_media_id` 匹配,不按文件名或内部媒体 row ID 匹配;本接口不得返回附件 URL,图片缩略图 / 大图和非图片下载 URL 仍通过 SourceMessage 会话权限链路取得。CP8 起每张 V4 任务卡返回 `fields[]`,前端应以该字段白名单渲染可编辑控件;`write_target` 只返回 `confirmed_payload`、`review_resolution.field_overrides`、`none` 等前端安全语义,不暴露内部列名。Room Information 卡已新增 `display_payload.room_information` 稳定展示模型,前端优先读取 `current_values` / `proposed_values` / `final_values` / `change_summary[]`,不要再从 Agent raw payload、`business_fields` 或 `target_order` 自行推导业务展示;Basic Information 以及普通业务卡的 `display_payload` / `confirmed_payload` 不返回 Agent `target_order`,普通业务卡也会移除邮件 HTML、raw evidence、附件原始 URL 和 PMS 原始响应等敏感字段。 | | `GET /api/reservation/order-tasks/{orderTaskId}/audits` | 查询 V4 订单任务审计流水 | 必须带 Bearer token,需要 `RESERVATION_AUDIT_READ`,后端按订单任务实际酒店校验访问权;返回 `order_task_id` 和 `items[]`。`items[]` 用于展示 V4 卡片确认、复核解阻、订单归属确认轨迹和 `V4_ROOMING_LIST_AUTO_DEF` 自动 DEF 摘要,只包含脱敏后的审计摘要,不包含邮件正文、HTML、附件 URL、AI 原始 payload、token 或 secret。 | | `GET /api/reservation/source-notifications/{notificationId}` | 查询 V4 S10/S99 来源通知详情 | 必须带 Bearer token,需要 `RESERVATION_TASK_READ`,后端按来源通知实际酒店校验访问权;只返回通知摘要、来源邮件通知卡、会话摘要和 `availability`;不返回订单任务、业务卡、邮件正文、附件 URL 或原始 AI payload。 | | `GET /api/reservation/source-notifications/{notificationId}/audits` | 查询 V4 S10/S99 来源通知审计流水 | 必须带 Bearer token,需要 `RESERVATION_AUDIT_READ`,后端按来源通知实际酒店校验访问权;返回 `notification_id` 和 `items[]`。`items[]` 第一版用于展示来源通知 ack 记录,只包含脱敏后的审计摘要。 | @@ -184,7 +184,7 @@ POST /api/auth/logout - 第一版仅处理 HTML 内容安全;`inline_images[]` 和 `attachments[]` 的 `externalUrl` 来自本系统 OSS 服务,暂不做额外拦截,但前端仍不得写入普通日志、错误上报、localStorage 或 URL query。 - 会话详情接口由后端内部写原文读取审计,actor 使用当前登录用户稳定 ID;前端不传 `X-TH-Hotel-Source-Original-Read-Key`、`X-TH-Hotel-Actor` 或 `X-TH-Hotel-Access-Scene`。 - 会话详情外层字段主要是 snake_case,但媒体对象沿用原文读取接口字段,当前是 `mediaType`、`fileName`、`contentType`、`sizeBytes`、`externalUrl`、`externalMediaId` 这种 camelCase,前端类型定义需要单独处理。 -- Payment 卡附件预览规则:业务卡里的 `attachment_ids[]` 是付款凭证引用,第一版只读展示并确认卡片,不允许前端增删或替换附件集合;后端下一阶段可返回 `payment_attachments[]` 安全摘要辅助展示。图片附件按 `contentType` 以 `image/` 开头判断,在卡片中展示缩略图,点击后打开大图预览;非图片附件统一展示文件名、类型、大小和下载按钮,不在 Payment 卡中内嵌 PDF / Word / Excel 预览。预览和下载必须先通过会话接口定位当前 SourceMessage,再按 `externalMediaId` / `payment_attachments[].external_media_id` 或附件 ID 匹配,不能按文件名猜测。 +- Payment 卡附件预览规则:业务卡里的 `attachment_ids[]` 是付款凭证引用,第一版只读展示并确认卡片,不允许前端增删或替换附件集合;后端已在任务详情 `display_payload.payment_attachments[]` 返回安全摘要辅助展示。图片附件按 `content_type` 或会话媒体 `contentType` 以 `image/` 开头判断,在卡片中展示缩略图,点击后打开大图预览;非图片附件统一展示文件名、类型、大小和下载按钮,不在 Payment 卡中内嵌 PDF / Word / Excel 预览。预览和下载必须先通过会话接口定位当前 SourceMessage,再按 `externalMediaId` / `payment_attachments[].external_media_id` 或附件 ID 匹配,不能按文件名、本系统内部媒体 row ID 或跨酒店 SourceMessage 媒体猜测。 ### 5.5 订单列表接入注意 @@ -530,7 +530,7 @@ RESERVATION_ROOMING_LIST_GENERATE - Room Information 卡 Breakfast 前端显示为“含早”勾选框:Group 固定勾选且只读;Fit 由后端按最终 Rate Code 中 `RB` / `RO` 派生,无法派生时作为必填勾选项。Group Booking Status 仅 Group 显示,稳定 code 为 `TEN` / `DEF` / `INQ`,展示文案为 `TEN-Tentative`、`DEF-Definite`、`INQ-Inquiry`;New Group 默认 `TEN`,`NEW_BOOKING` / `UPDATE_BOOKING` 确认前可改选,`CANCEL_BOOKING` 只读。 - Rooming List 卡确认存在跨卡联动:同订单为 Group 时,确认 `ROOMING_LIST` 后后端已把 Group Booking Status 自动置为 `DEF`,即使此前为 `TEN` 或 `INQ`;Fit 不显示也不变更该状态。该自动变更由后端写 `V4_ROOMING_LIST_AUTO_DEF` 审计,并在后续任务详情刷新时让 Room Information 的 `display_payload.room_information.final_values` 和 `confirmed_payload.room_information.final_values` 保持 DEF 口径一致;当前订单详情 `order_overview` 不返回 Group Booking Status 字段,仍只展示既有确认快照字段;如果没有可更新 Room Information 投影,确认仍成功,后端只写安全审计提示。 - Rooming List 卡第一版是轻量事项确认卡:前端展示卡片标题、状态、目标订单信息和“确认卡片”按钮即可;不要做名单 rows、附件预览、Excel 生成或 PMS 导入入口。确认仅表示该 Rooming List 事项已人工处理。 -- Payment 卡下一阶段建议由后端在 `display_payload_json.payment_attachments[]` 返回安全摘要,字段只包含附件 ID、文件名、类型、大小、是否图片、是否可预览 / 下载等,不包含外链。第一版 `attachment_ids[]` 是 Agent 返回的只读业务事实,前端只展示并确认卡片,不允许用户增删、替换或重新选择附件集合,也不把 `attachment_ids[]`、`externalUrl` 或完整附件对象提交回确认接口。 +- Payment 卡已由后端在 `display_payload.payment_attachments[]` 返回安全摘要,字段只包含附件 ID、文件名、类型、大小、是否图片、是否可预览 / 下载等,不包含外链。第一版 `attachment_ids[]` 是 Agent 返回的只读业务事实,前端只展示并确认卡片,不允许用户增删、替换或重新选择附件集合,也不把 `attachment_ids[]`、`externalUrl` 或完整附件对象提交回确认接口。 - V4 复核态卡片仍是原业务卡,不新建单独复核任务卡;页面状态显示“需要复核”,问题字段用 `fields[].validation_errors` 红字提示,主按钮文案统一为“确认卡片”。前端内部必须根据 `card_status=REVIEW_REQUIRED` 调用 `review-resolution`,不要调用普通 `confirm`。 - V4 可映射 event 现阶段仍保留现有任务详情结构作为过渡兼容;任务详情中若出现 `field_contract_version=20260718-v4` 或 AI payload 内的 `v4_source_message`、`v4_order_context`、`v4_message_event`,前端第一版只读展示即可,不要据此假定完整 V4 多卡页面已经完成。 - V4 `PAYMENT.attachment_ids[]` 不匹配、`UPDATE_BOOKING` 携带 `rate_code` 等问题会出现在任务详情同批次的 `adapter_contract_errors[]` 只读诊断块中,不展示保存、确认、执行或重试按钮。该字段只返回白名单诊断字段,不返回完整 AI payload、邮件正文、附件 URL 或 raw evidence。 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 0662fb5..78c08e7 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 附件安全摘要已补齐 | 可以;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。 | | `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-order-task-card-domain-model-cp2.md b/docs/project/requirements/M002-v4-order-task-card-domain-model-cp2.md index 85a7991..983c744 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 @@ -4,9 +4,9 @@ | 项目 | 内容 | | --- | --- | -| 文档版本 | 0.8 | -| 日期 | 2026-07-20 | -| 状态 | CP2 设计已确认;CP3-CP8、CP11、CP13、CP14、V4 业务审计查询、停止 V4 普通业务双写旧任务和 Room Information 后端展示模型第一版已实现 | +| 文档版本 | 0.9 | +| 日期 | 2026-07-21 | +| 状态 | CP2 设计已确认;CP3-CP8、CP11、CP13、CP14、V4 业务审计查询、停止 V4 普通业务双写旧任务、Room Information 后端展示模型和 Payment 附件安全摘要后端第一版已实现 | | 适用范围 | M002 V4 入站后的订单任务、多卡、状态、查询和写操作设计 | | 不适用范围 | 真实 PMS / OPERA / OHIP、前端页面视觉稿、生产历史数据迁移 | @@ -16,7 +16,7 @@ M002 V4 CP1 已完成 SuperAgent V4 回调包入站解析、基础校验、路 本文是 CP2 设计文档,用于把 2026-07-18 V4 字段契约落成后续可开发的数据模型和接口草案。 -截至 CP14、V4 业务审计查询、停止旧任务双写、Room Information 后端展示模型和 Rooming List 确认自动 DEF 后端联动,后端已实现本文第 10、11、12 节中的持久化和查询基线,并已把 SuperAgent V4 入站结果写入新表:普通业务包只创建 V4 订单任务、来源邮件展示卡、Basic Information 卡和业务卡,不再创建旧 `workflow_reservation_task`;V4 S10/S99 创建来源通知。当前已开放 V4 工作台、订单任务列表 / 详情、来源通知详情查询接口、订单详情 V4 订单任务时间线、V4 卡片确认接口、S10/S99 来源通知 ack 接口、V4 `REVIEW_REQUIRED` 卡复核解阻接口、V4 订单任务 / 来源通知审计查询接口、当前酒店数据库目录校验、卡片 `fields[]` 白名单、Account / Room Type / Rate Code lookup API、目录管理后台 CP1、订单列表 V4 继续处理入口字段、Room Information New / Update / Cancel 第一版业务展示模型,以及 Rooming List 确认触发 Group Booking Status 自动置 `DEF`。下一阶段已确认 Rate Code 需要按订单级 Account + `booking_type`(GROUP / FIT)过滤和校验,当前后端 CP11 实现仍是酒店级 Rate Code 目录,是待补齐缺口;Payment 卡下一阶段需要展示付款凭证附件,图片为缩略图 + 点击大图预览,非图片为文件列表 + 下载,但附件 URL 仍必须走 SourceMessage 原文权限链路;真实 PMS 同步继续后置,设计见 `M002-v4-real-catalog-lookup-api-design.md`。 +截至 CP14、V4 业务审计查询、停止旧任务双写、Room Information 后端展示模型、Rooming List 确认自动 DEF 后端联动和 Payment 附件安全摘要后端第一版,后端已实现本文第 10、11、12 节中的持久化和查询基线,并已把 SuperAgent V4 入站结果写入新表:普通业务包只创建 V4 订单任务、来源邮件展示卡、Basic Information 卡和业务卡,不再创建旧 `workflow_reservation_task`;V4 S10/S99 创建来源通知。当前已开放 V4 工作台、订单任务列表 / 详情、来源通知详情查询接口、订单详情 V4 订单任务时间线、V4 卡片确认接口、S10/S99 来源通知 ack 接口、V4 `REVIEW_REQUIRED` 卡复核解阻接口、V4 订单任务 / 来源通知审计查询接口、当前酒店数据库目录校验、卡片 `fields[]` 白名单、Account / Room Type / Rate Code lookup API、目录管理后台 CP1、订单列表 V4 继续处理入口字段、Room Information New / Update / Cancel 第一版业务展示模型、Rooming List 确认触发 Group Booking Status 自动置 `DEF`,以及 Payment 卡 `payment_attachments[]` 安全摘要。下一阶段已确认 Rate Code 需要按订单级 Account + `booking_type`(GROUP / FIT)过滤和校验,当前后端 CP11 实现仍是酒店级 Rate Code 目录,是待补齐缺口;Payment 前端仍需基于安全摘要接入缩略图 / 文件列表交互,真实预览和下载 URL 必须走 SourceMessage 原文权限链路;真实 PMS 同步继续后置,设计见 `M002-v4-real-catalog-lookup-api-design.md`。 后续如本文与 `M002-v4-agent-callback-field-contract.md` 的字段契约冲突,以字段契约为准;如与安全边界冲突,以 `security-access-control-boundary.md` 为准。 @@ -608,7 +608,7 @@ Rooming List 卡事项确认规则: Payment 卡附件展示规则: - Payment 卡业务字段仍以 Agent 返回的 `attachment_ids[]` 为准,用于确认这些附件是否为当前订单付款凭证;这不代表已收款、已入账或付款状态已确认。第一版 `attachment_ids[]` 是只读业务事实,前端展示后只允许“确认卡片”,不允许用户增删、替换或重新选择附件集合。 -- 后端下一阶段应在 Payment 卡 `display_payload` 中补 `payment_attachments[]` 安全摘要,由 `attachment_ids[]` 匹配当前触发 SourceMessage 的包级附件生成。建议字段为 `attachment_id`、`file_name`、`content_type`、`size_bytes`、`is_image`、`preview_available`、`download_available`,可选 `external_media_id`;不得包含 `externalUrl`、OSS URL、签名参数或附件原始二进制。 +- 后端已在 Payment 卡 `display_payload.payment_attachments[]` 中返回安全摘要,由 `attachment_ids[]` 匹配当前触发 SourceMessage 的包级附件或同酒店 SourceMessage 媒体摘要生成。匹配只认包级附件 ID / `external_media_id`,不认本系统媒体表内部 row ID,也不按文件名猜测。字段为 `attachment_id`、`file_name`、`content_type`、`size_bytes`、`is_image`、`preview_available`、`download_available`,可选 `external_media_id` / `unavailable_reason_code`;不得包含 `externalUrl`、OSS URL、签名参数或附件原始二进制。 - 图片判断以 `content_type` 以 `image/` 开头为主;图片在 Payment 卡内展示缩略图,点击后打开大图预览。缩略图和大图实际 URL 不从 `GET /api/reservation/order-tasks/{orderTaskId}` 返回,前端必须在具备 `SOURCE_MESSAGE_READ + SOURCE_MESSAGE_ORIGINAL_READ` 时调用 `GET /api/source-messages/{sourceMessageId}/conversation`,定位当前 SourceMessage 后按 `external_media_id` / `attachment_id` 匹配对应附件。 - 非图片附件统一显示文件列表,至少展示文件名、类型和大小,并提供下载动作;第一版不在 Payment 卡内嵌 PDF、Word、Excel 或压缩包预览。 - 入站 `PAYMENT.attachment_ids[]` 无法匹配同包 `source_message.attachments[].id` 时,不创建用户可处理 Payment 卡,只写 `adapter_contract_error` transition 或按 S10 / 技术异常规则处理。只有 `attachment_ids[]` 已合法匹配、但用户缺少原文读取权限、会话接口失败、附件 URL 缺失或媒体预览链路暂不可用时,Payment 卡才展示安全摘要和“无法预览 / 无法下载”的状态,不应把附件 URL 或错误详情暴露给普通用户。 @@ -874,7 +874,7 @@ AI 原始 payload、邮件正文、附件 URL 和技术 trace 不应直接进入 | M002-V4-CP13 | 目录管理后台 V1 | Account / Market / Source 管理,临时 Room Type / Rate Code 管理,目录维护权限和管理审计 | | M002-V4-CP14 | 订单列表 V4 继续处理入口 | 已完成:`GET /api/reservation/orders` 返回 V4 下一步订单任务、卡片、动作类型、动作状态和 open 数,前端可优先跳 V4 订单任务详情 | | M002-V4-CP14.5 | Account 范围 Rate Code Lookup | 待实现:按 Account + `booking_type` 管理和查询 Rate Code 适用关系;业务卡确认 / 复核校验 Rate Code 适用性;前端在 Account 确认后加载对应 GROUP/FIT 候选 | -| M002-V4-CP14.6 | Payment 附件预览 | 待实现:Payment 卡返回付款凭证附件安全摘要;前端图片缩略图 + 大图预览,非图片文件列表 + 下载;预览 / 下载走 SourceMessage 原文权限链路 | +| M002-V4-CP14.6 | Payment 附件预览 | 后端已完成:Payment 卡返回付款凭证附件安全摘要,不返回 URL;前端待接图片缩略图 + 大图预览、非图片文件列表 + 下载;预览 / 下载走 SourceMessage 原文权限链路 | | M002-V4-CP14.7 | Rooming List 事项确认卡 | 待实现前端轻量展示:Rooming List 卡第一版只展示事项和确认按钮,不解析名单、不预览附件、不生成 Excel、不导入 PMS | | M002-V4-CP14.8 | Room Information 展示模型 | 已完成前后端第一版:后端返回 `display_payload.room_information` 稳定展示模型;前端按 New / Update / Cancel 业务表单展示最终值、差异、Nights、Breakfast、Group Booking Status 和本地订单投影;Adult 不显示 | | M002-V4-CP14.9 | Rooming List 确认自动 DEF | 已完成后端第一版:确认 `ROOMING_LIST` 卡时,Group 同订单存在可更新 Room Information 确认快照则自动置 `DEF` 并写审计;Fit 不变更;无投影不造脏数据 | diff --git a/docs/project/security-access-control-boundary.md b/docs/project/security-access-control-boundary.md index 1927711..53f5a5c 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 安全摘要只能包含附件 ID、文件名、类型、大小、是否图片、是否可预览 / 下载等;不得返回 `ai_payload_json`;同批次 `adapter_contract_errors[]` 只返回白名单诊断字段;前端普通业务卡如遇 URL-like 附件字符串必须二次脱敏 | +| `GET /api/reservation/order-tasks/{orderTaskId}` | `FRONTEND_USER` | 已实现 M002 V4 CP5;强制 Bearer 登录 + `RESERVATION_TASK_READ` + 订单任务所属酒店访问权 | 保持登录 + `RESERVATION_TASK_READ` + 订单任务所属酒店访问权;V4 任务详情页展示顺序为 Basic Information、业务卡、SourceMessage Display;Basic Information 不返回 Agent `target_order`;Room Information 展示模型只返回当前酒店本地订单投影、Agent 白名单字段和系统派生值;普通业务卡 `display_payload` / `confirmed_payload` 会移除 Agent `target_order`、邮件 HTML、raw evidence、附件原始 URL 和 PMS 原始响应等敏感字段;`fields[].write_target` 只返回前端安全语义,不暴露内部列名;Payment 卡可返回付款凭证附件安全摘要;Payment 第一版 `attachment_ids[]` 只读展示,不支持前端增删或替换附件集合 | 只读查询默认不写业务审计;本接口不得直接返回邮件正文、HTML 或附件 URL,来源邮件卡正文和 Payment 图片预览 / 非图片下载必须通过 `GET /api/source-messages/{id}/conversation` 的 SourceMessage 原文权限链路读取;Basic Information、Room Information 展示模型和普通业务卡不得返回 PMS 原始响应、价格明细、AI 原始 payload、Agent 原始 `target_order` 或跨酒店订单值;Payment 安全摘要只能包含 `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 | @@ -75,7 +75,7 @@ | --- | --- | --- | --- | --- | | `GET /api/source-messages` | `FRONTEND_USER` | 已强制 Bearer 登录 + `SOURCE_MESSAGE_READ`;列表条件中的酒店按当前用户可访问酒店校验 | 保持登录 + `SOURCE_MESSAGE_READ` + 酒店访问权 | 只读摘要不写审计 | | `GET /api/source-messages/{id}` | `FRONTEND_USER` | 已强制 Bearer 登录 + `SOURCE_MESSAGE_READ`;按消息实际所属酒店校验访问权 | 保持登录 + `SOURCE_MESSAGE_READ` + 消息所属酒店访问权 | 只读摘要不写审计 | -| `GET /api/source-messages/{id}/conversation` | `FRONTEND_USER` | 已强制 Bearer 登录 + `SOURCE_MESSAGE_READ` + `SOURCE_MESSAGE_ORIGINAL_READ`;按消息实际所属酒店校验访问权;返回会话完整 text/html 和媒体 URL | 保持登录 + `SOURCE_MESSAGE_READ` + `SOURCE_MESSAGE_ORIGINAL_READ` + 消息所属酒店访问权;V4 任务详情页来源邮件卡读取正文、Payment 图片大图预览和非图片下载都必须走本接口,并且只使用当前触发该 order task 的 SourceMessage 内容和被 Payment `attachment_ids[]` 引用的附件 | 必须写原文读取审计,actor 使用当前登录用户稳定 ID;前端展示 HTML 优先使用 `html_body_sanitized`;前端不得把附件 `externalUrl` 写入确认 payload、日志、错误上报、URL query 或 localStorage | +| `GET /api/source-messages/{id}/conversation` | `FRONTEND_USER` | 已强制 Bearer 登录 + `SOURCE_MESSAGE_READ` + `SOURCE_MESSAGE_ORIGINAL_READ`;按消息实际所属酒店校验访问权;返回会话完整 text/html 和媒体 URL | 保持登录 + `SOURCE_MESSAGE_READ` + `SOURCE_MESSAGE_ORIGINAL_READ` + 消息所属酒店访问权;V4 任务详情页来源邮件卡读取正文、Payment 图片大图预览和非图片下载都必须走本接口,并且只使用当前触发该 order task 的 SourceMessage 内容和被 Payment `attachment_ids[]` 引用的附件;Payment 摘要匹配不得使用跨酒店 SourceMessage 或本系统内部媒体 row ID | 必须写原文读取审计,actor 使用当前登录用户稳定 ID;前端展示 HTML 优先使用 `html_body_sanitized`;前端不得把附件 `externalUrl` 写入确认 payload、日志、错误上报、URL query 或 localStorage | | `GET /api/source-messages/{id}/original` | `FRONTEND_USER` | 已强制 Bearer 登录 + `SOURCE_MESSAGE_READ` + `SOURCE_MESSAGE_ORIGINAL_READ`;按消息实际所属酒店校验访问权;不再使用原文读取 access key | 保持登录 + `SOURCE_MESSAGE_READ` + `SOURCE_MESSAGE_ORIGINAL_READ` + 消息所属酒店访问权 | 必须写原文读取审计,actor 使用当前登录用户稳定 ID | ### 3.4 系统管理后台接口 diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/message/common/dto/SourceMessageMediaSummary.java b/server/src/main/java/cn/nianxx/thhotel/platform/message/common/dto/SourceMessageMediaSummary.java new file mode 100644 index 0000000..42d5099 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/message/common/dto/SourceMessageMediaSummary.java @@ -0,0 +1,23 @@ +package cn.nianxx.thhotel.platform.message.common.dto; + +/** + * SourceMessage 媒体安全摘要。该 DTO 不包含 externalUrl,只供普通业务查询做附件 ID、名称、类型和大小匹配。 + * + * @param mediaId 媒体引用内部 ID + * @param mediaType 媒体类型,例如 ATTACHMENT 或 INLINE_IMAGE + * @param fileName 附件或内联图片文件名 + * @param contentType 媒体 MIME 类型 + * @param sizeBytes 媒体大小字节数 + * @param externalMediaId 外部系统媒体 ID,用于前端在原文权限接口返回的媒体对象中匹配 + * @param externalUrlPresent 是否存在受控原文读取 URL;不返回 URL 值本身 + */ +public record SourceMessageMediaSummary( + String mediaId, + String mediaType, + String fileName, + String contentType, + Long sizeBytes, + String externalMediaId, + boolean externalUrlPresent +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/message/repository/MybatisSourceMessageInboxRepository.java b/server/src/main/java/cn/nianxx/thhotel/platform/message/repository/MybatisSourceMessageInboxRepository.java index 232eb4b..22a8c14 100644 --- a/server/src/main/java/cn/nianxx/thhotel/platform/message/repository/MybatisSourceMessageInboxRepository.java +++ b/server/src/main/java/cn/nianxx/thhotel/platform/message/repository/MybatisSourceMessageInboxRepository.java @@ -2,6 +2,7 @@ package cn.nianxx.thhotel.platform.message.repository; import cn.nianxx.thhotel.platform.message.common.dto.SourceMessageInboxDraft; import cn.nianxx.thhotel.platform.message.common.dto.SourceMessageInboxSnapshot; +import cn.nianxx.thhotel.platform.message.common.dto.SourceMessageMediaSummary; import cn.nianxx.thhotel.platform.message.common.dto.SourceMessageOriginalAccessAuditDraft; import cn.nianxx.thhotel.platform.message.common.dto.SourceMessageOriginalContent; import cn.nianxx.thhotel.platform.message.common.dto.SourceMessageOriginalMediaItem; @@ -280,6 +281,26 @@ public class MybatisSourceMessageInboxRepository implements SourceMessageInboxRe return Optional.ofNullable(payload).map(SourceMessagePayloadEntity::getPayloadJson); } + /** + * 查询 SourceMessage 媒体安全摘要,只用于同酒店业务卡附件匹配,不返回外部 URL。 + */ + @Override + public List findMediaSummaries(String hotelId, Long id) { + if (!hasText(hotelId) || id == null) { + return List.of(); + } + SourceMessageInboxEntity inbox = inboxMapper.selectById(id); + if (inbox == null || !trim(hotelId).equals(inbox.getHotelId())) { + return List.of(); + } + return mediaMapper.selectList(Wrappers.lambdaQuery() + .eq(SourceMessageMediaEntity::getInboxId, id) + .orderByAsc(SourceMessageMediaEntity::getId)) + .stream() + .map(this::toMediaSummary) + .toList(); + } + /** * 记录重复投递 payload 差异,不覆盖第一次保存的原始 payload。 */ @@ -474,6 +495,21 @@ public class MybatisSourceMessageInboxRepository implements SourceMessageInboxRe ); } + /** + * 将媒体 Entity 转成安全摘要,保留 URL 是否存在的布尔信息但不暴露 URL 值。 + */ + private SourceMessageMediaSummary toMediaSummary(SourceMessageMediaEntity entity) { + return new SourceMessageMediaSummary( + entity.getId() == null ? null : entity.getId().toString(), + entity.getMediaType(), + entity.getFileName(), + entity.getContentType(), + entity.getSizeBytes(), + entity.getExternalMediaId(), + hasText(entity.getExternalUrl()) + ); + } + /** * 计算正文内容 SHA-256,用于后续排查正文是否被意外改写。 */ diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/message/repository/SourceMessageInboxRepository.java b/server/src/main/java/cn/nianxx/thhotel/platform/message/repository/SourceMessageInboxRepository.java index ed63c70..9cdd8d3 100644 --- a/server/src/main/java/cn/nianxx/thhotel/platform/message/repository/SourceMessageInboxRepository.java +++ b/server/src/main/java/cn/nianxx/thhotel/platform/message/repository/SourceMessageInboxRepository.java @@ -2,6 +2,7 @@ package cn.nianxx.thhotel.platform.message.repository; import cn.nianxx.thhotel.platform.message.common.dto.SourceMessageInboxDraft; import cn.nianxx.thhotel.platform.message.common.dto.SourceMessageInboxSnapshot; +import cn.nianxx.thhotel.platform.message.common.dto.SourceMessageMediaSummary; import cn.nianxx.thhotel.platform.message.common.dto.SourceMessageOriginalAccessAuditDraft; import cn.nianxx.thhotel.platform.message.common.dto.SourceMessageOriginalContent; import cn.nianxx.thhotel.platform.message.common.dto.SourceMessagePayloadDuplicateDraft; @@ -83,6 +84,11 @@ public interface SourceMessageInboxRepository { */ Optional findPayloadJson(Long id); + /** + * 按酒店和内部 SourceMessage ID 读取媒体安全摘要,不返回 externalUrl。 + */ + List findMediaSummaries(String hotelId, Long id); + /** * 幂等命中但 payload 变化时,标记差异并保存安全排查摘要。 */ diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/message/service/SourceMessageQueryService.java b/server/src/main/java/cn/nianxx/thhotel/platform/message/service/SourceMessageQueryService.java index ceb38cb..e3a5d5e 100644 --- a/server/src/main/java/cn/nianxx/thhotel/platform/message/service/SourceMessageQueryService.java +++ b/server/src/main/java/cn/nianxx/thhotel/platform/message/service/SourceMessageQueryService.java @@ -1,5 +1,6 @@ package cn.nianxx.thhotel.platform.message.service; +import cn.nianxx.thhotel.platform.message.common.dto.SourceMessageMediaSummary; import cn.nianxx.thhotel.platform.message.common.dto.SourceMessageSummaryResponse; import cn.nianxx.thhotel.platform.message.common.request.SourceMessageQueryRequest; import cn.nianxx.thhotel.platform.message.common.result.SourceMessagePageResult; @@ -37,6 +38,15 @@ public interface SourceMessageQueryService { */ Optional getSummary(Long inboxId); + /** + * 按酒店和内部 SourceMessage ID 读取媒体安全摘要,供业务卡按附件 ID 匹配展示,不返回正文、HTML 或 URL。 + * + * @param hotelId 酒店上下文 ID + * @param inboxId 内部 SourceMessage Inbox ID + * @return 媒体安全摘要列表 + */ + List getMediaSummaries(String hotelId, Long inboxId); + /** * 按内部 SourceMessage ID 批量读取安全摘要,供业务列表避免逐条查询。 * diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/message/service/impl/SourceMessageQueryServiceImpl.java b/server/src/main/java/cn/nianxx/thhotel/platform/message/service/impl/SourceMessageQueryServiceImpl.java index f1542ec..02a888c 100644 --- a/server/src/main/java/cn/nianxx/thhotel/platform/message/service/impl/SourceMessageQueryServiceImpl.java +++ b/server/src/main/java/cn/nianxx/thhotel/platform/message/service/impl/SourceMessageQueryServiceImpl.java @@ -3,6 +3,7 @@ package cn.nianxx.thhotel.platform.message.service.impl; import cn.nianxx.thhotel.platform.common.time.UtcTimeFormatter; import cn.nianxx.thhotel.platform.hotel.service.HotelContextService; import cn.nianxx.thhotel.platform.message.common.dto.SourceMessageInboxSnapshot; +import cn.nianxx.thhotel.platform.message.common.dto.SourceMessageMediaSummary; import cn.nianxx.thhotel.platform.message.common.dto.SourceMessageSummaryResponse; import cn.nianxx.thhotel.platform.message.common.request.SourceMessageQueryRequest; import cn.nianxx.thhotel.platform.message.common.result.SourceMessagePageResult; @@ -69,6 +70,14 @@ public class SourceMessageQueryServiceImpl implements SourceMessageQueryService return inboxRepository.findById(inboxId).map(this::toSummary); } + /** + * 读取 SourceMessage 媒体安全摘要,不返回正文、HTML、附件 URL 或原始 payload。 + */ + @Override + public List getMediaSummaries(String hotelId, Long inboxId) { + return inboxRepository.findMediaSummaries(hotelContextService.resolveCurrentHotelId(hotelId), inboxId); + } + /** * 批量读取 SourceMessage 安全摘要,用于业务列表预取主题等摘要字段,避免逐条查询。 */ 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 9a613aa..5f7ec6f 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 @@ -80,6 +80,7 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ private static final Set REVIEW_READONLY_ROOT_FIELDS = Set.of( "ai_payload_json", "attachments", + "attachment_ids", "blocking_points", "card_type", "card_status", @@ -537,7 +538,7 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ overlayRoomInformationPayload(orderTask, card, payload, submittedPayload); return; } - overlayBusinessPayload(payload, submittedPayload); + overlayBusinessPayload(card, payload, submittedPayload); } /** @@ -557,15 +558,18 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ /** * 业务卡只合并展示快照中已经存在且后端允许编辑的叶子字段,不接受新增字段或整体替换对象 / 数组。 */ - private void overlayBusinessPayload(ObjectNode payload, ObjectNode submittedPayload) { + private void overlayBusinessPayload( + ReservationV4TaskCardSnapshot card, + 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); + overlayEditableBusinessLeaves(card, (ObjectNode) businessFields, submittedRoot, List.of(), true); return; } - overlayEditableBusinessLeaves(payload, submittedRoot, List.of(), false); + overlayEditableBusinessLeaves(card, payload, submittedRoot, List.of(), false); } /** @@ -690,6 +694,7 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ } private void overlayEditableBusinessLeaves( + ReservationV4TaskCardSnapshot card, ObjectNode target, JsonNode submitted, List path, @@ -701,15 +706,16 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ while (fields.hasNext()) { Map.Entry field = fields.next(); List childPath = appendPath(path, field.getKey()); - if (!isBusinessPathWritable(childPath, wrappedBusinessFields)) { + if (!isBusinessPathWritable(card, childPath, wrappedBusinessFields)) { continue; } JsonNode submittedValue = submitted.get(field.getKey()); - overlayEditableBusinessValue(target, field.getKey(), field.getValue(), submittedValue, childPath, wrappedBusinessFields); + overlayEditableBusinessValue(card, target, field.getKey(), field.getValue(), submittedValue, childPath, wrappedBusinessFields); } } private void overlayEditableBusinessArray( + ReservationV4TaskCardSnapshot card, ArrayNode target, JsonNode submitted, List path, @@ -720,11 +726,12 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ 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); + overlayEditableBusinessValue(card, target, index, target.get(index), submitted.get(index), childPath, wrappedBusinessFields); } } private void overlayEditableBusinessValue( + ReservationV4TaskCardSnapshot card, ObjectNode parent, String fieldName, JsonNode currentValue, @@ -732,11 +739,11 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ List path, boolean wrappedBusinessFields) { if (currentValue != null && currentValue.isObject()) { - overlayEditableBusinessLeaves((ObjectNode) currentValue, submittedValue, path, wrappedBusinessFields); + overlayEditableBusinessLeaves(card, (ObjectNode) currentValue, submittedValue, path, wrappedBusinessFields); return; } if (currentValue != null && currentValue.isArray()) { - overlayEditableBusinessArray((ArrayNode) currentValue, submittedValue, path, wrappedBusinessFields); + overlayEditableBusinessArray(card, (ArrayNode) currentValue, submittedValue, path, wrappedBusinessFields); return; } if (submittedValue != null && !submittedValue.isMissingNode() && !submittedValue.isContainerNode()) { @@ -745,6 +752,7 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ } private void overlayEditableBusinessValue( + ReservationV4TaskCardSnapshot card, ArrayNode parent, int index, JsonNode currentValue, @@ -752,11 +760,11 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ List path, boolean wrappedBusinessFields) { if (currentValue != null && currentValue.isObject()) { - overlayEditableBusinessLeaves((ObjectNode) currentValue, submittedValue, path, wrappedBusinessFields); + overlayEditableBusinessLeaves(card, (ObjectNode) currentValue, submittedValue, path, wrappedBusinessFields); return; } if (currentValue != null && currentValue.isArray()) { - overlayEditableBusinessArray((ArrayNode) currentValue, submittedValue, path, wrappedBusinessFields); + overlayEditableBusinessArray(card, (ArrayNode) currentValue, submittedValue, path, wrappedBusinessFields); return; } if (submittedValue != null && !submittedValue.isMissingNode() && !submittedValue.isContainerNode()) { @@ -764,10 +772,16 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ } } - private boolean isBusinessPathWritable(List path, boolean wrappedBusinessFields) { + private boolean isBusinessPathWritable( + ReservationV4TaskCardSnapshot card, + List path, + boolean wrappedBusinessFields) { if (path == null || path.isEmpty()) { return false; } + if (ReservationV4CardType.PAYMENT.name().equals(card.cardType()) && "attachment_ids".equals(path.get(0))) { + return false; + } if (path.stream().anyMatch(REVIEW_READONLY_ROOT_FIELDS::contains)) { return false; } 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 a547e0d..41f335f 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 @@ -3,6 +3,7 @@ package cn.nianxx.thhotel.workflows.reservation.service.impl; import cn.nianxx.thhotel.platform.common.time.UtcTimeFormatter; import cn.nianxx.thhotel.platform.hotel.service.HotelContextException; import cn.nianxx.thhotel.platform.hotel.service.HotelContextService; +import cn.nianxx.thhotel.platform.message.common.dto.SourceMessageMediaSummary; import cn.nianxx.thhotel.platform.message.common.dto.SourceMessageSummaryResponse; import cn.nianxx.thhotel.platform.message.service.SourceMessageQueryService; import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationAiTransitionSnapshot; @@ -90,6 +91,8 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService 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"; + private static final String PAYMENT_UNAVAILABLE_ATTACHMENT_NOT_FOUND = "ATTACHMENT_NOT_FOUND"; + private static final String PAYMENT_UNAVAILABLE_ATTACHMENT_URL_UNAVAILABLE = "ATTACHMENT_URL_UNAVAILABLE"; private static final String GROUP_BOOKING_STATUS_TEN = "TEN"; private static final Map GROUP_BOOKING_STATUS_LABELS = Map.of( "TEN", "TEN-Tentative", @@ -253,16 +256,23 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService orderTask.id()); ReservationV4ActionAvailabilityResult orderAvailability = orderTaskAvailability(orderTask, queueContext, cards); ReservationV4TaskCardSnapshot basicCard = findFirstCard(cards, ReservationV4CardType.BASIC_INFORMATION.name()).orElse(null); - ReservationV4TaskCardResult sourceMessageCard = findFirstCard(cards, ReservationV4CardType.SOURCE_MESSAGE_DISPLAY.name()) - .map(card -> toCardResult(orderTask, card, orderAvailability, basicCard)) + ReservationV4TaskCardSnapshot sourceMessageCardSnapshot = findFirstCard( + cards, + ReservationV4CardType.SOURCE_MESSAGE_DISPLAY.name()).orElse(null); + Map paymentAttachmentSources = paymentAttachmentSources( + orderTask, + sourceMessageCardSnapshot, + cards); + ReservationV4TaskCardResult sourceMessageCard = Optional.ofNullable(sourceMessageCardSnapshot) + .map(card -> toCardResult(orderTask, card, orderAvailability, basicCard, paymentAttachmentSources)) .orElse(null); ReservationV4TaskCardResult basicInformationCard = Optional.ofNullable(basicCard) - .map(card -> toCardResult(orderTask, card, orderAvailability, basicCard)) + .map(card -> toCardResult(orderTask, card, orderAvailability, basicCard, paymentAttachmentSources)) .orElse(null); List businessCards = cards.stream() .filter(card -> !ReservationV4CardType.SOURCE_MESSAGE_DISPLAY.name().equals(card.cardType())) .filter(card -> !ReservationV4CardType.BASIC_INFORMATION.name().equals(card.cardType())) - .map(card -> toCardResult(orderTask, card, orderAvailability, basicCard)) + .map(card -> toCardResult(orderTask, card, orderAvailability, basicCard, paymentAttachmentSources)) .toList(); ReservationV4SourceMessageSummaryResult sourceSummary = sourceSummaries( orderTask.hotelId(), @@ -404,8 +414,9 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService ReservationV4OrderTaskSnapshot orderTask, ReservationV4TaskCardSnapshot card, ReservationV4ActionAvailabilityResult orderAvailability, - ReservationV4TaskCardSnapshot basicCard) { - JsonNode displayPayload = displayPayloadForCard(orderTask, card); + ReservationV4TaskCardSnapshot basicCard, + Map paymentAttachmentSources) { + JsonNode displayPayload = displayPayloadForCard(orderTask, card, paymentAttachmentSources); JsonNode confirmedPayload = confirmedPayloadForCard(card); JsonNode reviewResolution = parseJson(card.reviewResolutionJson()); JsonNode validationErrors = parseJson(card.validationErrorsJson()); @@ -436,14 +447,18 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService */ private JsonNode displayPayloadForCard( ReservationV4OrderTaskSnapshot orderTask, - ReservationV4TaskCardSnapshot card) { + ReservationV4TaskCardSnapshot card, + Map paymentAttachmentSources) { JsonNode displayPayload = parseJson(card.displayPayloadJson()); if (ReservationV4CardType.BASIC_INFORMATION.name().equals(card.cardType())) { return safeBasicInformationPayload(displayPayload); } if (ReservationV4CardType.SOURCE_MESSAGE_DISPLAY.name().equals(card.cardType()) || ReservationV4CardType.SOURCE_MESSAGE_NOTIFICATION.name().equals(card.cardType())) { - return displayPayload; + return safeBusinessPayload(displayPayload); + } + if (ReservationV4CardType.PAYMENT.name().equals(card.cardType())) { + return paymentDisplayPayload(displayPayload, paymentAttachmentSources); } if (!isRoomInformationEventCard(card) || !displayPayload.isObject()) { return safeBusinessPayload(displayPayload); @@ -517,6 +532,217 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService return copied; } + /** + * 生成 Payment 卡展示 payload,补充付款凭证附件安全摘要,不返回任何 URL 字段。 + */ + private JsonNode paymentDisplayPayload( + JsonNode displayPayload, + Map paymentAttachmentSources) { + JsonNode safeNode = safeBusinessPayload(displayPayload); + if (!safeNode.isObject()) { + return safeNode; + } + ObjectNode safePayload = (ObjectNode) safeNode; + ArrayNode summaries = objectMapper.createArrayNode(); + for (String attachmentId : paymentAttachmentIds(safePayload)) { + PaymentAttachmentSource source = paymentAttachmentSources.get(attachmentId); + summaries.add(paymentAttachmentSummary(attachmentId, source)); + } + safePayload.set("payment_attachments", summaries); + removeSensitiveBusinessPayloadFields(safePayload); + return safePayload; + } + + /** + * 从 Payment 卡业务 payload 中读取只读附件 ID 列表,兼容根字段和 business_fields 包裹两种形态。 + */ + private List paymentAttachmentIds(JsonNode payload) { + JsonNode attachmentIds = payload.path("business_fields").path("attachment_ids"); + if (!attachmentIds.isArray()) { + attachmentIds = payload.path("attachment_ids"); + } + if (!attachmentIds.isArray()) { + return List.of(); + } + List ids = new ArrayList<>(); + for (JsonNode item : attachmentIds) { + if (item != null && item.isTextual() && hasText(item.asText())) { + ids.add(item.asText().trim()); + } + } + return ids; + } + + /** + * 将单个付款附件匹配结果转换成前端可展示的安全摘要。 + */ + private ObjectNode paymentAttachmentSummary(String attachmentId, PaymentAttachmentSource source) { + ObjectNode summary = objectMapper.createObjectNode(); + summary.put("attachment_id", attachmentId); + if (source == null) { + summary.putNull("file_name"); + summary.putNull("content_type"); + summary.putNull("size_bytes"); + summary.put("is_image", false); + summary.put("preview_available", false); + summary.put("download_available", false); + summary.put("unavailable_reason_code", PAYMENT_UNAVAILABLE_ATTACHMENT_NOT_FOUND); + return summary; + } + putNullableText(summary, "file_name", source.fileName()); + putNullableText(summary, "content_type", source.contentType()); + if (source.sizeBytes() == null) { + summary.putNull("size_bytes"); + } else { + summary.put("size_bytes", source.sizeBytes()); + } + boolean image = isImageContentType(source.contentType()); + summary.put("is_image", image); + boolean available = source.available(); + summary.put("preview_available", image && available); + summary.put("download_available", available); + putNullableText(summary, "external_media_id", firstText(source.externalMediaId(), attachmentId)); + if (!available) { + summary.put("unavailable_reason_code", PAYMENT_UNAVAILABLE_ATTACHMENT_URL_UNAVAILABLE); + } + return summary; + } + + /** + * 写入可空文本字段,避免前端在字段不存在和字段为空之间反复做兼容判断。 + */ + private void putNullableText(ObjectNode node, String fieldName, String value) { + if (hasText(value)) { + node.put(fieldName, value); + } else { + node.putNull(fieldName); + } + } + + /** + * 判断附件 MIME 类型是否为图片,用于前端决定缩略图入口是否理论可用。 + */ + private boolean isImageContentType(String contentType) { + return contentType != null && contentType.toLowerCase(Locale.ROOT).startsWith("image/"); + } + + /** + * 汇总当前 V4 order task 对应 SourceMessage 的附件安全索引,仅供 Payment 卡按 ID 精确匹配。 + */ + private Map paymentAttachmentSources( + ReservationV4OrderTaskSnapshot orderTask, + ReservationV4TaskCardSnapshot sourceMessageCard, + List cards) { + boolean hasPaymentCard = cards != null && cards.stream() + .anyMatch(card -> ReservationV4CardType.PAYMENT.name().equals(card.cardType())); + if (!hasPaymentCard) { + return Map.of(); + } + Map sources = new LinkedHashMap<>(); + JsonNode sourceDisplayPayload = sourceMessageCard == null + ? NullNode.getInstance() + : parseJson(sourceMessageCard.displayPayloadJson()); + addSourceMessageCardAttachmentSources(sources, sourceDisplayPayload.path("attachments")); + for (SourceMessageMediaSummary media : sourceMessageQueryService.getMediaSummaries( + orderTask.hotelId(), + orderTask.sourceMessageId())) { + addSourceMessageMediaAttachmentSource(sources, media); + } + return sources; + } + + /** + * 从 SourceMessage 展示卡的包级 attachments[] 中提取附件摘要,优先使用 Agent 包内的附件 ID。 + */ + private void addSourceMessageCardAttachmentSources( + Map sources, + JsonNode attachments) { + if (attachments == null || !attachments.isArray()) { + return; + } + for (JsonNode attachment : attachments) { + String attachmentId = firstText(textAt(attachment, "id"), textAt(attachment, "attachment_id")); + if (!hasText(attachmentId)) { + continue; + } + String externalMediaId = firstText( + textAt(attachment, "external_media_id"), + firstText(textAt(attachment, "externalMediaId"), attachmentId)); + PaymentAttachmentSource source = new PaymentAttachmentSource( + attachmentId, + firstText(textAt(attachment, "name"), firstText(textAt(attachment, "file_name"), textAt(attachment, "fileName"))), + firstText(textAt(attachment, "content_type"), textAt(attachment, "contentType")), + longAt(attachment, "size", "size_bytes", "sizeBytes"), + externalMediaId, + true); + registerPaymentAttachmentSource(sources, attachmentId, source); + registerPaymentAttachmentSource(sources, externalMediaId, source); + } + } + + /** + * 从 SourceMessage 媒体表安全摘要中补充附件索引,不读取或返回真实外链。 + */ + private void addSourceMessageMediaAttachmentSource( + Map sources, + SourceMessageMediaSummary media) { + if (media == null || !isAttachmentMedia(media.mediaType())) { + return; + } + String attachmentId = media.externalMediaId(); + if (!hasText(attachmentId)) { + return; + } + PaymentAttachmentSource source = new PaymentAttachmentSource( + attachmentId, + media.fileName(), + media.contentType(), + media.sizeBytes(), + media.externalMediaId(), + media.externalUrlPresent()); + registerPaymentAttachmentSource(sources, attachmentId, source); + registerPaymentAttachmentSource(sources, media.externalMediaId(), source); + } + + /** + * 按候选 ID 注册付款附件摘要,保留第一次命中的来源,避免后续弱来源覆盖包级来源。 + */ + private void registerPaymentAttachmentSource( + Map sources, + String key, + PaymentAttachmentSource source) { + if (!hasText(key) || source == null) { + return; + } + PaymentAttachmentSource existing = sources.get(key); + if (existing == null || (!existing.available() && source.available())) { + sources.put(key, source); + } + } + + /** + * 只允许 SourceMessage 附件参与 Payment 匹配,内联图片不作为付款凭证。 + */ + private boolean isAttachmentMedia(String mediaType) { + return !hasText(mediaType) || "ATTACHMENT".equalsIgnoreCase(mediaType.trim()); + } + + /** + * 从附件 JSON 中读取大小字段,兼容 Agent 包和系统内部命名。 + */ + private Long longAt(JsonNode node, String... fieldNames) { + if (node == null || fieldNames == null) { + return null; + } + for (String fieldName : fieldNames) { + JsonNode value = node.get(fieldName); + if (value != null && value.isNumber()) { + return value.asLong(); + } + } + return null; + } + private void removeSensitiveBusinessPayloadFields(JsonNode node) { if (node == null || node.isMissingNode() || node.isNull()) { return; @@ -1765,7 +1991,7 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService Iterator> iterator = fieldRoot.fields(); while (iterator.hasNext()) { Map.Entry entry = iterator.next(); - if (!shouldExposeBusinessField(entry.getKey(), pointerPrefix)) { + if (!shouldExposeBusinessField(card, entry.getKey(), pointerPrefix)) { continue; } collectBusinessLeafFields( @@ -1781,7 +2007,14 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService return fields; } - private boolean shouldExposeBusinessField(String fieldName, String pointerPrefix) { + private boolean shouldExposeBusinessField( + ReservationV4TaskCardSnapshot card, + String fieldName, + String pointerPrefix) { + if (ReservationV4CardType.PAYMENT.name().equals(card.cardType()) + && ("attachment_ids".equals(fieldName) || "payment_attachments".equals(fieldName))) { + return false; + } if (V4_BUSINESS_READONLY_FIELDS.contains(fieldName)) { return false; } @@ -2248,4 +2481,17 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService } return value.trim(); } + + /** + * Payment 附件安全来源,只保存展示摘要和可用性,不保存真实 URL。 + */ + private record PaymentAttachmentSource( + String attachmentId, + String fileName, + String contentType, + Long sizeBytes, + String externalMediaId, + boolean available + ) { + } } 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 ac86df8..9d88089 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 @@ -208,6 +208,89 @@ class ReservationV4CommandControllerTest { assertAuditCount("V4_CARD_CONFIRM", "v4-command-admin", seeded.orderTask().id().toString(), 2); } + @Test + void shouldConfirmPaymentCardWithVersionOnlyAndKeepAttachmentIdsReadonly() throws Exception { + SeededOrderTask seeded = seedOrderTaskWithBusinessCard( + HOTEL_ID, + "mail-v4-command-payment-confirm-001", + Instant.parse("2026-07-19T01:12:00Z"), + null, + "GROUP", + "GROUP_CODE", + "GRP-V4-PAYMENT-COMMAND-001", + ReservationV4CardType.PAYMENT.name(), + "PAYMENT", + """ + { + "card_type":"PAYMENT", + "event_type":"PAYMENT", + "business_fields":{"attachment_ids":["att-pay-original-001"]} + } + """); + 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": { + "attachment_ids": ["att-pay-replaced-001"] + } + } + } + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.order_task.order_task_status").value("COMPLETED")) + .andExpect(jsonPath("$.business_cards[0].card_status").value("CONFIRMED")) + .andExpect(jsonPath("$.business_cards[0].confirmed_payload.business_fields.attachment_ids[0]") + .value("att-pay-original-001")) + .andExpect(content().string(not(containsString("att-pay-replaced-001")))); + } + + @Test + void shouldRejectPaymentReviewResolutionWhenTryingToModifyAttachmentIds() throws Exception { + SeededOrderTask seeded = seedReviewOrderTaskWithBusinessCard( + HOTEL_ID, + "mail-v4-command-payment-review-readonly-001", + Instant.parse("2026-07-19T01:12:30Z"), + 990000000000070120L, + ReservationV4TargetResolutionStatus.RESOLVED.name(), + ReservationV4CardStatus.CONFIRMED.name(), + ReservationV4CardStatus.REVIEW_REQUIRED.name(), + ReservationV4CardType.PAYMENT.name(), + "PAYMENT", + """ + { + "card_type":"PAYMENT", + "event_type":"PAYMENT", + "business_fields":{"attachment_ids":["att-pay-review-original-001"]} + } + """, + """ + [{"field":"business_fields.attachment_ids.0","message":"付款凭证附件集合只读"}] + """); + + performAuthorized(mockMvc, adminToken(), post( + "/api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/review-resolution", + seeded.orderTask().id(), + seeded.businessCard().id()) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "version": 0, + "field_overrides": [ + {"field_pointer": "/business_fields/attachment_ids/0", "value": "att-pay-review-replaced-001"} + ] + } + """)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error_code").value("V4_REVIEW_POINTER_READONLY")); + } + @Test void shouldSetGroupBookingStatusDefWhenConfirmingGroupRoomingListFromTen() throws Exception { Long orderId = 990000000000080001L; 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 7adc994..cefe51c 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 @@ -17,9 +17,12 @@ import cn.nianxx.thhotel.platform.identity.common.enums.PlatformUserStatus; import cn.nianxx.thhotel.platform.identity.domain.PlatformUserEntity; import cn.nianxx.thhotel.platform.identity.repository.PlatformIdentityRepository; import cn.nianxx.thhotel.platform.identity.service.impl.AuthPasswordService; +import cn.nianxx.thhotel.platform.message.common.dto.SourceMessageMediaSummary; import cn.nianxx.thhotel.platform.message.common.request.CaptureSourceMessageCommand; +import cn.nianxx.thhotel.platform.message.common.request.CaptureSourceMessageMedia; import cn.nianxx.thhotel.platform.message.common.result.SourceMessageCaptureResult; import cn.nianxx.thhotel.platform.message.service.SourceMessageCaptureService; +import cn.nianxx.thhotel.platform.message.service.SourceMessageQueryService; import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationAiBatchDraft; import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationAiTransitionDraft; import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationAuditLogDraft; @@ -78,6 +81,8 @@ class ReservationV4QueryControllerTest { @Autowired private SourceMessageCaptureService captureService; + @Autowired + private SourceMessageQueryService sourceMessageQueryService; @Autowired private ReservationV4WorkflowRepository workflowRepository; @@ -593,6 +598,197 @@ class ReservationV4QueryControllerTest { .andExpect(content().string(not(containsString("oss.example.test")))); } + @Test + void shouldReturnPaymentAttachmentSafeSummariesWithoutUrlsOrEditableAttachmentIds() throws Exception { + SourceMessageCaptureResult source = captureSourceMessage( + "mail-v4-query-payment-attachments-001", + "V4 Query Payment Attachments", + Instant.parse("2026-07-18T03:02:47Z"), + HOTEL_ID, + List.of( + new CaptureSourceMessageMedia( + "ATTACHMENT", + "payment-slip.png", + "image/png", + 12345L, + "https://oss.example.test/payment-slip.png?signature=secret", + "att-pay-image-001"), + new CaptureSourceMessageMedia( + "ATTACHMENT", + "payment-voucher.pdf", + "application/pdf", + 67890L, + "oss://private/payment-voucher.pdf", + "att-pay-pdf-001"))); + ReservationV4OrderTaskSnapshot orderTask = seedOrderTaskWithBusinessCardType( + source, + 990000000000003701L, + Instant.parse("2026-07-18T03:02:47Z"), + "GROUP", + "GROUP_CODE", + "GRP-V4-PAYMENT-SAFE-001", + ReservationV4CardType.PAYMENT.name(), + "PAYMENT", + ReservationV4CardStatus.CONFIRMED.name(), + ReservationV4CardStatus.PENDING_CONFIRM.name(), + """ + { + "card_type":"PAYMENT", + "event_type":"PAYMENT", + "business_fields":{ + "attachment_ids":["att-pay-image-001","att-pay-pdf-001"] + } + } + """, + 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].card_type").value("PAYMENT")) + .andExpect(jsonPath("$.business_cards[0].display_payload.payment_attachments.length()").value(2)) + .andExpect(jsonPath("$.business_cards[0].display_payload.payment_attachments[0].attachment_id") + .value("att-pay-image-001")) + .andExpect(jsonPath("$.business_cards[0].display_payload.payment_attachments[0].file_name") + .value("payment-slip.png")) + .andExpect(jsonPath("$.business_cards[0].display_payload.payment_attachments[0].content_type") + .value("image/png")) + .andExpect(jsonPath("$.business_cards[0].display_payload.payment_attachments[0].size_bytes") + .value(12345)) + .andExpect(jsonPath("$.business_cards[0].display_payload.payment_attachments[0].is_image") + .value(true)) + .andExpect(jsonPath("$.business_cards[0].display_payload.payment_attachments[0].preview_available") + .value(true)) + .andExpect(jsonPath("$.business_cards[0].display_payload.payment_attachments[0].download_available") + .value(true)) + .andExpect(jsonPath("$.business_cards[0].display_payload.payment_attachments[0].external_media_id") + .value("att-pay-image-001")) + .andExpect(jsonPath("$.business_cards[0].display_payload.payment_attachments[1].attachment_id") + .value("att-pay-pdf-001")) + .andExpect(jsonPath("$.business_cards[0].display_payload.payment_attachments[1].is_image") + .value(false)) + .andExpect(jsonPath("$.business_cards[0].display_payload.payment_attachments[1].preview_available") + .value(false)) + .andExpect(jsonPath("$.business_cards[0].display_payload.payment_attachments[1].download_available") + .value(true)) + .andExpect(jsonPath("$.business_cards[0].fields[?(@.field_pointer=='/business_fields/attachment_ids/0')]") + .doesNotExist()) + .andExpect(jsonPath("$.business_cards[0].fields[?(@.field_pointer=='/business_fields/attachment_ids/1')]") + .doesNotExist()) + .andExpect(content().string(not(containsString("externalUrl")))) + .andExpect(content().string(not(containsString("download_url")))) + .andExpect(content().string(not(containsString("signedUrl")))) + .andExpect(content().string(not(containsString("https://oss.example.test")))) + .andExpect(content().string(not(containsString("oss://private")))); + } + + @Test + void shouldNotExposeCrossHotelPaymentAttachmentSummaryWhenV4ReferenceIsPolluted() throws Exception { + SourceMessageCaptureResult otherSource = captureSourceMessage( + "mail-v4-query-payment-cross-hotel-001", + "V4 Query Payment Cross Hotel Attachments", + Instant.parse("2026-07-18T03:02:48Z"), + OTHER_HOTEL_ID, + List.of(new CaptureSourceMessageMedia( + "ATTACHMENT", + "other-hotel-payment-secret.pdf", + "application/pdf", + 55555L, + "https://oss.example.test/other-hotel-payment-secret.pdf", + "att-other-hotel-secret-001"))); + ReservationV4OrderTaskSnapshot orderTask = seedOrderTaskWithBusinessCardType( + otherSource, + 990000000000003702L, + Instant.parse("2026-07-18T03:02:48Z"), + "GROUP", + "GROUP_CODE", + "GRP-V4-PAYMENT-CROSS-HOTEL-001", + ReservationV4CardType.PAYMENT.name(), + "PAYMENT", + ReservationV4CardStatus.CONFIRMED.name(), + ReservationV4CardStatus.PENDING_CONFIRM.name(), + """ + { + "card_type":"PAYMENT", + "event_type":"PAYMENT", + "business_fields":{ + "attachment_ids":["att-other-hotel-secret-001"] + } + } + """, + 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.payment_attachments[0].attachment_id") + .value("att-other-hotel-secret-001")) + .andExpect(jsonPath("$.business_cards[0].display_payload.payment_attachments[0].file_name") + .doesNotExist()) + .andExpect(jsonPath("$.business_cards[0].display_payload.payment_attachments[0].content_type") + .doesNotExist()) + .andExpect(jsonPath("$.business_cards[0].display_payload.payment_attachments[0].download_available") + .value(false)) + .andExpect(jsonPath("$.business_cards[0].display_payload.payment_attachments[0].unavailable_reason_code") + .value("ATTACHMENT_NOT_FOUND")) + .andExpect(content().string(not(containsString("other-hotel-payment-secret.pdf")))) + .andExpect(content().string(not(containsString("https://oss.example.test/other-hotel-payment-secret.pdf")))); + } + + @Test + void shouldNotMatchPaymentAttachmentByInternalSourceMessageMediaId() throws Exception { + SourceMessageCaptureResult source = captureSourceMessage( + "mail-v4-query-payment-internal-media-id-001", + "V4 Query Payment Internal Media Id", + Instant.parse("2026-07-18T03:02:49Z"), + HOTEL_ID, + List.of(new CaptureSourceMessageMedia( + "ATTACHMENT", + "payment-internal-id-should-not-match.png", + "image/png", + 34567L, + "https://oss.example.test/payment-internal-id-should-not-match.png", + "att-payment-real-external-001"))); + SourceMessageMediaSummary mediaSummary = sourceMessageQueryService + .getMediaSummaries(HOTEL_ID, source.inboxId()) + .get(0); + ReservationV4OrderTaskSnapshot orderTask = seedOrderTaskWithBusinessCardType( + source, + 990000000000003703L, + Instant.parse("2026-07-18T03:02:49Z"), + "GROUP", + "GROUP_CODE", + "GRP-V4-PAYMENT-INTERNAL-MEDIA-ID-001", + ReservationV4CardType.PAYMENT.name(), + "PAYMENT", + ReservationV4CardStatus.CONFIRMED.name(), + ReservationV4CardStatus.PENDING_CONFIRM.name(), + """ + { + "card_type":"PAYMENT", + "event_type":"PAYMENT", + "business_fields":{ + "attachment_ids":["%s"] + } + } + """.formatted(mediaSummary.mediaId()), + 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.payment_attachments[0].attachment_id") + .value(mediaSummary.mediaId())) + .andExpect(jsonPath("$.business_cards[0].display_payload.payment_attachments[0].file_name") + .doesNotExist()) + .andExpect(jsonPath("$.business_cards[0].display_payload.payment_attachments[0].download_available") + .value(false)) + .andExpect(jsonPath("$.business_cards[0].display_payload.payment_attachments[0].unavailable_reason_code") + .value("ATTACHMENT_NOT_FOUND")) + .andExpect(content().string(not(containsString("payment-internal-id-should-not-match.png")))) + .andExpect(content().string(not(containsString("att-payment-real-external-001")))); + } + @Test void shouldReturnReadonlyRoomInformationDisplayModelForCancelBooking() throws Exception { Long orderId = 990000000000777002L; @@ -1235,6 +1431,67 @@ class ReservationV4QueryControllerTest { return orderTask; } + private ReservationV4OrderTaskSnapshot seedOrderTaskWithBusinessCardType( + SourceMessageCaptureResult source, + Long aiBatchId, + Instant receivedAt, + String targetBookingType, + String targetLocatorType, + String targetLocatorValue, + String businessCardType, + String businessEventType, + String basicCardStatus, + String businessCardStatus, + String businessDisplayPayloadJson, + String confirmedPayloadJson) { + LocalDateTime now = LocalDateTime.ofInstant(receivedAt.plusSeconds(10), ZoneOffset.UTC); + ReservationV4OrderTaskSnapshot orderTask = workflowRepository.findOrCreateOrderTask(new ReservationV4OrderTaskDraft( + HOTEL_ID, + source.inboxId(), + aiBatchId, + "order-generic-card-" + source.inboxId(), + 1, + null, + targetBookingType, + targetLocatorType, + targetLocatorValue, + ReservationV4TargetResolutionStatus.RESOLVED.name(), + ReservationV4OrderTaskStatus.OPEN.name(), + LocalDateTime.ofInstant(receivedAt, ZoneOffset.UTC), + now)); + insertCard(orderTask, ReservationV4CardType.SOURCE_MESSAGE_DISPLAY.name(), null, 0, 10, + ReservationV4CardStatus.READONLY.name(), null, """ + {"card_type":"SOURCE_MESSAGE_DISPLAY","source_message":{"subject":"V4 Query Business"}} + """); + insertCard(orderTask, ReservationV4CardType.BASIC_INFORMATION.name(), null, 0, 20, + basicCardStatus, reviewStatusFor(basicCardStatus), """ + { + "card_type":"BASIC_INFORMATION", + "order_ref":"order-1", + "basic_information":{"account_code":"QBD_TRAVEL","market_code":"LEISURE","source_code":"TRAVEL_AGENT"} + } + """); + ReservationV4TaskCardSnapshot businessCard = insertCard( + orderTask, + businessCardType, + businessEventType, + 1, + 30, + businessCardStatus, + reviewStatusFor(businessCardStatus), + businessDisplayPayloadJson); + if (confirmedPayloadJson != null) { + workflowRepository.confirmTaskCardWithVersion( + HOTEL_ID, + businessCard.id(), + businessCard.version(), + confirmedPayloadJson, + "v4-query-admin", + now); + } + return orderTask; + } + private void seedConfirmedRoomInformationProjection( Long orderId, String externalMessageId, @@ -1370,6 +1627,15 @@ class ReservationV4QueryControllerTest { String subject, Instant receivedAt, String hotelId) { + return captureSourceMessage(externalMessageId, subject, receivedAt, hotelId, List.of()); + } + + private SourceMessageCaptureResult captureSourceMessage( + String externalMessageId, + String subject, + Instant receivedAt, + String hotelId, + List mediaItems) { return captureService.capture(new CaptureSourceMessageCommand( hotelId, "AGENTBUS", @@ -1386,7 +1652,7 @@ class ReservationV4QueryControllerTest { "Please handle V4 query message.", "{\"source\":{\"external_message_id\":\"" + externalMessageId + "\"}}", "agentbus-outlook-v1", - List.of() + mediaItems )); } 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 3d91641..e06af61 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 @@ -1091,6 +1091,29 @@ class SuperAgentTaskResultControllerTest { .contains("att-pay-1") .contains("payment-slip.jpg") .doesNotContain("https://oss.example.test"); + Long orderTaskId = jdbcTemplate.queryForObject(""" + SELECT id + FROM workflow_reservation_v4_order_task + WHERE source_message_id = ? + AND order_ref = 'order-1' + LIMIT 1 + """, Long.class, source.inboxId()); + performAuthorized(mockMvc, adminToken(), get("/api/reservation/order-tasks/{orderTaskId}", orderTaskId) + .param("hotel_id", "HOTEL-TEST")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.business_cards[?(@.card_type=='PAYMENT')].display_payload.payment_attachments[0].attachment_id") + .value(contains("att-pay-1"))) + .andExpect(jsonPath("$.business_cards[?(@.card_type=='PAYMENT')].display_payload.payment_attachments[0].file_name") + .value(contains("payment-slip.jpg"))) + .andExpect(jsonPath("$.business_cards[?(@.card_type=='PAYMENT')].display_payload.payment_attachments[0].content_type") + .value(contains("image/jpeg"))) + .andExpect(jsonPath("$.business_cards[?(@.card_type=='PAYMENT')].display_payload.payment_attachments[0].is_image") + .value(contains(true))) + .andExpect(jsonPath("$.business_cards[?(@.card_type=='PAYMENT')].display_payload.payment_attachments[0].download_available") + .value(contains(true))) + .andExpect(content().string(not(containsString("\"field_pointer\":\"/business_fields/attachment_ids")))) + .andExpect(content().string(not(containsString("https://oss.example.test")))) + .andExpect(content().string(not(containsString("raw-event-attachment.jpg")))); List displayPayloads = jdbcTemplate.queryForList(""" SELECT display_payload_json FROM workflow_reservation_v4_task_card