From 5b4257b125421c098d1bfcd0a70eeb8f1e12dbfd Mon Sep 17 00:00:00 2001 From: andy Date: Mon, 20 Jul 2026 12:19:05 +0700 Subject: [PATCH] =?UTF-8?q?=E7=BB=9F=E4=B8=80V4=E5=BE=85=E5=A4=84=E7=90=86?= =?UTF-8?q?=E8=AE=A1=E6=95=B0=E5=B9=B6=E8=A1=A5=E9=BD=90=E5=AE=A1=E8=AE=A1?= =?UTF-8?q?=E6=9F=A5=E8=AF=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- PROJECT_STATE.md | 16 +-- .../backend-to-frontend-notes.md | 8 +- ...002-v4-order-task-card-domain-model-cp2.md | 31 ++++- .../security-access-control-boundary.md | 12 +- .../ReservationOrderListItemResult.java | 3 + ...ReservationV4OrderTaskAuditListResult.java | 17 +++ ...onV4SourceNotificationAuditListResult.java | 17 +++ .../control/ReservationV4QueryController.java | 24 ++++ ...ybatisReservationAiWorkflowRepository.java | 59 +++++++++ .../ReservationAiWorkflowRepository.java | 10 ++ .../service/ReservationV4QueryService.java | 12 ++ .../ReservationFrontendQueryServiceImpl.java | 1 + .../impl/ReservationV4QueryServiceImpl.java | 121 ++++++++++++++++++ ...eservationFrontendQueryControllerTest.java | 12 ++ .../ReservationV4QueryControllerTest.java | 114 +++++++++++++++++ 15 files changed, 438 insertions(+), 19 deletions(-) create mode 100644 server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationV4OrderTaskAuditListResult.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationV4SourceNotificationAuditListResult.java diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index 4d22e3d..7288b25 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -4,15 +4,15 @@ | --- | --- | | 最近更新 | 2026-07-20 | | 当前分支 | `feature/huangting` | -| 当前阶段 | M002 V4 入站、多卡模型、持久化基线、入站写入、查询接口、卡片确认、复核解阻、目录校验、订单详情时间线、DB 目录、Lookup API、前端 lookup 接入、目录管理后台 CP1 前后端和订单列表 V4 继续处理入口 / open count 收口 | -| 当前重点 | M002 V4 已完成订单列表 V4 继续处理入口字段和前端消费,`GET /api/reservation/orders` 可返回 V4 下一步订单任务、卡片、动作类型、动作状态、V4 open 数和统一展示字段 `open_work_item_count`;前端“继续处理”已按 V4 优先、旧任务回退跳转,订单列表待处理数量已改用 `open_work_item_count` 展示;后续可做测试机联调、真实 PMS / OPERA / OHIP 同步或 SuperAgent 目录供给方案 | +| 当前阶段 | M002 V4 入站、多卡模型、持久化基线、入站写入、查询接口、卡片确认、复核解阻、目录校验、订单详情时间线、DB 目录、Lookup API、前端 lookup 接入、目录管理后台 CP1 前后端、订单列表 V4 继续处理入口 / open count 收口和 V4 业务审计查询 | +| 当前重点 | M002 V4 已完成订单列表 V4 继续处理入口字段和前端消费,`GET /api/reservation/orders` 可返回 V4 下一步订单任务、卡片、动作类型、动作状态、V4 open 数和统一展示字段 `open_work_item_count`;前端“继续处理”已按 V4 优先、旧任务回退跳转,订单列表待处理数量已改用 `open_work_item_count` 展示;V4 订单任务和 S10/S99 来源通知已补齐业务审计查询接口,前端可展示卡片确认、复核解阻和 ack 审计时间线;后续可做测试机联调、真实 PMS / OPERA / OHIP 同步或 SuperAgent 目录供给方案 | ## 1. 当前 Checkpoint -- 名称:`M002-V4-CP14-order-list-v4-entrypoint` -- 状态:Done,订单列表接口已补齐 V4 下一步处理入口字段:`next_v4_order_task_id`、`next_v4_action_card_id`、`next_v4_action_type`、`next_v4_action_status`、`v4_open_order_task_count`,并新增统一展示计数 `open_work_item_count`。第一版不考虑旧数据,`open_work_item_count` 等于 V4 未完成订单任务数。前端订单列表已优先使用 `next_v4_order_task_id` 跳转 V4 订单任务详情,并在无 V4 入口时回退旧 `next_processable_task_id`;待处理数量展示只读 `open_work_item_count`,不使用旧 `open_task_count` 作为展示数量。 -- 目标:让前端订单列表“继续处理”在存在 V4 待处理订单任务时可优先跳转 V4 订单任务详情;无 V4 待处理时继续回退旧 V2/V3 任务入口。 -- 边界:本轮只扩展订单列表安全摘要字段,不改 V4 确认 / 复核接口、不接真实 OPERA / OHIP、不废弃旧 V2/V3 任务入口、不把 S10/S99 来源通知放进订单列表。 +- 名称:`M002-V4-business-audit-query` +- 状态:Done,V4 订单任务和来源通知已补齐业务审计查询接口:`GET /api/reservation/order-tasks/{orderTaskId}/audits` 返回卡片确认 / 复核解阻审计流水,`GET /api/reservation/source-notifications/{notificationId}/audits` 返回 S10/S99 ack 审计流水。两个接口均强制 Bearer 登录、`RESERVATION_AUDIT_READ` 和对象所属酒店访问权,并对返回快照做敏感字段脱敏。 +- 目标:让前端可在 V4 订单任务详情和 S10/S99 来源通知详情展示审计时间线。 +- 边界:本轮不新增审计表、不调整 V4 写操作、不接真实 OPERA / OHIP、不处理旧 V2/V3 审计展示重构。 ## 2. 当前优先级 @@ -35,12 +35,12 @@ - 后续每完成一个 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` 展示待处理数量。真实 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 审计两个只读接口。真实 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 必须先确认;Account / Market / Source 当前通过数据库目录读取和派生;旧 V2/V3 任务详情和草稿确认接口后续可逐步废弃。 ## 5. Next Steps -- 后续如继续做 M002 V4,可优先进入测试机 V4 订单列表继续处理入口前后端联调、真实 PMS / OPERA / OHIP 目录同步和 `workflow_reservation_catalog_sync_run` checkpoint。 +- 后续如继续做 M002 V4,可优先进入测试机 V4 订单列表、V4 详情、V4 审计时间线前后端联调,或推进真实 PMS / OPERA / OHIP 目录同步和 `workflow_reservation_catalog_sync_run` checkpoint。 - 后续新增重要功能时,优先在 `docs/project/requirements/` 或未来 `docs/specs/` 中形成 Spec,再实现代码。 - M010 后续如需预览、历史记录、OSS 下载、订单 / 任务预填或客户字段目录化,再单独开前后端 checkpoint。 - M011 后续如需运营查询或长期追踪,可进入 CP4:设计 Excel 解析批次 / 行级持久化表;当前 CP3 只增强 SuperAgent 输入,不直接落订单、任务或长期解析历史。 diff --git a/docs/project/frontend-backend/backend-to-frontend-notes.md b/docs/project/frontend-backend/backend-to-frontend-notes.md index 79a149d..6ed768e 100644 --- a/docs/project/frontend-backend/backend-to-frontend-notes.md +++ b/docs/project/frontend-backend/backend-to-frontend-notes.md @@ -57,7 +57,9 @@ | `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 会话接口。CP8 起每张 V4 任务卡返回 `fields[]`,前端应以该字段白名单渲染可编辑控件。 | +| `GET /api/reservation/order-tasks/{orderTaskId}/audits` | 查询 V4 订单任务审计流水 | 必须带 Bearer token,需要 `RESERVATION_AUDIT_READ`,后端按订单任务实际酒店校验访问权;返回 `order_task_id` 和 `items[]`。`items[]` 用于展示 V4 卡片确认、复核解阻和订单归属确认轨迹,只包含脱敏后的审计摘要,不包含邮件正文、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 记录,只包含脱敏后的审计摘要。 | | `GET /api/reservation/lookups/accounts` | 查询 V4 Account 目录 | 必须带 Bearer token,需要 `RESERVATION_TASK_READ`,支持 `hotel_id`、`keyword`、`page_num`、`page_size`;返回统一 wrapper:`hotel_id`、`catalog_type=ACCOUNT`、`catalog_source`、`catalog_version`、`stale`、`items[]`、`page`、`warnings[]`。`keyword` 匹配目录 code 时后端按稳定 code 大写归一化处理,前端可传小写;匹配显示名仍按数据库比较规则。`keyword` 无匹配时 `items=[]` / `page.total=0`,但只要酒店未过滤目录存在,`catalog_source/catalog_version` 仍保持真实目录元数据,不代表目录未初始化。前端在 `options_source=reservation_v4_account_catalog` 时调用,只提交 `items[].code`,Market / Source 以后端确认派生结果为准。 | | `GET /api/reservation/lookups/room-types` | 查询 V4 Room Type 目录 | 必须带 Bearer token,需要 `RESERVATION_TASK_READ`,支持 `hotel_id`、`keyword`、`page_num`、`page_size`;第一版只返回当前酒店 `ACTIVE` 房型目录,不接日期过滤,不代表 PMS 全量房型。`keyword` 匹配房型 code 时后端按稳定 code 大写归一化处理,前端可传小写;匹配显示名仍按数据库比较规则。`keyword` 无匹配时按空选项处理,不要当作目录不可用。前端在 `options_source=reservation_v4_room_type_catalog` 时调用。 | | `GET /api/reservation/lookups/rate-codes` | 查询 V4 Rate Code 目录 | 必须带 Bearer token,需要 `RESERVATION_TASK_READ`,支持 `hotel_id`、`keyword`、`page_num`、`page_size`;第一版只返回当前酒店 `ACTIVE` Rate Code,`pricing_available=false` 表示后端未接真实价格,不要据此展示价格。`keyword` 匹配 Rate Code 时后端按稳定 code 大写归一化处理,前端可传小写;匹配显示名仍按数据库比较规则。`keyword` 无匹配时按空选项处理,不要当作目录不可用。前端在 `options_source=reservation_v4_rate_code_catalog` 时调用。 | @@ -73,6 +75,7 @@ | `POST /api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/confirm` | 确认 V4 订单任务卡 | 必须带 Bearer token,需要 `RESERVATION_TASK_CONFIRM`,请求 JSON 带 `version`,可选 `confirmed_payload`;Basic Information 必须先确认,业务卡第一版不强制逐张顺序确认;前端只提交当前卡 `fields[]` 中可编辑字段,后端以展示快照为基准合并,未开放字段会被忽略;确认前会按当前酒店数据库目录校验 Account / Room Type / Rate Code,嵌套字段错误会返回如 `business_fields.after.room_items.0.room_type_code` 的路径,失败返回 `V4_FIELD_VALIDATION_FAILED`;确认后卡片 `CONFIRMED`、写 `confirmed_payload_json/confirmed_at/confirmed_by` 并锁定,重复确认返回错误;成功返回刷新后的订单任务详情。 | | `POST /api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/review-resolution` | V4 复核解阻并确认卡片 | 必须带 Bearer token,需要 `RESERVATION_MANUAL_REVIEW_RESOLVE`,仅用于 `card_status=REVIEW_REQUIRED`;请求 JSON 带 `version`,可选 `field_overrides[]` 和 `reason`;订单任务归属未解决时 `confirmed_order_id` 必填,且必须是当前酒店下真实可见订单;目录错误字段可按 `validation_errors_json` / `fields[].validation_errors` 指向的 pointer 修正;成功后卡片 `CONFIRMED`、`review_status=RESOLVED`,写 `review_resolution_json/confirmed_payload_json/confirmed_at/confirmed_by` 并返回刷新后的订单任务详情。 | | `POST /api/reservation/source-notifications/{notificationId}/ack` | 确认 V4 S10/S99 来源通知已读 / 已处理 | 必须带 Bearer token,需要 `RESERVATION_TASK_CONFIRM`,请求 JSON 带 `version`;仅允许 `route_code=S10/S99`;确认后 `notification_status=ACKED`,写 `ack_by/ack_at`,成功返回刷新后的来源通知详情;重复 ack 返回当前已确认状态且不新增审计;该动作不创建订单、不参与订单阻塞。 | +| `GET /api/reservation/order-tasks/{orderTaskId}/audits` / `GET /api/reservation/source-notifications/{notificationId}/audits` | 查询 V4 业务审计展示数据 | 必须带 Bearer token,需要 `RESERVATION_AUDIT_READ`;前端可在 V4 订单任务详情和 S10/S99 来源通知详情的“审计时间线”中调用。响应沿用旧审计行结构:`audit_id`、`actor_type`、`actor_id`、`action`、`reason`、`before_snapshot`、`after_snapshot`、`occurred_at`。快照已由后端脱敏,前端仍不要把未知 URL-like 字符串当附件或正文直渲。 | | `GET /api/reservation/orders/{orderId}` | 查询订单详情与任务时间线 | 必须带 Bearer token,需要 `RESERVATION_ORDER_READ`,后端按订单所属酒店做访问校验;`include_tasks=false` 可只取订单摘要,此时旧 `tasks[]` 和 V4 `v4_order_tasks[]` 都为空数组;旧 `tasks[]` 按后端队列顺序返回,前端不要自行按创建时间重排;V4 `v4_order_tasks[]` 按同订单 V4 订单任务来源时间正序返回;隐藏技术订单详情不可作为普通订单页打开。 | | `GET /api/reservation/tasks/{taskId}` | 查询任务详情 | 必须带 Bearer token,需要 `RESERVATION_TASK_READ`,后端按任务所属酒店做访问校验;以返回的可处理状态和只读原因控制按钮,不只看任务状态;`fields[]` 已包含 P0 字段元数据;旧源邮件只读通知卡字段列表和 OPERA 操作列表为空;V3 结构化 S10/S99 通过 `source_message_only_result.agent_assessment`、`notification`、`manual_review` 展示;普通业务任务可通过 `adapter_contract_errors[]` 和 `unhandled_intents[]` 查看同批次未建任务的诊断信息;type-known manual review 会返回顶层 `review_status`、`review_resolution` 和 `manual_review`。 | | `PUT /api/reservation/tasks/{taskId}/draft` | 保存任务草稿 | 只保存草稿,不代表用户最终确认。 | @@ -487,6 +490,7 @@ RESERVATION_ROOMING_LIST_GENERATE - `/reservation/tasks` 应使用 `GET /api/reservation/workbench-items` 作为默认工作台列表入口,展示 V4 订单任务和 S10/S99 来源通知混排摘要;当筛选工作项类型为订单任务时,改用 `GET /api/reservation/order-tasks`,仅发送后端当前支持的 `keyword`、`order_task_status`、`card_status`、`page_num`、`page_size`。 - `/reservation/order-tasks/{orderTaskId}` 应使用 `GET /api/reservation/order-tasks/{orderTaskId}` 展示 `source_message_card`、`basic_information_card`、`business_cards[]`、`card_counts`、`order_task` 摘要和 `adapter_contract_errors[]` 只读诊断。 - `/reservation/source-notifications/{notificationId}` 应使用 `GET /api/reservation/source-notifications/{notificationId}` 展示 S10/S99 来源通知详情,并通过 `POST /api/reservation/source-notifications/{notificationId}/ack` 确认已读 / 已处理。 +- V4 审计时间线应分别调用 `GET /api/reservation/order-tasks/{orderTaskId}/audits` 和 `GET /api/reservation/source-notifications/{notificationId}/audits`;按钮权限使用 `/api/auth/me.permissions[]` 中的 `RESERVATION_AUDIT_READ`。 - V4 卡片确认应使用 `POST /api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/confirm`,请求带 `version`;前端只从当前卡 `fields[]` 中挑选 `editable=true`、`raw_readonly!=true`、`write_target` 指向确认 payload 的字段构造 `confirmed_payload`。 - V4 复核解阻应使用 `POST /api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/review-resolution`,请求带 `version`;`field_overrides[]` 只使用当前卡 `fields[].field_pointer`,订单任务归属未解决时允许用户填写 `confirmed_order_id`。 - 所有按钮应按后端 `availability.confirmable`、`availability.reviewable`、`availability.ackable` 和前端权限共同控制;不可操作原因优先展示 `readonly_reason_message`,否则按 `readonly_reason_code` 做友好映射。 @@ -510,13 +514,13 @@ RESERVATION_ROOMING_LIST_GENERATE - 普通任务切换订单接口继续后置。 - M002 V4 入站解析与数据模型基线已完成第一版:后端可接收 `source_message + order_contexts[] + message_events[]`,识别 `NEW_BOOKING`、`UPDATE_BOOKING`、`CANCEL_BOOKING`、`TRACE_RESERVATION_NOTES`、`ROOMING_LIST`、`PAYMENT`,并保存 V4 原始 payload、`route_code`、系统处理分类和 `field_contract_version=20260718-v4`。前端暂不需要直接调用 V4 回调接口。 -- M002 V4 CP5 已完成查询接口:普通 V4 业务包可通过 `/api/reservation/workbench-items`、`/api/reservation/order-tasks`、`/api/reservation/order-tasks/{orderTaskId}` 查看;V4 S10/S99 来源通知可通过 `/api/reservation/source-notifications/{notificationId}` 查看。M002 V4 CP6 已开放普通卡片确认和 S10/S99 ack 写接口;M002 V4 CP7 已开放 `POST /api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/review-resolution` 复核解阻接口;M002 V4 CP8 已开放 V4 卡片 `fields[]` 白名单和目录校验;M002 V4 CP11 已把固定种子迁移到数据库目录,并开放 Account / Room Type / Rate Code lookup API;目录管理后台 CP1 已开放 Account / Room Type / Rate Code 后端列表、新增、启用 / 停用接口。 +- M002 V4 CP5 已完成查询接口:普通 V4 业务包可通过 `/api/reservation/workbench-items`、`/api/reservation/order-tasks`、`/api/reservation/order-tasks/{orderTaskId}` 查看;V4 S10/S99 来源通知可通过 `/api/reservation/source-notifications/{notificationId}` 查看。M002 V4 CP6 已开放普通卡片确认和 S10/S99 ack 写接口;M002 V4 CP7 已开放 `POST /api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/review-resolution` 复核解阻接口;M002 V4 CP8 已开放 V4 卡片 `fields[]` 白名单和目录校验;M002 V4 CP11 已把固定种子迁移到数据库目录,并开放 Account / Room Type / Rate Code lookup API;目录管理后台 CP1 已开放 Account / Room Type / Rate Code 后端列表、新增、启用 / 停用接口;V4 业务审计查询已补齐订单任务审计和来源通知 ack 审计两个只读接口。 - V4 任务卡的 `display_payload_json` 只保留后端白名单展示字段;`ai_payload_json` 才包含完整 SuperAgent 原始 event。后续 V4 查询接口不得把 `ai_payload_json`、附件 URL 或 raw evidence 直接给普通页面渲染;前端对来源消息卡附件字段仍做 URL-like 文本兜底脱敏。 - 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。 - V4 包级契约错误只会保存在 AI transition 中,不会出现在普通任务列表;V4 event 级契约错误如果同批次存在其它业务任务,前端仍按任务详情里的 `adapter_contract_errors[]` 只读展示诊断信息。 - M002 V4 CP2 订单任务与多卡领域模型设计已落到 `docs/project/requirements/M002-v4-order-task-card-domain-model-cp2.md`:后续前端 V4 页面应围绕 `order_task + source_message_card + basic_information_card + business_cards[]` 设计;V4 工作台统一列表、业务订单任务列表 / 详情和 S10/S99 来源通知详情已实现。 -- M002 V4 CP3 已新增 V4 订单任务、任务卡、S10/S99 来源通知三张表和 Repository 基线;M002 V4 CP4 已把正式 V4 回调写入这些表;M002 V4 CP5 已开放查询;M002 V4 CP6 已开放普通卡片确认和 S10/S99 ack;M002 V4 CP7 已开放复核解阻与复核场景订单归属确认;M002 V4 CP8 已开放目录校验和 V4 任务卡 `fields[]` 字段白名单;M002 V4 CP11 已新增目录表、数据库种子和 lookup API;目录管理后台 CP1 已新增 `RESERVATION_CATALOG_MANAGE` 后端接口。 +- M002 V4 CP3 已新增 V4 订单任务、任务卡、S10/S99 来源通知三张表和 Repository 基线;M002 V4 CP4 已把正式 V4 回调写入这些表;M002 V4 CP5 已开放查询;M002 V4 CP6 已开放普通卡片确认和 S10/S99 ack;M002 V4 CP7 已开放复核解阻与复核场景订单归属确认;M002 V4 CP8 已开放目录校验和 V4 任务卡 `fields[]` 字段白名单;M002 V4 CP11 已新增目录表、数据库种子和 lookup API;目录管理后台 CP1 已新增 `RESERVATION_CATALOG_MANAGE` 后端接口;V4 审计查询已开放 `RESERVATION_AUDIT_READ` 下的订单任务审计和来源通知审计。 - V4 订单任务和卡片 `availability` 已新增 `reviewable`。当前语义:`REVIEW_REQUIRED` 卡如果未被 Basic Information 或前置订单任务阻塞,会返回 `read_only=false`、`editable=true`、`confirmable=false`、`reviewable=true`、`readonly_reason_code=PROCESSABLE`;前端应调用 `review-resolution`,不要调用普通 `confirm`。 - CP11 起 V4 入站阶段按当前酒店数据库目录做校验:Account 缺失或不存在时 Basic Information 卡直接 `REVIEW_REQUIRED`;业务卡已有 `room_items[].room_type_code` 或 `rate_code` 但不在当前酒店目录时,业务卡也会直接 `REVIEW_REQUIRED`,错误会回显在 `fields[].validation_errors`。 - CP8 确认接口也按 `fields[]` 白名单收口:前端可以只提交用户修改过的可编辑字段,不建议整包回传 `display_payload`。后端会从当前卡展示快照生成确认快照,并只合并可写叶子字段;来源邮件、路由、`target_order`、`order_ref`、`manual_review`、校验诊断字段以及前端额外注入字段不会写入 `confirmed_payload_json`。 diff --git a/docs/project/requirements/M002-v4-order-task-card-domain-model-cp2.md b/docs/project/requirements/M002-v4-order-task-card-domain-model-cp2.md index 6972272..f167ad9 100644 --- a/docs/project/requirements/M002-v4-order-task-card-domain-model-cp2.md +++ b/docs/project/requirements/M002-v4-order-task-card-domain-model-cp2.md @@ -6,7 +6,7 @@ | --- | --- | | 文档版本 | 0.7 | | 日期 | 2026-07-20 | -| 状态 | CP2 设计已确认;CP3-CP8、CP11、CP13、CP14 已实现 | +| 状态 | CP2 设计已确认;CP3-CP8、CP11、CP13、CP14 和 V4 业务审计查询已实现 | | 适用范围 | M002 V4 入站后的订单任务、多卡、状态、查询和写操作设计 | | 不适用范围 | V4 复核接口、真实 PMS / OPERA / OHIP、前端页面视觉稿、历史数据迁移 | @@ -16,7 +16,7 @@ M002 V4 CP1 已完成 SuperAgent V4 回调包入站解析、基础校验、路 本文是 CP2 设计文档,用于把 2026-07-18 V4 字段契约落成后续可开发的数据模型和接口草案。 -截至 CP14,后端已实现本文第 10、11、12 节中的持久化和查询基线,并已把 SuperAgent V4 入站结果写入新表:普通业务包创建 V4 订单任务、来源邮件展示卡、Basic Information 卡和业务卡;V4 S10/S99 创建来源通知。当前已开放 V4 工作台、订单任务列表 / 详情、来源通知详情查询接口、订单详情 V4 订单任务时间线、V4 卡片确认接口、S10/S99 来源通知 ack 接口、V4 `REVIEW_REQUIRED` 卡复核解阻接口、当前酒店数据库目录校验、卡片 `fields[]` 白名单、Account / Room Type / Rate Code lookup API、目录管理后台 CP1,以及订单列表 V4 继续处理入口字段。真实 PMS 同步继续后置,设计见 `M002-v4-real-catalog-lookup-api-design.md`。 +截至 CP14 和 V4 业务审计查询补齐,后端已实现本文第 10、11、12 节中的持久化和查询基线,并已把 SuperAgent V4 入站结果写入新表:普通业务包创建 V4 订单任务、来源邮件展示卡、Basic Information 卡和业务卡;V4 S10/S99 创建来源通知。当前已开放 V4 工作台、订单任务列表 / 详情、来源通知详情查询接口、订单详情 V4 订单任务时间线、V4 卡片确认接口、S10/S99 来源通知 ack 接口、V4 `REVIEW_REQUIRED` 卡复核解阻接口、V4 订单任务 / 来源通知审计查询接口、当前酒店数据库目录校验、卡片 `fields[]` 白名单、Account / Room Type / Rate Code lookup API、目录管理后台 CP1,以及订单列表 V4 继续处理入口字段。真实 PMS 同步继续后置,设计见 `M002-v4-real-catalog-lookup-api-design.md`。 后续如本文与 `M002-v4-agent-callback-field-contract.md` 的字段契约冲突,以字段契约为准;如与安全边界冲突,以 `security-access-control-boundary.md` 为准。 @@ -629,10 +629,11 @@ GET /api/reservation/orders 权限:RESERVATION_ORDER_READ ``` -在保留旧字段 `open_task_count` 和 `next_processable_task_id` 的基础上,M002 V4 CP14 已补齐以下 V4 字段: +在保留旧字段 `open_task_count` 和 `next_processable_task_id` 的基础上,M002 V4 CP14 已补齐以下 V4 字段,并新增订单列表统一展示计数字段: | 字段 | 中文说明 | | --- | --- | +| `open_work_item_count` | 订单列表统一待处理工作项数量;开发阶段不考虑旧数据,第一版等于 `v4_open_order_task_count` | | `next_v4_order_task_id` | 当前订单下第一条仍需用户处理的 V4 订单任务 ID | | `next_v4_action_card_id` | 该 V4 订单任务下第一张仍需确认或复核的卡片 ID | | `next_v4_action_type` | `CONFIRM` / `REVIEW` / `NONE` | @@ -642,6 +643,7 @@ GET /api/reservation/orders 派生规则: - 只统计绑定到订单的 V4 订单任务;S10/S99 来源通知不创建订单,不进入订单列表字段统计。 +- `open_work_item_count` 是前端展示待处理数量的统一口径;第一版忽略旧 V2/V3 数据,不叠加 `open_task_count`。 - 同订单 V4 订单任务沿用 Repository 队列顺序:`source_received_at`、`source_message_id`、`order_context_index`、`created_at`、数字 ID 正序。 - `order_task_status=COMPLETED` 的 V4 订单任务不计入 `v4_open_order_task_count`,也不作为下一步入口。 - 同一 V4 订单任务内,Basic Information 必须优先于业务卡。 @@ -650,6 +652,25 @@ GET /api/reservation/orders 前端订单列表“继续处理”应优先使用 `next_v4_order_task_id` 跳转 V4 订单任务详情;没有 V4 待处理入口时,再回退旧 `next_processable_task_id`。 +### 12.7 V4 业务审计查询 + +V4 卡片确认、复核解阻、复核场景订单归属确认和 S10/S99 来源通知 ack 已写入 `workflow_reservation_audit_log`。由于 V4 新模型不再使用旧 `workflow_reservation_task.id` 作为主承载,第一版审计查询按写入快照中的 V4 目标 ID 关联: + +```text +GET /api/reservation/order-tasks/{orderTaskId}/audits +GET /api/reservation/source-notifications/{notificationId}/audits +``` + +接口规则: + +- 两个接口都必须 Bearer 登录。 +- 权限码统一使用 `RESERVATION_AUDIT_READ`。 +- 后端先反查 V4 订单任务或来源通知的真实 `hotel_id`,再校验当前用户酒店访问权。 +- `order-tasks/{orderTaskId}/audits` 第一版返回 `V4_CARD_CONFIRM`、`V4_CARD_REVIEW_RESOLVE` 等订单任务相关业务审计。 +- `source-notifications/{notificationId}/audits` 第一版返回 `V4_SOURCE_NOTIFICATION_ACK`。 +- 响应沿用旧审计行结构:`audit_id`、`order_id`、`task_id`、`operation_id`、`actor_type`、`actor_id`、`action`、`reason`、`before_snapshot`、`after_snapshot`、`occurred_at`。 +- 审计快照只作为前端时间线展示数据,后端会清理 `raw`、正文、HTML、token、secret、私有 URL 和完整 payload 字段,前端仍不能把未知 URL-like 字符串直渲成附件或正文。 + ## 13. 前端写操作接口 ### 13.1 确认卡片 @@ -755,6 +776,7 @@ S10/S99 已确认采用来源通知模型,不继续复用隐藏技术订单或 | 能力 | 分类 | 权限码 | 审计 | | --- | --- | --- | --- | | 查询工作台、订单任务列表 / 详情、来源通知详情 | `FRONTEND_USER` | `RESERVATION_TASK_READ` | 只读默认不写业务审计 | +| 查询 V4 订单任务 / 来源通知业务审计 | `FRONTEND_USER` | `RESERVATION_AUDIT_READ` | 查询审计不再写审计;审计快照脱敏后返回 | | 确认卡片 | `FRONTEND_USER` | `RESERVATION_TASK_CONFIRM` | 写业务审计 | | 复核解阻 | `FRONTEND_USER` | `RESERVATION_MANUAL_REVIEW_RESOLVE` | 写业务审计 | | 确认 S10/S99 来源通知 | `FRONTEND_USER` | `RESERVATION_TASK_CONFIRM` | 写业务审计 | @@ -779,7 +801,8 @@ AI 原始 payload、邮件正文、附件 URL 和技术 trace 不应直接进入 | M002-V4-CP12 | 前端 Lookup 接入 | V4 卡片字段按 `options_source` 调用 lookup,替换固定种子硬编码选项,处理 stale / warning / 空目录 | | M002-V4-CP13 | 目录管理后台 V1 | Account / Market / Source 管理,临时 Room Type / Rate Code 管理,目录维护权限和管理审计 | | M002-V4-CP14 | 订单列表 V4 继续处理入口 | 已完成:`GET /api/reservation/orders` 返回 V4 下一步订单任务、卡片、动作类型、动作状态和 open 数,前端可优先跳 V4 订单任务详情 | -| M002-V4-CP15 | PMS / OPERA / OHIP 目录同步 | 同步 Adapter、同步 run、最后成功快照、失败重试和同步状态管理入口 | +| M002-V4-CP15 | V4 业务审计查询 | 已完成:`GET /api/reservation/order-tasks/{orderTaskId}/audits` 和 `GET /api/reservation/source-notifications/{notificationId}/audits` 返回卡片确认、复核解阻和来源通知 ack 的脱敏审计流水 | +| M002-V4-CP16 | PMS / OPERA / OHIP 目录同步 | 同步 Adapter、同步 run、最后成功快照、失败重试和同步状态管理入口 | ## 17. 已确认设计决策 diff --git a/docs/project/security-access-control-boundary.md b/docs/project/security-access-control-boundary.md index de85afb..ab0ddf5 100644 --- a/docs/project/security-access-control-boundary.md +++ b/docs/project/security-access-control-boundary.md @@ -44,13 +44,15 @@ | 接口 / 能力 | 分类 | 当前管控 | 目标管控 | 审计要求 | | --- | --- | --- | --- | --- | | `GET /api/reservation/tasks` | `FRONTEND_USER` | 已强制 Bearer 登录 + `RESERVATION_TASK_READ`;支持可选 `hotel_id` 并校验酒店访问权 | 保持登录 + `RESERVATION_TASK_READ` + 酒店访问权 | 只读查询默认不写业务审计 | -| `GET /api/reservation/orders` | `FRONTEND_USER` | 已强制 Bearer 登录 + `RESERVATION_ORDER_READ`;支持可选 `hotel_id` 并校验酒店访问权;已返回 V4 继续处理入口安全字段 | 保持登录 + `RESERVATION_ORDER_READ` + 酒店访问权;V4 入口字段只返回 order task / card ID、动作类型、状态和数量摘要 | 只读查询默认不写业务审计;不得返回邮件正文、附件 URL、AI 原始 payload 或来源通知原始 payload | +| `GET /api/reservation/orders` | `FRONTEND_USER` | 已强制 Bearer 登录 + `RESERVATION_ORDER_READ`;支持可选 `hotel_id` 并校验酒店访问权;已返回 V4 继续处理入口和统一 open count 安全字段 | 保持登录 + `RESERVATION_ORDER_READ` + 酒店访问权;V4 入口字段只返回 order task / card ID、动作类型、状态和数量摘要;`open_work_item_count` 仅返回当前订单待处理数量摘要 | 只读查询默认不写业务审计;不得返回邮件正文、附件 URL、AI 原始 payload 或来源通知原始 payload | | `GET /api/reservation/orders/{orderId}` | `FRONTEND_USER` | 已强制 Bearer 登录 + `RESERVATION_ORDER_READ`;按订单实际所属酒店校验访问权;已返回旧 `tasks[]` 和 V4 `v4_order_tasks[]` 安全摘要时间线 | 保持登录 + `RESERVATION_ORDER_READ` + 订单所属酒店访问权;V4 时间线读取按订单酒店过滤 | 只读查询默认不写业务审计;不得在 `v4_order_tasks[]` 返回邮件正文、附件 URL、AI 原始 payload 或来源通知原始 payload | | `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` + 订单任务所属酒店访问权 | 只读查询默认不写业务审计;邮件正文和附件读取仍走 SourceMessage 原文权限;不得返回 `ai_payload_json` 或附件 URL;同批次 `adapter_contract_errors[]` 只返回白名单诊断字段;前端普通业务卡如遇 URL-like 附件字符串必须二次脱敏 | +| `GET /api/reservation/order-tasks/{orderTaskId}/audits` | `FRONTEND_USER` | 已强制 Bearer 登录 + `RESERVATION_AUDIT_READ` + V4 订单任务所属酒店访问权 | 保持登录 + `RESERVATION_AUDIT_READ` + 订单任务所属酒店访问权;仅返回卡片确认和复核解阻审计摘要 | 查询审计不再写审计;返回快照必须脱敏,不返回原始邮件正文、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 | | `GET /api/reservation/lookups/accounts` | `FRONTEND_USER` | 已实现 M002 V4 CP11;强制 Bearer 登录 + `RESERVATION_TASK_READ` + 酒店访问权 | 保持登录 + `RESERVATION_TASK_READ` + 酒店访问权;只返回 Account code、显示名、派生 Market / Source 和目录安全元数据 | 只读查询默认不写业务审计;不得返回 PMS 原始响应、Secret 或外部同步错误详情 | | `GET /api/reservation/lookups/room-types` | `FRONTEND_USER` | 已实现 M002 V4 CP11;强制 Bearer 登录 + `RESERVATION_TASK_READ` + 酒店访问权 | 保持登录 + `RESERVATION_TASK_READ` + 酒店访问权;只返回当前酒店可选 ACTIVE 房型目录快照 | 只读查询默认不写业务审计;不得返回 PMS 原始响应、价格敏感细节或跨酒店房型 | | `GET /api/reservation/lookups/rate-codes` | `FRONTEND_USER` | 已实现 M002 V4 CP11;强制 Bearer 登录 + `RESERVATION_TASK_READ` + 酒店访问权 | 保持登录 + `RESERVATION_TASK_READ` + 酒店访问权;只返回当前酒店可选 ACTIVE Rate Code 目录快照,不做真实价格计算 | 只读查询默认不写业务审计;不得返回 PMS 原始响应、价格明细、Secret 或跨酒店 Rate Plan | @@ -135,7 +137,7 @@ | `RESERVATION_TASK_CONFIRM` | 最终确认任务 | 任务确认 | | `RESERVATION_MANUAL_REVIEW_RESOLVE` | 处理人工复核和 Fallback 转换 | 复核解阻、Fallback 转换 | | `RESERVATION_OPERA_SIM_EXECUTE` | 执行或重试 OPERA 模拟 / 未来真实操作 | OPERA execute / retry | -| `RESERVATION_AUDIT_READ` | 查看业务审计流水 | 任务审计列表 | +| `RESERVATION_AUDIT_READ` | 查看业务审计流水 | 旧任务审计列表、V4 订单任务审计列表、V4 来源通知 ack 审计列表 | | `RESERVATION_INVOICE_GENERATE` | 生成 Reservation Proforma Invoice | Manual Invoice 生成、未来任务 / 订单预填生成 | | `RESERVATION_ROOMING_LIST_GENERATE` | 生成 Reservation Rooming List Excel | Rooming List 上传名单并生成 `.xlsx` 下载 | | `RESERVATION_CATALOG_MANAGE` | 维护 Reservation 受控目录 | 已用于 `/api/admin/reservation/catalogs/**`;第一版支持 Account、Room Type、Rate Code 管理,Market / Source 独立管理后置 | @@ -181,7 +183,7 @@ - 前端用户接口必须从当前登录用户解析可访问酒店集合。 - 显式传入 `hotel_id` 时,后端必须校验该用户是否可访问该酒店。 - 未传 `hotel_id` 时,单酒店阶段可按用户默认酒店或系统唯一 `ACTIVE` 酒店解析。 -- 直接按 `taskId`、`orderId`、`sourceMessageId` 定位的接口,必须反查对象所属 `hotel_id` 并校验访问权;当前已覆盖 Reservation 订单详情、任务详情、任务审计列表和 SourceMessage 摘要详情。 +- 直接按 `taskId`、`orderId`、`sourceMessageId`、V4 `orderTaskId` 或 V4 `notificationId` 定位的接口,必须反查对象所属 `hotel_id` 并校验访问权;当前已覆盖 Reservation 订单详情、任务详情、任务审计列表、V4 订单任务详情 / 审计、V4 来源通知详情 / 审计和 SourceMessage 摘要详情。 - 第三方 SuperAgent 接口第一版不依赖用户酒店权限,但必须使用系统酒店解析 SourceMessage,并防止跨酒店误匹配。 - AgentBus 入站不接受外部随意指定酒店;单酒店阶段由系统唯一 `ACTIVE` 酒店解析。 @@ -190,7 +192,7 @@ | 审计类型 | 当前载体 | 必须记录的动作 | | --- | --- | --- | | 管理审计 | `platform_admin_audit_log` | 用户、角色、权限、菜单、酒店的写操作 | -| 业务审计 | `workflow_reservation_audit_log` | 任务确认、人工复核、订单归属确认、OPERA 执行 / 重试 | +| 业务审计 | `workflow_reservation_audit_log` | 任务确认、人工复核、订单归属确认、V4 来源通知 ack、OPERA 执行 / 重试 | | 邮件原文读取审计 | `platform_source_message_original_access_audit` | 读取邮件正文、HTML、附件外链 | | SuperAgent 入站追踪 | `workflow_reservation_ai_batch`、`workflow_reservation_ai_transition` | task-results / MCP 提交、路由、adapter error | | AgentBus 分发追踪 | `platform_superagent_dispatch_run` | SourceMessage 自动分发 SuperAgent、重试、失败摘要 | @@ -226,7 +228,7 @@ 后续开发权限收口时建议按以下顺序推进: -1. 已完成 CP1:Reservation / SourceMessage 第一批只读查询接口已收口登录、权限码和酒店访问权,包括任务列表、订单列表、订单详情、任务详情、任务审计列表、SourceMessage 摘要列表和摘要详情。 +1. 已完成 CP1:Reservation / SourceMessage 第一批只读查询接口已收口登录、权限码和酒店访问权,包括任务列表、订单列表、订单详情、任务详情、任务审计列表、V4 订单任务审计列表、V4 来源通知审计列表、SourceMessage 摘要列表和摘要详情。 2. 已完成 CP2:邮件原文和会话完整正文读取已迁移到 `SOURCE_MESSAGE_READ` + `SOURCE_MESSAGE_ORIGINAL_READ`,并按消息所属酒店校验访问权;原文读取审计 actor 使用当前登录用户稳定 ID。 3. 再收口 Reservation 写操作:草稿、确认、人工复核、OPERA 模拟。 4. 迁移业务审计 actor 到当前登录用户。 diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationOrderListItemResult.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationOrderListItemResult.java index bca012e..7575c00 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationOrderListItemResult.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationOrderListItemResult.java @@ -15,6 +15,7 @@ import java.time.OffsetDateTime; * @param confirmationNumber Confirmation No.,仅订单业务号类型为 CONFIRMATION_NUMBER 时返回 * @param displayName 订单展示名 * @param openTaskCount 未关闭任务数,排除 COMPLETED 和 FAILED + * @param openWorkItemCount 订单列表统一待处理工作项数量,第一版按 V4 未完成订单任务数计算,不叠加旧任务 * @param nextProcessableTaskId 下一条当前可处理任务 ID * @param nextV4OrderTaskId 当前订单下第一条仍需用户处理的 V4 订单任务 ID * @param nextV4ActionCardId 当前 V4 订单任务下第一张仍需确认或复核的卡片 ID @@ -43,6 +44,8 @@ public record ReservationOrderListItemResult( String displayName, @JsonProperty("open_task_count") Integer openTaskCount, + @JsonProperty("open_work_item_count") + Integer openWorkItemCount, @JsonProperty("next_processable_task_id") String nextProcessableTaskId, @JsonProperty("next_v4_order_task_id") diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationV4OrderTaskAuditListResult.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationV4OrderTaskAuditListResult.java new file mode 100644 index 0000000..fd451de --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationV4OrderTaskAuditListResult.java @@ -0,0 +1,17 @@ +package cn.nianxx.thhotel.workflows.reservation.common.result; + +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.List; + +/** + * V4 订单任务审计列表响应。用于前端展示卡片确认和人工复核解阻流水。 + */ +public record ReservationV4OrderTaskAuditListResult( + /** V4 订单任务 ID。 */ + @JsonProperty("order_task_id") + String orderTaskId, + + /** 已脱敏的审计流水列表。 */ + List items +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationV4SourceNotificationAuditListResult.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationV4SourceNotificationAuditListResult.java new file mode 100644 index 0000000..d8bd9ca --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationV4SourceNotificationAuditListResult.java @@ -0,0 +1,17 @@ +package cn.nianxx.thhotel.workflows.reservation.common.result; + +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.List; + +/** + * V4 来源通知审计列表响应。用于前端展示 S10/S99 已读确认流水。 + */ +public record ReservationV4SourceNotificationAuditListResult( + /** V4 来源通知 ID。 */ + @JsonProperty("notification_id") + String notificationId, + + /** 已脱敏的审计流水列表。 */ + List items +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationV4QueryController.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationV4QueryController.java index 96f8885..0bcb2ad 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationV4QueryController.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationV4QueryController.java @@ -4,8 +4,10 @@ import cn.nianxx.thhotel.platform.access.common.enums.PlatformPermissionCode; import cn.nianxx.thhotel.platform.security.service.FrontendAuthorizationService; import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationV4OrderTaskQueryRequest; import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationV4WorkbenchQueryRequest; +import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4OrderTaskAuditListResult; import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4OrderTaskDetailResult; import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4OrderTaskListResult; +import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4SourceNotificationAuditListResult; import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4SourceNotificationDetailResult; import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4WorkbenchListResult; import cn.nianxx.thhotel.workflows.reservation.service.ReservationV4QueryService; @@ -89,6 +91,17 @@ public class ReservationV4QueryController { return v4QueryService.getOrderTaskDetail(hotelId, orderTaskId); } + /** + * 查询 V4 订单任务审计流水,用于前端展示卡片确认和复核记录。 + */ + @GetMapping(value = "/order-tasks/{orderTaskId}/audits", produces = MediaType.APPLICATION_JSON_VALUE) + public ReservationV4OrderTaskAuditListResult listOrderTaskAudits( + @PathVariable Long orderTaskId, + @RequestParam(name = "hotel_id", required = false) String hotelId) { + authorizationService.requirePermission(PlatformPermissionCode.RESERVATION_AUDIT_READ.name()); + return v4QueryService.listOrderTaskAudits(hotelId, orderTaskId); + } + /** * 查询 V4 S10/S99 来源通知详情,只返回安全邮件摘要和通知状态。 */ @@ -99,4 +112,15 @@ public class ReservationV4QueryController { authorizationService.requirePermission(PlatformPermissionCode.RESERVATION_TASK_READ.name()); return v4QueryService.getSourceNotificationDetail(hotelId, notificationId); } + + /** + * 查询 V4 来源通知审计流水,用于前端展示 S10/S99 确认已读记录。 + */ + @GetMapping(value = "/source-notifications/{notificationId}/audits", produces = MediaType.APPLICATION_JSON_VALUE) + public ReservationV4SourceNotificationAuditListResult listSourceNotificationAudits( + @PathVariable Long notificationId, + @RequestParam(name = "hotel_id", required = false) String hotelId) { + authorizationService.requirePermission(PlatformPermissionCode.RESERVATION_AUDIT_READ.name()); + return v4QueryService.listSourceNotificationAudits(hotelId, notificationId); + } } diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/repository/MybatisReservationAiWorkflowRepository.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/repository/MybatisReservationAiWorkflowRepository.java index 080e50d..e49d21f 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/repository/MybatisReservationAiWorkflowRepository.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/repository/MybatisReservationAiWorkflowRepository.java @@ -57,6 +57,11 @@ import org.springframework.stereotype.Repository; @Repository public class MybatisReservationAiWorkflowRepository implements ReservationAiWorkflowRepository { + private static final List V4_ORDER_TASK_AUDIT_ACTIONS = List.of( + "V4_CARD_CONFIRM", + "V4_CARD_REVIEW_RESOLVE"); + private static final String ACTION_V4_SOURCE_NOTIFICATION_ACK = "V4_SOURCE_NOTIFICATION_ACK"; + private final ReservationAiBatchMapper batchMapper; private final ReservationAiTransitionMapper transitionMapper; private final ReservationOrderMapper orderMapper; @@ -900,6 +905,60 @@ public class MybatisReservationAiWorkflowRepository implements ReservationAiWork .toList(); } + /** + * 查询 V4 订单任务审计流水。V4 审计写入时不占用旧 task_id,通过快照目标 ID 做第一版关联。 + */ + @Override + public List findAuditLogsByV4OrderTaskId(String hotelId, Long orderTaskId) { + return findAuditLogsBySnapshotTarget( + hotelId, + V4_ORDER_TASK_AUDIT_ACTIONS, + "v4_order_task_id", + orderTaskId); + } + + /** + * 查询 V4 来源通知审计流水。第一版仅包含确认已读 / 已处理 ack 记录。 + */ + @Override + public List findAuditLogsByV4SourceNotificationId(String hotelId, Long notificationId) { + return findAuditLogsBySnapshotTarget( + hotelId, + List.of(ACTION_V4_SOURCE_NOTIFICATION_ACK), + "v4_source_notification_id", + notificationId); + } + + /** + * 按审计快照中的目标字段过滤 V4 审计记录,避免旧 task_id 为空导致 V4 审计不可查。 + */ + private List findAuditLogsBySnapshotTarget( + String hotelId, + List actions, + String targetFieldName, + Long targetId) { + if (targetId == null) { + return List.of(); + } + String targetFieldToken = "\"" + targetFieldName + "\""; + String targetIdToken = "\"" + targetId + "\""; + return auditLogMapper.selectList(Wrappers.lambdaQuery() + .eq(ReservationAuditLogEntity::getHotelId, hotelId) + .in(ReservationAuditLogEntity::getAction, actions) + .and(wrapper -> wrapper + .and(beforeSnapshot -> beforeSnapshot + .like(ReservationAuditLogEntity::getBeforeSnapshotJson, targetFieldToken) + .like(ReservationAuditLogEntity::getBeforeSnapshotJson, targetIdToken)) + .or(afterSnapshot -> afterSnapshot + .like(ReservationAuditLogEntity::getAfterSnapshotJson, targetFieldToken) + .like(ReservationAuditLogEntity::getAfterSnapshotJson, targetIdToken))) + .orderByAsc(ReservationAuditLogEntity::getOccurredAt) + .orderByAsc(ReservationAuditLogEntity::getId)) + .stream() + .map(this::toAuditLogSnapshot) + .toList(); + } + /** * 按业务 key 查询 AI transition。该查询只用于补充待处理任务上下文,不替代订单查询。 */ diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/repository/ReservationAiWorkflowRepository.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/repository/ReservationAiWorkflowRepository.java index d0253c1..56706e3 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/repository/ReservationAiWorkflowRepository.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/repository/ReservationAiWorkflowRepository.java @@ -283,4 +283,14 @@ public interface ReservationAiWorkflowRepository { * 查询任务审计流水。 */ List findAuditLogsByTaskId(String hotelId, Long taskId); + + /** + * 按 V4 订单任务 ID 查询审计流水。V4 审计第一版通过快照中的目标 ID 关联。 + */ + List findAuditLogsByV4OrderTaskId(String hotelId, Long orderTaskId); + + /** + * 按 V4 来源通知 ID 查询审计流水。第一版仅返回 S10/S99 ack 相关记录。 + */ + List findAuditLogsByV4SourceNotificationId(String hotelId, Long notificationId); } diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/ReservationV4QueryService.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/ReservationV4QueryService.java index 4d81ea8..96b8528 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/ReservationV4QueryService.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/ReservationV4QueryService.java @@ -3,7 +3,9 @@ package cn.nianxx.thhotel.workflows.reservation.service; import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationV4OrderTaskQueryRequest; import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationV4WorkbenchQueryRequest; import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4OrderTaskDetailResult; +import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4OrderTaskAuditListResult; import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4OrderTaskListResult; +import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4SourceNotificationAuditListResult; import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4SourceNotificationDetailResult; import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4WorkbenchListResult; @@ -27,8 +29,18 @@ public interface ReservationV4QueryService { */ ReservationV4OrderTaskDetailResult getOrderTaskDetail(String hotelId, Long orderTaskId); + /** + * 查询 V4 订单任务审计流水,只返回前端可展示的脱敏审计摘要。 + */ + ReservationV4OrderTaskAuditListResult listOrderTaskAudits(String hotelId, Long orderTaskId); + /** * 查询 V4 S10/S99 来源通知详情,不返回业务订单任务结构。 */ ReservationV4SourceNotificationDetailResult getSourceNotificationDetail(String hotelId, Long notificationId); + + /** + * 查询 V4 来源通知审计流水,只返回 S10/S99 ack 相关脱敏审计摘要。 + */ + ReservationV4SourceNotificationAuditListResult listSourceNotificationAudits(String hotelId, Long notificationId); } diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationFrontendQueryServiceImpl.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationFrontendQueryServiceImpl.java index f583fdc..eebe269 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationFrontendQueryServiceImpl.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationFrontendQueryServiceImpl.java @@ -574,6 +574,7 @@ public class ReservationFrontendQueryServiceImpl implements ReservationFrontendQ confirmationNumber(order), order.displayName(), openTaskCount(tasks), + v4NextAction.openOrderTaskCount(), nextProcessableTaskId(tasks, availabilityByTaskId), v4NextAction.orderTaskId(), v4NextAction.cardId(), diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationV4QueryServiceImpl.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationV4QueryServiceImpl.java index 4a37c1e..4b65bb1 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationV4QueryServiceImpl.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationV4QueryServiceImpl.java @@ -6,6 +6,7 @@ import cn.nianxx.thhotel.platform.hotel.service.HotelContextService; 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; +import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationAuditLogSnapshot; import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationPageSnapshot; import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4AccountCatalogItem; import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4OrderTaskSnapshot; @@ -24,13 +25,16 @@ import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationV4Workb import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationAiTransitionDisplayResult; import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationOrderSummaryResult; import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationPaginationResult; +import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationTaskAuditLogResult; import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4ActionAvailabilityResult; import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4CardCountsResult; +import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4OrderTaskAuditListResult; import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4OrderTaskDetailResult; import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4OrderTaskListItemResult; import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4OrderTaskListResult; import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4OrderTaskSummaryResult; import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4SourceMessageSummaryResult; +import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4SourceNotificationAuditListResult; import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4SourceNotificationDetailResult; import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4SourceNotificationSummaryResult; import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4TaskCardFieldResult; @@ -44,6 +48,7 @@ import cn.nianxx.thhotel.workflows.reservation.service.ReservationV4DirectorySer import cn.nianxx.thhotel.workflows.reservation.service.ReservationV4QueryService; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.NullNode; import com.fasterxml.jackson.databind.node.ObjectNode; import java.time.LocalDateTime; @@ -53,6 +58,7 @@ import java.util.HashSet; import java.util.Iterator; import java.util.LinkedHashMap; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.Objects; import java.util.Optional; @@ -251,6 +257,21 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService orderAvailability); } + /** + * 查询 V4 订单任务审计流水,按订单任务实际酒店做访问权校验后返回脱敏摘要。 + */ + @Override + @Transactional(readOnly = true) + public ReservationV4OrderTaskAuditListResult listOrderTaskAudits(String hotelId, Long orderTaskId) { + ReservationV4OrderTaskSnapshot orderTask = requireOrderTask(orderTaskId, hotelId); + List items = aiWorkflowRepository + .findAuditLogsByV4OrderTaskId(orderTask.hotelId(), orderTask.id()) + .stream() + .map(this::toAuditLogResult) + .toList(); + return new ReservationV4OrderTaskAuditListResult(orderTask.id().toString(), items); + } + /** * 查询 V4 S10/S99 来源通知详情,只返回安全邮件摘要和通知状态。 */ @@ -269,6 +290,23 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService availability); } + /** + * 查询 V4 来源通知审计流水,按通知实际酒店做访问权校验后返回脱敏摘要。 + */ + @Override + @Transactional(readOnly = true) + public ReservationV4SourceNotificationAuditListResult listSourceNotificationAudits( + String hotelId, + Long notificationId) { + ReservationV4SourceNotificationSnapshot notification = requireSourceNotification(notificationId, hotelId); + List items = aiWorkflowRepository + .findAuditLogsByV4SourceNotificationId(notification.hotelId(), notification.id()) + .stream() + .map(this::toAuditLogResult) + .toList(); + return new ReservationV4SourceNotificationAuditListResult(notification.id().toString(), items); + } + private ReservationPageSnapshot querySourceNotificationPage( String hotelId, String notificationStatus, @@ -399,6 +437,89 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService availability); } + /** + * 转换 V4 审计快照为前端审计行,时间统一输出 UTC ISO 字符串。 + */ + private ReservationTaskAuditLogResult toAuditLogResult(ReservationAuditLogSnapshot snapshot) { + return new ReservationTaskAuditLogResult( + snapshot.id().toString(), + snapshot.orderId() == null ? null : snapshot.orderId().toString(), + snapshot.taskId() == null ? null : snapshot.taskId().toString(), + snapshot.operationId() == null ? null : snapshot.operationId().toString(), + snapshot.actorType(), + snapshot.actorId(), + snapshot.action(), + snapshot.reason(), + auditPayloadToObject(snapshot.beforeSnapshotJson()), + auditPayloadToObject(snapshot.afterSnapshotJson()), + auditOccurredAt(snapshot)); + } + + /** + * 格式化审计发生时间,固定输出带秒的 UTC ISO 8601 字符串。 + */ + private String auditOccurredAt(ReservationAuditLogSnapshot snapshot) { + if (snapshot.occurredAt() == null) { + return null; + } + return UtcTimeFormatter.toUtcOffsetDateTime(snapshot.occurredAt()).toInstant().toString(); + } + + /** + * 将审计 JSON 快照转换为安全对象,避免业务审计展示泄漏原始正文或敏感 token。 + */ + private Object auditPayloadToObject(String payloadJson) { + JsonNode payload = parseJson(payloadJson); + if (payload == null || payload.isNull() || payload.isMissingNode()) { + return null; + } + return objectMapper.convertValue(sanitizeAuditPayload(payload), Object.class); + } + + /** + * 递归清理审计快照中的敏感字段,保留状态码、目标 ID 和普通业务摘要。 + */ + private JsonNode sanitizeAuditPayload(JsonNode payload) { + if (payload == null || payload.isNull() || payload.isMissingNode()) { + return NullNode.getInstance(); + } + if (payload.isObject()) { + ObjectNode sanitized = objectMapper.createObjectNode(); + Iterator> iterator = payload.fields(); + while (iterator.hasNext()) { + Map.Entry entry = iterator.next(); + if (isSensitiveAuditField(entry.getKey())) { + continue; + } + sanitized.set(entry.getKey(), sanitizeAuditPayload(entry.getValue())); + } + return sanitized; + } + if (payload.isArray()) { + ArrayNode sanitized = objectMapper.createArrayNode(); + for (JsonNode item : payload) { + sanitized.add(sanitizeAuditPayload(item)); + } + return sanitized; + } + return payload; + } + + /** + * 判断审计字段名是否属于前端不应展示的敏感信息。 + */ + private boolean isSensitiveAuditField(String fieldName) { + String normalized = fieldName == null ? "" : fieldName.toLowerCase(Locale.ROOT); + return "raw".equals(normalized) + || normalized.endsWith("_raw") + || normalized.contains("payload_json") + || normalized.contains("private_url") + || normalized.contains("secret") + || normalized.contains("token") + || normalized.contains("body") + || normalized.contains("html"); + } + private ReservationV4CardCountsResult cardCounts(List cards) { List safeCards = cards == null ? List.of() : cards; return new ReservationV4CardCountsResult( diff --git a/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationFrontendQueryControllerTest.java b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationFrontendQueryControllerTest.java index c3b69b8..947baa2 100644 --- a/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationFrontendQueryControllerTest.java +++ b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationFrontendQueryControllerTest.java @@ -588,12 +588,16 @@ class ReservationFrontendQueryControllerTest { .value(contains("ACTIVE"))) .andExpect(jsonPath("$.items[?(@.order_id=='" + activeOrderId + "')].open_task_count") .value(contains(1))) + .andExpect(jsonPath("$.items[?(@.order_id=='" + activeOrderId + "')].open_work_item_count") + .value(contains(0))) .andExpect(jsonPath("$.items[?(@.order_id=='" + activeOrderId + "')].next_processable_task_id") .value(contains(pendingTaskId.toString()))) .andExpect(jsonPath("$.items[?(@.order_id=='" + endedOrderId + "')].order_status") .value(contains("ENDED"))) .andExpect(jsonPath("$.items[?(@.order_id=='" + endedOrderId + "')].open_task_count") .value(contains(0))) + .andExpect(jsonPath("$.items[?(@.order_id=='" + endedOrderId + "')].open_work_item_count") + .value(contains(0))) .andExpect(jsonPath("$.page.total").value(2)); performAuthorized(mockMvc, adminToken(), get("/api/reservation/orders") @@ -732,6 +736,8 @@ class ReservationFrontendQueryControllerTest { .value(contains(legacyTaskId.toString()))) .andExpect(jsonPath("$.items[?(@.order_id=='" + basicFirstOrderId + "')].v4_open_order_task_count") .value(contains(2))) + .andExpect(jsonPath("$.items[?(@.order_id=='" + basicFirstOrderId + "')].open_work_item_count") + .value(contains(2))) .andExpect(jsonPath("$.items[?(@.order_id=='" + basicFirstOrderId + "')].next_v4_order_task_id") .value(contains(basicFirstOrderTask.id().toString()))) .andExpect(jsonPath("$.items[?(@.order_id=='" + basicFirstOrderId + "')].next_v4_action_card_id") @@ -742,6 +748,8 @@ class ReservationFrontendQueryControllerTest { .value(contains("PENDING_CONFIRM"))) .andExpect(jsonPath("$.items[?(@.order_id=='" + reviewFirstOrderId + "')].v4_open_order_task_count") .value(contains(1))) + .andExpect(jsonPath("$.items[?(@.order_id=='" + reviewFirstOrderId + "')].open_work_item_count") + .value(contains(1))) .andExpect(jsonPath("$.items[?(@.order_id=='" + reviewFirstOrderId + "')].next_v4_order_task_id") .value(contains(reviewOrderTask.id().toString()))) .andExpect(jsonPath("$.items[?(@.order_id=='" + reviewFirstOrderId + "')].next_v4_action_card_id") @@ -760,6 +768,8 @@ class ReservationFrontendQueryControllerTest { .value(contains("PENDING_CONFIRM"))) .andExpect(jsonPath("$.items[?(@.order_id=='" + completedOnlyOrderId + "')].v4_open_order_task_count") .value(contains(0))) + .andExpect(jsonPath("$.items[?(@.order_id=='" + completedOnlyOrderId + "')].open_work_item_count") + .value(contains(0))) .andExpect(jsonPath("$.items[?(@.order_id=='" + completedOnlyOrderId + "')].next_v4_order_task_id") .value(contains(nullValue()))) .andExpect(jsonPath("$.items[?(@.order_id=='" + completedOnlyOrderId + "')].next_v4_action_type") @@ -768,6 +778,8 @@ class ReservationFrontendQueryControllerTest { .value(contains(nullValue()))) .andExpect(jsonPath("$.items[?(@.order_id=='" + noV4OrderId + "')].v4_open_order_task_count") .value(contains(0))) + .andExpect(jsonPath("$.items[?(@.order_id=='" + noV4OrderId + "')].open_work_item_count") + .value(contains(0))) .andExpect(jsonPath("$.items[?(@.order_id=='" + noV4OrderId + "')].next_v4_action_type") .value(contains("NONE"))); } diff --git a/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationV4QueryControllerTest.java b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationV4QueryControllerTest.java index 03356b9..b77a885 100644 --- a/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationV4QueryControllerTest.java +++ b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationV4QueryControllerTest.java @@ -22,6 +22,7 @@ import cn.nianxx.thhotel.platform.message.common.result.SourceMessageCaptureResu import cn.nianxx.thhotel.platform.message.service.SourceMessageCaptureService; 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; import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4OrderTaskDraft; import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4OrderTaskSnapshot; import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4SourceNotificationDraft; @@ -367,6 +368,58 @@ class ReservationV4QueryControllerTest { .andExpect(content().string(not(containsString("Sensitive raw notification body")))); } + @Test + void shouldReturnV4OrderTaskAudits() throws Exception { + ReservationV4OrderTaskSnapshot orderTask = seedOrderTask( + "mail-v4-query-order-task-audits-001", + Instant.parse("2026-07-18T04:10:00Z")); + insertV4OrderTaskAudit(orderTask, "V4_CARD_CONFIRM", "v4-query-admin", + Instant.parse("2026-07-18T04:11:00Z")); + insertV4OrderTaskAudit(orderTask, "V4_CARD_REVIEW_RESOLVE", "v4-query-admin", + Instant.parse("2026-07-18T04:12:00Z")); + ReservationV4OrderTaskSnapshot otherOrderTask = seedOrderTask( + "mail-v4-query-order-task-audits-other-001", + Instant.parse("2026-07-18T04:13:00Z")); + insertV4OrderTaskAudit(otherOrderTask, "V4_CARD_CONFIRM", "v4-query-admin", + Instant.parse("2026-07-18T04:14:00Z")); + + performAuthorized(mockMvc, adminToken(), get("/api/reservation/order-tasks/{orderTaskId}/audits", + orderTask.id()).param("hotel_id", HOTEL_ID)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.order_task_id").value(orderTask.id().toString())) + .andExpect(jsonPath("$.items.length()").value(2)) + .andExpect(jsonPath("$.items[0].action").value("V4_CARD_CONFIRM")) + .andExpect(jsonPath("$.items[0].actor_id").value("v4-query-admin")) + .andExpect(jsonPath("$.items[0].after_snapshot.v4_order_task_id").value(orderTask.id().toString())) + .andExpect(jsonPath("$.items[1].action").value("V4_CARD_REVIEW_RESOLVE")) + .andExpect(jsonPath("$.items[1].after_snapshot.v4_order_task_id").value(orderTask.id().toString())) + .andExpect(jsonPath("$.items[0].occurred_at").value(matchesPattern(UTC_INSTANT_PATTERN))) + .andExpect(content().string(not(containsString(otherOrderTask.id().toString())))) + .andExpect(content().string(not(containsString("Sensitive raw audit body")))); + } + + @Test + void shouldReturnV4SourceNotificationAudits() throws Exception { + ReservationV4SourceNotificationSnapshot notification = seedSourceNotification( + "mail-v4-query-notification-audits-001", + "S10", + Instant.parse("2026-07-18T04:20:00Z")); + insertV4SourceNotificationAudit(notification, "v4-query-admin", + Instant.parse("2026-07-18T04:21:00Z")); + + performAuthorized(mockMvc, adminToken(), get("/api/reservation/source-notifications/{notificationId}/audits", + notification.id()).param("hotel_id", HOTEL_ID)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.notification_id").value(notification.id().toString())) + .andExpect(jsonPath("$.items.length()").value(1)) + .andExpect(jsonPath("$.items[0].action").value("V4_SOURCE_NOTIFICATION_ACK")) + .andExpect(jsonPath("$.items[0].actor_type").value("USER")) + .andExpect(jsonPath("$.items[0].after_snapshot.v4_source_notification_id") + .value(notification.id().toString())) + .andExpect(jsonPath("$.items[0].occurred_at").value(matchesPattern(UTC_INSTANT_PATTERN))) + .andExpect(content().string(not(containsString("Sensitive raw audit body")))); + } + @Test void shouldRejectV4WorkbenchItemsWithoutLogin() throws Exception { mockMvc.perform(get("/api/reservation/workbench-items") @@ -400,6 +453,14 @@ class ReservationV4QueryControllerTest { notification.id()).param("hotel_id", HOTEL_ID)) .andExpect(status().isForbidden()) .andExpect(jsonPath("$.error_code").value("FRONTEND_PERMISSION_DENIED")); + performAuthorized(mockMvc, noPermissionToken(), get("/api/reservation/order-tasks/{orderTaskId}/audits", + orderTask.id()).param("hotel_id", HOTEL_ID)) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.error_code").value("FRONTEND_PERMISSION_DENIED")); + performAuthorized(mockMvc, noPermissionToken(), get("/api/reservation/source-notifications/{notificationId}/audits", + notification.id()).param("hotel_id", HOTEL_ID)) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.error_code").value("FRONTEND_PERMISSION_DENIED")); } @Test @@ -427,6 +488,14 @@ class ReservationV4QueryControllerTest { notification.id()).param("hotel_id", OTHER_HOTEL_ID)) .andExpect(status().isForbidden()) .andExpect(jsonPath("$.error_code").value("HOTEL_ACCESS_DENIED")); + performAuthorized(mockMvc, adminToken(), get("/api/reservation/order-tasks/{orderTaskId}/audits", + orderTask.id()).param("hotel_id", OTHER_HOTEL_ID)) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.error_code").value("HOTEL_ACCESS_DENIED")); + performAuthorized(mockMvc, adminToken(), get("/api/reservation/source-notifications/{notificationId}/audits", + notification.id()).param("hotel_id", OTHER_HOTEL_ID)) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.error_code").value("HOTEL_ACCESS_DENIED")); } @Test @@ -670,4 +739,49 @@ class ReservationV4QueryControllerTest { "PAYMENT.attachment_ids 未匹配来源附件。", now)); } + + private void insertV4OrderTaskAudit( + ReservationV4OrderTaskSnapshot orderTask, + String action, + String actorId, + Instant occurredAt) { + aiWorkflowRepository.insertAuditLog(new ReservationAuditLogDraft( + HOTEL_ID, + orderTask.orderId(), + null, + null, + "USER", + actorId, + action, + "前端查询 V4 审计测试。", + """ + {"v4_order_task_id":"%s","card_status":"PENDING_CONFIRM","raw":"Sensitive raw audit body"} + """.formatted(orderTask.id()), + """ + {"v4_order_task_id":"%s","card_status":"CONFIRMED","field_pointers":["/basic_information/account_code"]} + """.formatted(orderTask.id()), + LocalDateTime.ofInstant(occurredAt, ZoneOffset.UTC))); + } + + private void insertV4SourceNotificationAudit( + ReservationV4SourceNotificationSnapshot notification, + String actorId, + Instant occurredAt) { + aiWorkflowRepository.insertAuditLog(new ReservationAuditLogDraft( + HOTEL_ID, + null, + null, + null, + "USER", + actorId, + "V4_SOURCE_NOTIFICATION_ACK", + "确认已处理。", + """ + {"v4_source_notification_id":"%s","notification_status":"ACK_REQUIRED","raw":"Sensitive raw audit body"} + """.formatted(notification.id()), + """ + {"v4_source_notification_id":"%s","notification_status":"ACKED"} + """.formatted(notification.id()), + LocalDateTime.ofInstant(occurredAt, ZoneOffset.UTC))); + } }