实现V4 Payment附件安全摘要
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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。
|
||||
|
||||
@@ -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`;如审计查询需要酒店上下文隔离,请后端补可选入参。 |
|
||||
|
||||
@@ -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 不变更;无投影不造脏数据 |
|
||||
|
||||
@@ -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 系统管理后台接口
|
||||
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
@@ -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<SourceMessageMediaSummary> 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.<SourceMessageMediaEntity>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,用于后续排查正文是否被意外改写。
|
||||
*/
|
||||
|
||||
@@ -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<String> findPayloadJson(Long id);
|
||||
|
||||
/**
|
||||
* 按酒店和内部 SourceMessage ID 读取媒体安全摘要,不返回 externalUrl。
|
||||
*/
|
||||
List<SourceMessageMediaSummary> findMediaSummaries(String hotelId, Long id);
|
||||
|
||||
/**
|
||||
* 幂等命中但 payload 变化时,标记差异并保存安全排查摘要。
|
||||
*/
|
||||
|
||||
@@ -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<SourceMessageSummaryResponse> getSummary(Long inboxId);
|
||||
|
||||
/**
|
||||
* 按酒店和内部 SourceMessage ID 读取媒体安全摘要,供业务卡按附件 ID 匹配展示,不返回正文、HTML 或 URL。
|
||||
*
|
||||
* @param hotelId 酒店上下文 ID
|
||||
* @param inboxId 内部 SourceMessage Inbox ID
|
||||
* @return 媒体安全摘要列表
|
||||
*/
|
||||
List<SourceMessageMediaSummary> getMediaSummaries(String hotelId, Long inboxId);
|
||||
|
||||
/**
|
||||
* 按内部 SourceMessage ID 批量读取安全摘要,供业务列表避免逐条查询。
|
||||
*
|
||||
|
||||
@@ -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<SourceMessageMediaSummary> getMediaSummaries(String hotelId, Long inboxId) {
|
||||
return inboxRepository.findMediaSummaries(hotelContextService.resolveCurrentHotelId(hotelId), inboxId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量读取 SourceMessage 安全摘要,用于业务列表预取主题等摘要字段,避免逐条查询。
|
||||
*/
|
||||
|
||||
@@ -80,6 +80,7 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
|
||||
private static final Set<String> 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<String> path,
|
||||
@@ -701,15 +706,16 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
|
||||
while (fields.hasNext()) {
|
||||
Map.Entry<String, JsonNode> field = fields.next();
|
||||
List<String> 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<String> 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<String> 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<String> 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<String> 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<String> path, boolean wrappedBusinessFields) {
|
||||
private boolean isBusinessPathWritable(
|
||||
ReservationV4TaskCardSnapshot card,
|
||||
List<String> 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;
|
||||
}
|
||||
|
||||
@@ -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<String, String> 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<String, PaymentAttachmentSource> 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<ReservationV4TaskCardResult> 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<String, PaymentAttachmentSource> 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<String, PaymentAttachmentSource> 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<String, PaymentAttachmentSource> 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<String> 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<String> 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<String, PaymentAttachmentSource> paymentAttachmentSources(
|
||||
ReservationV4OrderTaskSnapshot orderTask,
|
||||
ReservationV4TaskCardSnapshot sourceMessageCard,
|
||||
List<ReservationV4TaskCardSnapshot> cards) {
|
||||
boolean hasPaymentCard = cards != null && cards.stream()
|
||||
.anyMatch(card -> ReservationV4CardType.PAYMENT.name().equals(card.cardType()));
|
||||
if (!hasPaymentCard) {
|
||||
return Map.of();
|
||||
}
|
||||
Map<String, PaymentAttachmentSource> 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<String, PaymentAttachmentSource> 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<String, PaymentAttachmentSource> 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<String, PaymentAttachmentSource> 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<Map.Entry<String, JsonNode>> iterator = fieldRoot.fields();
|
||||
while (iterator.hasNext()) {
|
||||
Map.Entry<String, JsonNode> 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
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<CaptureSourceMessageMedia> mediaItems) {
|
||||
return captureService.capture(new CaptureSourceMessageCommand(
|
||||
hotelId,
|
||||
"AGENTBUS",
|
||||
@@ -1386,7 +1652,7 @@ class ReservationV4QueryControllerTest {
|
||||
"<html><body>Please handle V4 query message.</body></html>",
|
||||
"{\"source\":{\"external_message_id\":\"" + externalMessageId + "\"}}",
|
||||
"agentbus-outlook-v1",
|
||||
List.of()
|
||||
mediaItems
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
@@ -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<String> displayPayloads = jdbcTemplate.queryForList("""
|
||||
SELECT display_payload_json
|
||||
FROM workflow_reservation_v4_task_card
|
||||
|
||||
Reference in New Issue
Block a user