diff --git a/docs/project/frontend-backend/backend-to-frontend-notes.md b/docs/project/frontend-backend/backend-to-frontend-notes.md index 3f6203b..b96f01d 100644 --- a/docs/project/frontend-backend/backend-to-frontend-notes.md +++ b/docs/project/frontend-backend/backend-to-frontend-notes.md @@ -54,9 +54,9 @@ | `POST /api/auth/logout` | 登出当前 session | 带 `Authorization: Bearer `;成功后前端必须清理本地 token 和当前用户上下文。 | | `GET /api/reservation/orders` | 查询订单列表 | 必须带 `Authorization: Bearer `,需要 `RESERVATION_ORDER_READ`;默认返回全部订单状态;按后端维护的订单最近业务活动时间倒序,当前落库字段为 `workflow_reservation_order.latest_activity_at`,前端不要自行重排;`open_task_count` 排除 `COMPLETED` 和 `FAILED`;隐藏技术订单不返回,因此 S10/S99 和旧 S000/S999 不会在订单列表形成订单。 | | `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` 倒序;列表不返回邮件正文、附件 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`;前端用 `availability`、`card_counts` 和 `next_action_card_id` 控制入口。 | -| `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 会话接口。 | +| `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 会话接口。 | | `GET /api/reservation/source-notifications/{notificationId}` | 查询 V4 S10/S99 来源通知详情 | 必须带 Bearer token,需要 `RESERVATION_TASK_READ`,后端按来源通知实际酒店校验访问权;只返回通知摘要、来源邮件通知卡、会话摘要和 `availability`;不返回订单任务、业务卡、邮件正文、附件 URL 或原始 AI payload。 | | `GET /api/reservation/orders/{orderId}` | 查询订单详情与任务时间线 | 必须带 Bearer token,需要 `RESERVATION_ORDER_READ`,后端按订单所属酒店做访问校验;`include_tasks=false` 可只取订单摘要;时间线按后端队列顺序返回,前端不要自行按创建时间重排;`tasks[]` 已返回来源邮件会话摘要字段和 V3 路由字段;隐藏技术订单详情不可作为普通订单页打开。 | | `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`。 | @@ -92,9 +92,9 @@ | `GET /api/reservation/tasks/{taskId}` | 补齐顶层来源邮件字段,并扩展 `fields[]` 元数据。 | 顶层来源字段用于打开邮件会话;`fields[]` 中的 `result_type`、`task_type`、`task_subtype`、`default_value_source` 用于前端字段分组、调试和白名单对齐。 | | `GET /api/source-messages/{sourceMessageId}/conversation` | 新增邮件会话详情接口,并补齐 `html_body_sanitized` / `html_render_mode`。 | 当前唯一推荐路径是这个接口;前端渲染邮件 HTML 时优先使用 `html_body_sanitized`;不要调用历史讨论过的 `/api/source-message-conversations/{externalConversationId}`。 | | `POST /api/system/debug/eml-superagent-runs` | 新增 Debug EML 上传到 SuperAgent 调试接口,并补齐独立 Debug 外部消息 ID、原始 Message-ID 保留、安全 HTML 字段和入口通知识别。 | 只用于调试页面;请求为 multipart/form-data;必须传 `X-TH-Hotel-Debug-Upload-Key`,但该 key 不能写进前端源码、构建产物、URL、localStorage 或错误上报;SuperAgent 返回旧 S000/S999 或新 S10/S99 入口通知时都不应被前端视为 JSON 解析失败。 | -| `GET /api/reservation/workbench-items` / `/api/reservation/order-tasks/**` / `/api/reservation/source-notifications/{notificationId}` | 新增 M002 V4 CP5 查询接口。 | 这是 V4 新模型前端主入口;查询阶段只读,`confirmable=false`、`ackable=false` 表示写接口还没开放,不是前端按钮 bug。前端不要继续从旧 `/api/reservation/tasks/**` 推断 V4 多卡详情。 | +| `GET /api/reservation/workbench-items` / `/api/reservation/order-tasks/**` / `/api/reservation/source-notifications/{notificationId}` | 新增 M002 V4 CP5 查询接口。 | 这是 V4 新模型前端主入口;查询阶段只读,`confirmable=false`、`ackable=false` 表示写接口还没开放,不是前端按钮 bug。工作台条目已返回 `created_at` / `updated_at` 作为排序兜底和调试字段;前端不要继续从旧 `/api/reservation/tasks/**` 推断 V4 多卡详情。 | -V4 CP5 分页注意:`page_num` 从 1 开始,后端第一版安全上限为 100;`page_size` 最大 100。超出上限时后端按上限处理并在 `page.page_num` / `page.page_size` 中返回实际使用值。 +V4 CP5 分页注意:`page_num` 从 1 开始,后端第一版安全上限为 100;`page_size` 最大 100。超出上限时后端按上限处理并在 `page.page_num` / `page.page_size` 中返回实际使用值。`order_task_status` 和 `card_status` 是稳定枚举查询参数,前端不要传中文文案或自造状态码。 酒店上下文注意:Reservation 列表、订单详情、任务列表和 Debug EML 上传的 `hotel_id` 第一版都是可选参数。对已收口的 Reservation / SourceMessage 只读接口,前端必须先登录并带 Bearer token;不传 `hotel_id` 时后端按当前登录用户默认酒店或对象所属酒店校验,传了当前选中酒店时后端会校验该酒店是否可访问。Debug EML 仍按调试入口规则受控,不属于本轮登录权限收口范围。 @@ -464,7 +464,7 @@ RESERVATION_ROOMING_LIST_GENERATE - 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}` 查看。卡片确认、复核和 S10/S99 ack 写接口仍未开放。 - V4 任务卡的 `display_payload_json` 只保留后端白名单展示字段;`ai_payload_json` 才包含完整 SuperAgent 原始 event。后续 V4 查询接口不得把 `ai_payload_json`、附件 URL 或 raw evidence 直接给普通页面渲染。 - 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[]` 只读诊断块中,不展示保存、确认、执行或重试按钮。 +- 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 已开放查询。当前仍不代表 V4 卡片确认、复核或 S10/S99 ack API 已经可用。 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 801c505..77633aa 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 @@ -464,11 +464,12 @@ GET /api/reservation/workbench-items - `order_task_status`:仅业务订单任务返回 `OPEN` / `COMPLETED`。 - `display_status`:可返回 `OPEN` / `BLOCKED` / `COMPLETED` / `ACK_REQUIRED` / `ACKED`。 - `readonly_reason_code` +- `created_at` / `updated_at`:工作台条目创建和更新时间,主要用于同一来源时间下的稳定排序和前端调试。 排序规则: - 默认按 `source_received_at` 倒序。 -- 同一来源时间下按 AI batch 接收时间、记录创建时间倒序。 +- 同一来源时间下按记录 `updated_at`、`created_at`、数字 ID 倒序。 ### 12.2 业务订单任务列表 @@ -488,6 +489,12 @@ GET /api/reservation/order-tasks | `keyword` | 可选,匹配邮件主题、发件人、Group Code、Booking Code、Confirmation Number | | `page_num` / `page_size` | 分页 | +筛选说明: + +- `order_task_status` 只接受 `OPEN` / `COMPLETED`,非法值返回 `V4_ORDER_TASK_STATUS_INVALID`。 +- `card_status` 只接受 `READONLY` / `PENDING_CONFIRM` / `REVIEW_REQUIRED` / `CONFIRMED`,非法值返回 `V4_CARD_STATUS_INVALID`。 +- `card_status` 按业务 / 可处理卡筛选,固定的 `SOURCE_MESSAGE_DISPLAY` 来源邮件只读卡不参与匹配,避免 `READONLY` 把所有普通业务任务都筛出来。 + 返回摘要应包含: - `order_task_id` @@ -530,6 +537,10 @@ GET /api/reservation/order-tasks/{orderTaskId} 前端应以返回的 `cards[]` 和 `availability` 为准渲染,不自行拼完整字段矩阵。 +`adapter_contract_errors[]` 返回同一 AI 批次中未生成业务卡的 V4 event 级契约错误诊断块,只包含白名单诊断字段,例如 `event_type`、`source_event_index`、`contract_errors`、`reason_code`、`missing_fields` 等;不返回完整 AI payload、邮件正文、附件 URL 或 raw evidence。 + +来源邮件摘要必须按当前查询酒店过滤。如果 V4 订单任务因脏引用指向了其它酒店的 SourceMessage,详情接口只能返回该 `source_message_id` 的空摘要占位,不得透出对方酒店的主题、外部消息 ID、发件人或会话信息。 + ### 12.4 S10/S99 来源通知详情 ```text diff --git a/docs/project/security-access-control-boundary.md b/docs/project/security-access-control-boundary.md index 45da954..0b1bb97 100644 --- a/docs/project/security-access-control-boundary.md +++ b/docs/project/security-access-control-boundary.md @@ -47,9 +47,9 @@ | `GET /api/reservation/orders` | `FRONTEND_USER` | 已强制 Bearer 登录 + `RESERVATION_ORDER_READ`;支持可选 `hotel_id` 并校验酒店访问权 | 保持登录 + `RESERVATION_ORDER_READ` + 酒店访问权 | 只读查询默认不写业务审计 | | `GET /api/reservation/orders/{orderId}` | `FRONTEND_USER` | 已强制 Bearer 登录 + `RESERVATION_ORDER_READ`;按订单实际所属酒店校验访问权 | 保持登录 + `RESERVATION_ORDER_READ` + 订单所属酒店访问权 | 只读查询默认不写业务审计 | | `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 | -| `GET /api/reservation/order-tasks` | `FRONTEND_USER` | 已实现 M002 V4 CP5;强制 Bearer 登录 + `RESERVATION_TASK_READ` + 酒店访问权 | 保持登录 + `RESERVATION_TASK_READ` + 酒店访问权;只返回 V4 业务订单任务,不返回 S10/S99 来源通知 | 只读查询默认不写业务审计;不得返回 AI 原始 payload | -| `GET /api/reservation/order-tasks/{orderTaskId}` | `FRONTEND_USER` | 已实现 M002 V4 CP5;强制 Bearer 登录 + `RESERVATION_TASK_READ` + 订单任务所属酒店访问权 | 保持登录 + `RESERVATION_TASK_READ` + 订单任务所属酒店访问权 | 只读查询默认不写业务审计;邮件正文和附件读取仍走 SourceMessage 原文权限;不得返回 `ai_payload_json` | +| `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`;同批次 `adapter_contract_errors[]` 只返回白名单诊断字段 | | `GET /api/reservation/source-notifications/{notificationId}` | `FRONTEND_USER` | 已实现 M002 V4 CP5;强制 Bearer 登录 + `RESERVATION_TASK_READ` + 来源通知所属酒店访问权 | 保持登录 + `RESERVATION_TASK_READ` + 来源通知所属酒店访问权 | 只读查询默认不写业务审计;邮件正文和附件读取仍走 SourceMessage 原文权限;不得返回来源通知原始 payload | | `PUT /api/reservation/tasks/{taskId}/draft` | `FRONTEND_USER` | 第一版未全量强制登录;actor 仍待迁移 | 登录 + `RESERVATION_TASK_EDIT` + 酒店访问权 | 写草稿审计可按业务需要记录 | | `POST /api/reservation/tasks/{taskId}/confirm` | `FRONTEND_USER` | 第一版未全量强制登录;actor 仍待迁移 | 登录 + `RESERVATION_TASK_CONFIRM` + 酒店访问权 | 必须写业务审计 | diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationV4WorkbenchItemResult.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationV4WorkbenchItemResult.java index 24e5219..e8383f7 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationV4WorkbenchItemResult.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationV4WorkbenchItemResult.java @@ -19,6 +19,8 @@ import java.time.OffsetDateTime; * @param orderTaskStatus 订单任务状态,仅 ORDER_TASK 返回 * @param displayStatus 前端展示状态 * @param readonlyReasonCode 只读原因稳定码 + * @param createdAt 条目创建 UTC 时间,用于前端调试和稳定排序兜底 + * @param updatedAt 条目最近更新 UTC 时间,用于同来源时间下的稳定排序 */ public record ReservationV4WorkbenchItemResult( @JsonProperty("item_type") @@ -46,6 +48,10 @@ public record ReservationV4WorkbenchItemResult( @JsonProperty("display_status") String displayStatus, @JsonProperty("readonly_reason_code") - String readonlyReasonCode + String readonlyReasonCode, + @JsonProperty("created_at") + OffsetDateTime createdAt, + @JsonProperty("updated_at") + OffsetDateTime updatedAt ) { } diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/mapper/ReservationV4OrderTaskMapper.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/mapper/ReservationV4OrderTaskMapper.java index b3f7932..ee0fe33 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/mapper/ReservationV4OrderTaskMapper.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/mapper/ReservationV4OrderTaskMapper.java @@ -36,6 +36,7 @@ public interface ReservationV4OrderTaskMapper extends BaseMapper 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 89d0b9e..f86bc90 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 @@ -5,10 +5,12 @@ import cn.nianxx.thhotel.platform.hotel.service.HotelContextException; 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.ReservationPageSnapshot; import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4OrderTaskSnapshot; import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4SourceNotificationSnapshot; import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4TaskCardSnapshot; +import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationAiSystemProcessCategory; import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationV4CardStatus; import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationV4CardType; import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationV4NotificationStatus; @@ -18,6 +20,7 @@ import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationV4Workben import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationV4OrderTaskQueryRequest; import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationV4SourceNotificationQueryRequest; import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationV4WorkbenchQueryRequest; +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.ReservationV4ActionAvailabilityResult; @@ -32,6 +35,7 @@ import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4Source import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4TaskCardResult; import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4WorkbenchItemResult; import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4WorkbenchListResult; +import cn.nianxx.thhotel.workflows.reservation.repository.ReservationAiWorkflowRepository; import cn.nianxx.thhotel.workflows.reservation.repository.ReservationV4SourceNotificationRepository; import cn.nianxx.thhotel.workflows.reservation.repository.ReservationV4WorkflowRepository; import cn.nianxx.thhotel.workflows.reservation.service.ReservationV4QueryService; @@ -66,6 +70,7 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService private static final String DISPLAY_STATUS_OPEN = "OPEN"; private final ReservationV4WorkflowRepository workflowRepository; + private final ReservationAiWorkflowRepository aiWorkflowRepository; private final ReservationV4SourceNotificationRepository sourceNotificationRepository; private final SourceMessageQueryService sourceMessageQueryService; private final HotelContextService hotelContextService; @@ -76,11 +81,13 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService */ public ReservationV4QueryServiceImpl( ReservationV4WorkflowRepository workflowRepository, + ReservationAiWorkflowRepository aiWorkflowRepository, ReservationV4SourceNotificationRepository sourceNotificationRepository, SourceMessageQueryService sourceMessageQueryService, HotelContextService hotelContextService, ObjectMapper objectMapper) { this.workflowRepository = workflowRepository; + this.aiWorkflowRepository = aiWorkflowRepository; this.sourceNotificationRepository = sourceNotificationRepository; this.sourceMessageQueryService = sourceMessageQueryService; this.hotelContextService = hotelContextService; @@ -215,7 +222,7 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService basicInformationCard, businessCards, cardCounts(cards), - List.of(), + adapterContractErrors(orderTask), orderAvailability); } @@ -281,7 +288,9 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService null, orderTask.orderTaskStatus(), item.displayStatus(), - item.availability().readonlyReasonCode()); + item.availability().readonlyReasonCode(), + orderTask.createdAt(), + orderTask.updatedAt()); } private ReservationV4WorkbenchItemResult toWorkbenchSourceNotificationItem( @@ -301,7 +310,9 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService notification.notificationStatus(), null, notification.notificationStatus(), - availability.readonlyReasonCode()); + availability.readonlyReasonCode(), + UtcTimeFormatter.toUtcOffsetDateTime(notification.createdAt()), + UtcTimeFormatter.toUtcOffsetDateTime(notification.updatedAt())); } private ReservationV4TaskCardResult toCardResult( @@ -573,7 +584,10 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService } List uniqueIds = sourceMessageIds.stream().filter(Objects::nonNull).distinct().toList(); List summaries = sourceMessageQueryService.getSummariesByIds(uniqueIds); - List conversationIds = summaries.stream() + List safeSummaries = summaries.stream() + .filter(summary -> Objects.equals(hotelId, summary.hotelId())) + .toList(); + List conversationIds = safeSummaries.stream() .map(SourceMessageSummaryResponse::externalConversationId) .filter(Objects::nonNull) .distinct() @@ -582,7 +596,7 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService hotelId, conversationIds); Map result = new LinkedHashMap<>(); - for (SourceMessageSummaryResponse summary : summaries) { + for (SourceMessageSummaryResponse summary : safeSummaries) { Long sourceMessageId = Long.valueOf(summary.id()); result.put(sourceMessageId, new ReservationV4SourceMessageSummaryResult( summary.id(), @@ -610,6 +624,59 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService return result; } + private List adapterContractErrors(ReservationV4OrderTaskSnapshot orderTask) { + if (orderTask.aiBatchId() == null) { + return List.of(); + } + return aiWorkflowRepository.findTransitionsByBatchId(orderTask.hotelId(), orderTask.aiBatchId()).stream() + .filter(transition -> ReservationAiSystemProcessCategory.ADAPTER_CONTRACT_ERROR.name() + .equals(transition.systemProcessCategory())) + .map(this::toTransitionDisplayResult) + .toList(); + } + + private ReservationAiTransitionDisplayResult toTransitionDisplayResult(ReservationAiTransitionSnapshot transition) { + return new ReservationAiTransitionDisplayResult( + transition.id().toString(), + transition.sourceEventIndex(), + transition.arrayIndex(), + transition.resultType(), + transition.aiTaskType(), + transition.taskSubtype(), + transition.routeCode(), + transition.systemProcessCategory(), + transition.adapterErrorCode(), + transition.adapterErrorMessage(), + diagnosticPayloadFragment(transition.aiPayloadJson())); + } + + private Object diagnosticPayloadFragment(String aiPayloadJson) { + if (!hasText(aiPayloadJson)) { + return null; + } + JsonNode payload = parseJson(aiPayloadJson); + Map fragment = new LinkedHashMap<>(); + copyDiagnosticPayloadField(payload, fragment, "event_type"); + copyDiagnosticPayloadField(payload, fragment, "source_event_index"); + copyDiagnosticPayloadField(payload, fragment, "current_or_history"); + copyDiagnosticPayloadField(payload, fragment, "contract_errors"); + copyDiagnosticPayloadField(payload, fragment, "intent_type"); + copyDiagnosticPayloadField(payload, fragment, "reason_code"); + copyDiagnosticPayloadField(payload, fragment, "missing_fields"); + copyDiagnosticPayloadField(payload, fragment, "parent_source_event_index"); + copyDiagnosticPayloadField(payload, fragment, "linked_task_group_id"); + copyDiagnosticPayloadField(payload, fragment, "blocked_until_parent_completed"); + return fragment; + } + + private void copyDiagnosticPayloadField(JsonNode payload, Map fragment, String fieldName) { + JsonNode value = payload == null ? null : payload.get(fieldName); + if (value == null || value.isNull() || value.isMissingNode()) { + return; + } + fragment.put(fieldName, objectMapper.convertValue(value, Object.class)); + } + private List findSourceMessageIdsByKeyword(String hotelId, String keyword) { if (!hasText(keyword)) { return List.of(); @@ -725,7 +792,21 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService private Comparator workbenchItemComparator() { return Comparator.comparing(ReservationV4WorkbenchItemResult::sourceReceivedAt, Comparator.nullsLast(Comparator.reverseOrder())) - .thenComparing(ReservationV4WorkbenchItemResult::targetId, Comparator.reverseOrder()); + .thenComparing(ReservationV4WorkbenchItemResult::updatedAt, + Comparator.nullsLast(Comparator.reverseOrder())) + .thenComparing(ReservationV4WorkbenchItemResult::createdAt, + Comparator.nullsLast(Comparator.reverseOrder())) + .thenComparing(this::targetIdAsLong, Comparator.nullsLast(Comparator.reverseOrder())) + .thenComparing(ReservationV4WorkbenchItemResult::targetId, + Comparator.nullsLast(Comparator.reverseOrder())); + } + + private Long targetIdAsLong(ReservationV4WorkbenchItemResult item) { + try { + return item.targetId() == null ? null : Long.valueOf(item.targetId()); + } catch (NumberFormatException exception) { + return null; + } } private ReservationV4WorkbenchQueryRequest normalizeWorkbenchRequest(ReservationV4WorkbenchQueryRequest request) { @@ -748,13 +829,45 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService return new ReservationV4OrderTaskQueryRequest( normalizeHotelId(request.hotelId()), request.orderId(), - trimToNull(request.orderTaskStatus()), - trimToNull(request.cardStatus()), + normalizeOrderTaskStatus(request.orderTaskStatus()), + normalizeCardStatus(request.cardStatus()), trimToNull(request.keyword()), request.pageNum(), request.pageSize()); } + private String normalizeOrderTaskStatus(String orderTaskStatus) { + String normalized = trimToNull(orderTaskStatus); + if (normalized == null) { + return null; + } + for (ReservationV4OrderTaskStatus status : ReservationV4OrderTaskStatus.values()) { + if (status.name().equals(normalized)) { + return normalized; + } + } + throw new ReservationTaskWorkflowException( + HttpStatus.BAD_REQUEST, + "V4_ORDER_TASK_STATUS_INVALID", + "V4 订单任务状态不支持。"); + } + + private String normalizeCardStatus(String cardStatus) { + String normalized = trimToNull(cardStatus); + if (normalized == null) { + return null; + } + for (ReservationV4CardStatus status : ReservationV4CardStatus.values()) { + if (status.name().equals(normalized)) { + return normalized; + } + } + throw new ReservationTaskWorkflowException( + HttpStatus.BAD_REQUEST, + "V4_CARD_STATUS_INVALID", + "V4 任务卡状态不支持。"); + } + private String normalizeHotelId(String hotelId) { try { return hotelContextService.resolveCurrentHotelId(hotelId); 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 7caed9f..15efa2a 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 @@ -19,16 +19,22 @@ import cn.nianxx.thhotel.platform.identity.service.impl.AuthPasswordService; import cn.nianxx.thhotel.platform.message.common.request.CaptureSourceMessageCommand; import cn.nianxx.thhotel.platform.message.common.result.SourceMessageCaptureResult; 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.ReservationV4OrderTaskDraft; import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4OrderTaskSnapshot; import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4SourceNotificationDraft; import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4SourceNotificationSnapshot; import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4TaskCardDraft; +import cn.nianxx.thhotel.workflows.reservation.common.enums.AiResultType; +import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationAiSystemProcessCategory; +import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationSystemTaskType; import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationV4CardStatus; import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationV4CardType; import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationV4NotificationStatus; import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationV4OrderTaskStatus; import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationV4TargetResolutionStatus; +import cn.nianxx.thhotel.workflows.reservation.repository.ReservationAiWorkflowRepository; import cn.nianxx.thhotel.workflows.reservation.repository.ReservationV4SourceNotificationRepository; import cn.nianxx.thhotel.workflows.reservation.repository.ReservationV4WorkflowRepository; import java.time.Instant; @@ -73,6 +79,9 @@ class ReservationV4QueryControllerTest { @Autowired private ReservationV4WorkflowRepository workflowRepository; + @Autowired + private ReservationAiWorkflowRepository aiWorkflowRepository; + @Autowired private ReservationV4SourceNotificationRepository sourceNotificationRepository; @Autowired @@ -136,6 +145,7 @@ class ReservationV4QueryControllerTest { performAuthorized(mockMvc, adminToken(), get("/api/reservation/workbench-items") .param("hotel_id", HOTEL_ID) + .param("keyword", "mail-v4-query-workbench") .param("page_num", "1") .param("page_size", "20")) .andExpect(status().isOk()) @@ -159,6 +169,64 @@ class ReservationV4QueryControllerTest { .andExpect(content().string(not(containsString("Sensitive raw notification body")))); } + @Test + void shouldReturnAdapterContractErrorsOnV4OrderTaskDetail() throws Exception { + Instant receivedAt = Instant.parse("2026-07-18T03:10:00Z"); + SourceMessageCaptureResult source = captureSourceMessage( + "mail-v4-query-adapter-error-001", + "V4 Query Adapter Error", + receivedAt, + HOTEL_ID); + Long batchId = insertAiBatch(source.inboxId(), LocalDateTime.ofInstant(receivedAt, ZoneOffset.UTC)); + insertAdapterContractErrorTransition( + source.inboxId(), + batchId, + LocalDateTime.ofInstant(receivedAt.plusSeconds(1), ZoneOffset.UTC)); + ReservationV4OrderTaskSnapshot orderTask = seedOrderTask( + source, + batchId, + receivedAt, + "order-adapter-error", + "GRP-V4-ADAPTER-001"); + + performAuthorized(mockMvc, adminToken(), get("/api/reservation/order-tasks/{orderTaskId}", orderTask.id()) + .param("hotel_id", HOTEL_ID)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.adapter_contract_errors.length()").value(1)) + .andExpect(jsonPath("$.adapter_contract_errors[0].system_process_category") + .value("ADAPTER_CONTRACT_ERROR")) + .andExpect(jsonPath("$.adapter_contract_errors[0].adapter_error_code") + .value("V4_PAYMENT_ATTACHMENT_NOT_FOUND")) + .andExpect(jsonPath("$.adapter_contract_errors[0].payload_fragment.contract_errors[0]") + .value("attachment_ids 未匹配来源附件")) + .andExpect(content().string(not(containsString("raw_sensitive_payload")))); + } + + @Test + void shouldNotExposeCrossHotelSourceMessageSummaryWhenV4ReferenceIsPolluted() throws Exception { + Instant receivedAt = Instant.parse("2026-07-18T03:20:00Z"); + SourceMessageCaptureResult otherSource = captureSourceMessage( + "mail-v4-query-cross-source-001", + "Other Hotel Secret Subject", + receivedAt, + OTHER_HOTEL_ID); + ReservationV4OrderTaskSnapshot orderTask = seedOrderTask( + otherSource, + 990000000000000901L, + receivedAt, + "order-cross-source", + "GRP-V4-CROSS-SOURCE-001"); + + performAuthorized(mockMvc, adminToken(), get("/api/reservation/order-tasks/{orderTaskId}", orderTask.id()) + .param("hotel_id", HOTEL_ID)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.source_message_summary.source_message_id") + .value(otherSource.inboxId().toString())) + .andExpect(jsonPath("$.source_message_summary.subject").doesNotExist()) + .andExpect(jsonPath("$.source_message_summary.external_message_id").doesNotExist()) + .andExpect(content().string(not(containsString("Other Hotel Secret Subject")))); + } + @Test void shouldReturnV4OrderTaskDetailWithSafeCards() throws Exception { ReservationV4OrderTaskSnapshot orderTask = seedOrderTask("mail-v4-query-detail-001", @@ -203,6 +271,45 @@ class ReservationV4QueryControllerTest { .andExpect(jsonPath("$.page.total").value(1)); } + @Test + void shouldNotMatchReadonlySourceMessageDisplayCardWhenFilteringBusinessCards() throws Exception { + seedOrderTask("mail-v4-query-readonly-filter-001", + Instant.parse("2026-07-18T03:40:00Z")); + + performAuthorized(mockMvc, adminToken(), get("/api/reservation/order-tasks") + .param("hotel_id", HOTEL_ID) + .param("card_status", "READONLY") + .param("keyword", "mail-v4-query-readonly-filter-001") + .param("page_num", "1") + .param("page_size", "20")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.items.length()").value(0)) + .andExpect(jsonPath("$.page.total").value(0)); + } + + @Test + void shouldSortSameReceivedAtWorkbenchItemsByUpdatedAtAndId() throws Exception { + Instant sameReceivedAt = Instant.parse("2026-07-18T03:50:00Z"); + ReservationV4SourceNotificationSnapshot notification = seedSourceNotification( + "mail-v4-query-same-time-notification-001", + "S10", + sameReceivedAt); + ReservationV4OrderTaskSnapshot orderTask = seedOrderTask( + "mail-v4-query-same-time-order-001", + sameReceivedAt); + + performAuthorized(mockMvc, adminToken(), get("/api/reservation/workbench-items") + .param("hotel_id", HOTEL_ID) + .param("keyword", "mail-v4-query-same-time") + .param("page_num", "1") + .param("page_size", "20")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.items[0].item_type").value("SOURCE_NOTIFICATION")) + .andExpect(jsonPath("$.items[0].target_id").value(notification.id().toString())) + .andExpect(jsonPath("$.items[1].item_type").value("ORDER_TASK")) + .andExpect(jsonPath("$.items[1].target_id").value(orderTask.id().toString())); + } + @Test void shouldReturnV4SourceNotificationDetailWithoutRawPayload() throws Exception { ReservationV4SourceNotificationSnapshot notification = seedSourceNotification( @@ -296,6 +403,21 @@ class ReservationV4QueryControllerTest { .andExpect(jsonPath("$.error_code").value("V4_WORKBENCH_ITEM_TYPE_INVALID")); } + @Test + void shouldRejectUnsupportedOrderTaskStatusAndCardStatus() throws Exception { + performAuthorized(mockMvc, adminToken(), get("/api/reservation/order-tasks") + .param("hotel_id", HOTEL_ID) + .param("order_task_status", "UNKNOWN")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error_code").value("V4_ORDER_TASK_STATUS_INVALID")); + + performAuthorized(mockMvc, adminToken(), get("/api/reservation/order-tasks") + .param("hotel_id", HOTEL_ID) + .param("card_status", "UNKNOWN")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error_code").value("V4_CARD_STATUS_INVALID")); + } + @Test void shouldCapHugeWorkbenchPageSafely() throws Exception { performAuthorized(mockMvc, adminToken(), get("/api/reservation/workbench-items") @@ -308,18 +430,32 @@ class ReservationV4QueryControllerTest { } private ReservationV4OrderTaskSnapshot seedOrderTask(String externalMessageId, Instant receivedAt) { - SourceMessageCaptureResult source = captureSourceMessage(externalMessageId, "V4 Query Business", receivedAt); + SourceMessageCaptureResult source = captureSourceMessage(externalMessageId, "V4 Query Business", receivedAt, HOTEL_ID); + return seedOrderTask( + source, + 990000000000000001L, + receivedAt, + "order-1", + "GRP-V4-QUERY-001"); + } + + private ReservationV4OrderTaskSnapshot seedOrderTask( + SourceMessageCaptureResult source, + Long aiBatchId, + Instant receivedAt, + String orderRef, + String targetLocatorValue) { LocalDateTime now = LocalDateTime.ofInstant(receivedAt.plusSeconds(10), ZoneOffset.UTC); ReservationV4OrderTaskSnapshot orderTask = workflowRepository.findOrCreateOrderTask(new ReservationV4OrderTaskDraft( HOTEL_ID, source.inboxId(), - 990000000000000001L, - "order-1", + aiBatchId, + orderRef, 1, null, "GROUP", "GROUP_CODE", - "GRP-V4-QUERY-001", + targetLocatorValue, ReservationV4TargetResolutionStatus.RESOLVED.name(), ReservationV4OrderTaskStatus.OPEN.name(), LocalDateTime.ofInstant(receivedAt, ZoneOffset.UTC), @@ -346,7 +482,8 @@ class ReservationV4QueryControllerTest { SourceMessageCaptureResult source = captureSourceMessage( externalMessageId, "V4 Query Notification", - receivedAt); + receivedAt, + HOTEL_ID); return sourceNotificationRepository.findOrCreateSourceNotification(new ReservationV4SourceNotificationDraft( HOTEL_ID, source.inboxId(), @@ -392,9 +529,10 @@ class ReservationV4QueryControllerTest { private SourceMessageCaptureResult captureSourceMessage( String externalMessageId, String subject, - Instant receivedAt) { + Instant receivedAt, + String hotelId) { return captureService.capture(new CaptureSourceMessageCommand( - HOTEL_ID, + hotelId, "AGENTBUS", "EMAIL", externalMessageId, @@ -412,4 +550,57 @@ class ReservationV4QueryControllerTest { List.of() )); } + + private Long insertAiBatch(Long sourceMessageId, LocalDateTime receivedAt) { + return aiWorkflowRepository.insertBatch(new ReservationAiBatchDraft( + HOTEL_ID, + sourceMessageId, + "sha256-v4-query-adapter-error", + "batch-v4-query-adapter-error", + "superagent-test", + "request-v4-query-adapter-error", + receivedAt, + 2, + null)); + } + + private void insertAdapterContractErrorTransition(Long sourceMessageId, Long batchId, LocalDateTime now) { + aiWorkflowRepository.insertTransition(new ReservationAiTransitionDraft( + HOTEL_ID, + batchId, + sourceMessageId, + 2, + 2, + 2, + "P0", + "reservation-v4", + AiResultType.ADAPTER_CONTRACT_ERROR.code(), + "adapter_contract_error", + null, + ReservationAiSystemProcessCategory.ADAPTER_CONTRACT_ERROR.name(), + ReservationSystemTaskType.ADAPTER_CONTRACT_ERROR.name(), + "ADAPTER_CONTRACT_ERROR", + null, + "current", + null, + null, + "sha256-v4-query-adapter-error-item", + "item-v4-query-adapter-error", + null, + null, + null, + false, + """ + {"event_type":"PAYMENT","source_event_index":2,"contract_errors":["attachment_ids 未匹配来源附件"],"raw_sensitive_payload":"must not leak"} + """, + null, + null, + null, + null, + null, + null, + "V4_PAYMENT_ATTACHMENT_NOT_FOUND", + "PAYMENT.attachment_ids 未匹配来源附件。", + now)); + } }