实现V4 Payment附件安全摘要

This commit is contained in:
andy
2026-07-21 16:31:54 +07:00
parent 3438d4daec
commit 4b869a0ecd
15 changed files with 759 additions and 43 deletions

View File

@@ -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 notificationDebug 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 notificationDebug 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 / 附加费表高亮行业务 JSONDebug 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 来源通知 ackM002 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 来源通知 ackM002 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

View File

@@ -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。

View File

@@ -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 DisplayTrace 卡 `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 DisplayTrace 卡 `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`;如审计查询需要酒店上下文隔离,请后端补可选入参。 |

View File

@@ -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 不变更;无投影不造脏数据 |

View File

@@ -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 DisplayBasic 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 DisplayBasic 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 系统管理后台接口

View File

@@ -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
) {
}

View File

@@ -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用于后续排查正文是否被意外改写。
*/

View File

@@ -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 变化时,标记差异并保存安全排查摘要。
*/

View File

@@ -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 批量读取安全摘要,供业务列表避免逐条查询。
*

View File

@@ -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 安全摘要,用于业务列表预取主题等摘要字段,避免逐条查询。
*/

View File

@@ -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;
}

View File

@@ -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
) {
}
}

View File

@@ -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;

View File

@@ -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
));
}

View File

@@ -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