diff --git a/docs/project/frontend-backend/backend-to-frontend-notes.md b/docs/project/frontend-backend/backend-to-frontend-notes.md index 7e7e0c4..8977988 100644 --- a/docs/project/frontend-backend/backend-to-frontend-notes.md +++ b/docs/project/frontend-backend/backend-to-frontend-notes.md @@ -51,10 +51,103 @@ | `GET /api/source-messages/{id}/original` | 读取来源消息原文 | 需要受控访问头,返回 HTML 时前端展示前必须 sanitize。 | | `GET /api/source-messages/{sourceMessageId}/conversation` | 读取邮件会话详情 | 返回同一外部会话全部邮件的完整 text/html、附件外链、内联图片和关联订单 / 任务摘要;前端不传原文读取 key,但展示 HTML 前必须 sanitize。 | +### 5.1 本轮新增 / 修改接口说明 + +本轮后端新增或补齐了以下前端 P0 查询能力。前端后续开发时,应优先以本节作为接入口径。 + +| 接口 | 本轮变化 | 前端接入注意 | +| --- | --- | --- | +| `GET /api/reservation/orders` | 新增订单列表接口。 | `order_status` 不传时默认查询全部订单状态;`page_num` 从 1 开始;`page_size` 后端有最大值保护;`open_task_count` 排除 `COMPLETED` 和 `FAILED`;`next_processable_task_id` 为空表示当前没有可继续处理的任务。 | +| `GET /api/reservation/tasks` | 补齐来源邮件会话摘要字段。 | 列表仍然只返回安全摘要,不返回正文、HTML、附件 URL 或 AI 原始 payload;点击邮件入口时使用 `source_message_id` 调会话详情。 | +| `GET /api/reservation/orders/{orderId}` | 补齐 `tasks[]` 每条任务的来源邮件会话摘要字段。 | `include_tasks=false` 可只取订单摘要;时间线顺序由后端按订单队列返回,前端不要自行按创建时间重排。`include_source_summary` 第一版不作为前端裁剪字段的强约束,前端暂不要依赖它减少返回字段。 | +| `GET /api/reservation/tasks/{taskId}` | 补齐顶层来源邮件字段,并扩展 `fields[]` 元数据。 | 顶层来源字段用于打开邮件会话;`fields[]` 中的 `result_type`、`task_type`、`task_subtype`、`default_value_source` 用于前端字段分组、调试和白名单对齐。 | +| `GET /api/source-messages/{sourceMessageId}/conversation` | 新增邮件会话详情接口。 | 当前唯一推荐路径是这个接口;不要调用历史讨论过的 `/api/source-message-conversations/{externalConversationId}`。 | + +### 5.2 来源邮件会话字段说明 + +任务列表、订单详情任务时间线、任务详情顶层会返回以下来源邮件字段: + +| 字段 | 说明 | 前端使用方式 | +| --- | --- | --- | +| `source_message_id` | 本系统内部 SourceMessage Inbox ID。 | 打开邮件会话详情时作为路径参数传入 `/api/source-messages/{sourceMessageId}/conversation`。 | +| `source_subject` | 来源邮件主题安全摘要。 | 用于列表或任务详情标题旁展示,不代表完整邮件主题一定无敏感信息。 | +| `source_sender_summary` | 来源发件人安全摘要。 | 用于辅助用户判断邮件来源。 | +| `source_received_at` | 本系统接收来源消息时间,UTC。 | 前端展示时按用户或酒店时区格式化。 | +| `external_conversation_id` | 外部邮件会话 ID。 | 仅用于展示或调试,不作为当前会话详情接口路径参数。 | +| `conversation_message_count` | 同一外部会话下的邮件数量。 | 用于提示用户打开的是整段会话,不是单封邮件。 | + +### 5.3 邮件会话详情接入注意 + +- `GET /api/source-messages/{sourceMessageId}/conversation` 只接收路径参数 `sourceMessageId`;第一版不接收 `hotelId`、`includeBody`、`includeRelated`。 +- 后端会根据 `sourceMessageId` 定位 `external_conversation_id`,并返回同一会话下全部邮件;如果来源消息没有外部会话 ID,会降级返回当前单封邮件。 +- `messages[]` 按后端接收时间正序返回,前端不要重新按创建时间或任务时间排序。 +- 返回内容包含完整 `text_body`、`html_body`、`inline_images[]`、`attachments[]`、`related_orders[]`、`related_tasks[]`。 +- `html_sanitize_required=true` 时,前端必须先 sanitize 再渲染 HTML;不要使用未清洗的 `html_body` 直接设置 DOM。 +- `inline_images[]` 和 `attachments[]` 里的 `externalUrl` 可能包含有时效或访问凭证的外链,前端不得写入普通日志、错误上报、localStorage 或 URL query。 +- 会话详情接口由后端内部写原文读取审计,前端不传 `X-TH-Hotel-Source-Original-Read-Key`。 +- 会话详情外层字段主要是 snake_case,但媒体对象沿用原文读取接口字段,当前是 `mediaType`、`fileName`、`contentType`、`sizeBytes`、`externalUrl`、`externalMediaId` 这种 camelCase,前端类型定义需要单独处理。 + +### 5.4 订单列表接入注意 + +- `GET /api/reservation/orders` 默认返回全部订单状态,包括 `TEMPORARY`、`ACTIVE`、`ENDED`、`LOGIC_DELETED`。 +- `keyword` 会匹配订单业务号、临时订单号、展示名、订单状态,也会匹配来源消息安全摘要命中的 SourceMessage ID;前端可以用邮件主题、外部消息 ID 或会话 ID 辅助查订单。 +- `open_task_count` 只统计未关闭任务,排除 `COMPLETED` 和 `FAILED`。 +- `next_processable_task_id` 是后端按同订单队列实时计算出的下一条可处理任务;前端可以用它做“继续处理”入口。 +- `display_order_key` 是前端优先展示的订单业务号或临时订单号;`group_code` 和 `confirmation_number` 只有在当前订单业务号类型匹配时返回。 +- 订单 ID、任务 ID、SourceMessage ID 在这些前端接口中按字符串返回,前端不要转换成 JavaScript number。 + +### 5.5 前端联调演示数据 seed 接口 + +后端提供一个受控的 dev/test 演示数据入口,方便前端在空库或本地环境快速看到页面效果。 + +```text +POST /api/system/reservation/demo-data +Header: X-TH-Hotel-Demo-Data-Key: <本地演示数据访问口令> +Content-Type: application/json + +{ + "hotel_id": "HOTEL-TEST", + "run_label": "frontend-smoke" +} +``` + +启用方式: + +- 默认关闭,需要后端环境显式设置 `reservation.demo-data.enabled=true` 或环境变量 `RESERVATION_DEMO_DATA_ENABLED=true`。 +- 必须配置 `reservation.demo-data.access-key` 或环境变量 `RESERVATION_DEMO_DATA_ACCESS_KEY`。 +- 该接口只用于 dev/test 联调,不允许放进生产普通页面,也不要把访问口令写进前端仓库、浏览器环境变量或构建产物。 + +返回内容: + +- `demo_run_id`:本次 seed 的唯一关键词,可用于任务列表 / 订单列表搜索。 +- `source_messages[]`:本次生成的 SourceMessage ID、外部消息 ID 和会话 ID。 +- `orders[]`:本次生成的订单 ID、订单状态和展示键。 +- `tasks[]`:本次生成的任务 ID、任务类型、任务 subtype 和任务状态。 +- `entrypoints`:可直接访问的后端查询入口,包括任务列表、订单列表、队列订单详情、失败任务详情和邮件会话详情。 + +当前 seed 覆盖的页面效果: + +- 同订单前置任务未完成,后续任务只读不可处理。 +- 已完成 New Booking 任务和两条 OPERA 模拟成功记录。 +- OPERA 模拟失败任务,可在任务详情看到失败 attempt 和重试入口。 +- Fallback / manual_review 任务。 +- Message Notification 只读任务。 +- 同一邮件会话下多封邮件、完整 HTML、附件外链和内联图片外链。 + +### 5.6 任务详情字段元数据接入注意 + +- `fields[]` 第一版服务于任务详情动态展示,字段来源与白名单规则以 `docs/import/20260708/任务卡前端展示字段表 3.0.xlsx` 为准。 +- `result_type`、`task_type`、`task_subtype`、`default_value_source` 已透出给前端,用于和最新前端白名单对齐。 +- 后端校验、最终确认写入、OPERA 映射和展示条件仍以 `docs/import/20260706/任务卡展示编辑矩阵.xlsx` 为完整规则来源。 +- 前端保存草稿时不要自行按 `write_path` 重组 OPERA 参数;第一版按任务详情返回的字段和值提交即可,真实 OPERA 参数组装后续由后端 adapter / 转换层处理。 +- 任务详情页控制按钮时以 `availability.editable`、`availability.confirmable`、`availability.executable`、`availability.read_only` 和 `availability.blocked` 为准;`can_process` 和 `readonly_reason_code` 只出现在任务列表 / 订单时间线摘要里。 + ## 6. 不给前端直接调用的接口 +- `POST /api/system/reservation/demo-data` 只用于 dev/test 联调造数,不是生产业务页面接口;访问口令不能进入前端代码。 - `POST /api/integrations/superagent/task-results` 是 SuperAgent 到后端的服务到服务入站接口。 - `POST /api/ai-query/v1/case-context` 和 `POST /api/ai-query/v1/object-detail` 是 SuperAgent 查询上下文接口,不是前端页面接口。 +- `GET /api/source-message-conversations/{externalConversationId}` 是历史讨论过的候选路径,当前后端不提供,前端不要接入。 - AgentBus probe、fixture、replay、system 类接口不应放到普通业务前端页面。 ## 7. 需要持续提醒的后置事项 diff --git a/docs/project/frontend-backend/frontend-to-backend-api-requests.md b/docs/project/frontend-backend/frontend-to-backend-api-requests.md index cf4c3b8..854a558 100644 --- a/docs/project/frontend-backend/frontend-to-backend-api-requests.md +++ b/docs/project/frontend-backend/frontend-to-backend-api-requests.md @@ -14,6 +14,7 @@ | P0 | 任务详情读取接口 `GET /api/reservation/tasks/{taskId}` | 任务详情页动态渲染 | 已完成第一版,已补来源邮件字段和 3.0 字段元数据 | | P0 | 任务详情操作接口 | 任务详情保存、确认、OPERA、审计 | 已完成;前端可直接接入 | | P0 | 邮件会话详情接口 `GET /api/source-messages/{sourceMessageId}/conversation` | 邮件会话详情页 | 已完成第一版 | +| 联调 | 演示数据 seed 接口 `POST /api/system/reservation/demo-data` | 本地 / test 前端页面看效果 | 已完成;仅 dev/test 受控使用 | | P1 | Message Notification 列表 / 详情接口 | 信息提醒页或订单详情只读卡片 | 未完成独立接口;可先通过任务列表 / 任务详情展示 `INFORMATIONAL_MESSAGE` | | P1 | 任务卡前端字段白名单元数据接口 | 字段白名单调试、版本对齐 | 未完成;若任务详情已透出完整元数据,可后置 | | 后置 | 普通任务切换订单接口 | 任务详情订单归属调整 | 未完成;已确认后置 | @@ -38,7 +39,8 @@ | `GET /api/source-messages/{id}/original` | 已完成单封原文受控读取 | 谨慎接入 | 只能读单封邮件,不能返回同一 conversation 全量邮件。 | | `GET /api/reservation/orders` | 已完成第一版 | 可以 | 默认查询全部订单状态;`open_task_count` 排除 `COMPLETED` 和 `FAILED`。 | | `GET /api/source-messages/{sourceMessageId}/conversation` | 已完成第一版 | 可以 | 返回完整 text/html、媒体外链和关联订单 / 任务摘要;前端不传原文读取 key。 | -| `GET /api/source-message-conversations/{externalConversationId}` | 未发现后端实现 | 不可以 | 可作为后端偏好的邮件会话详情路径。 | +| `POST /api/system/reservation/demo-data` | 已完成 | 仅本地 / test 联调可用 | 默认关闭,必须后端配置访问口令;不能作为生产页面接口。 | +| `GET /api/source-message-conversations/{externalConversationId}` | 未发现后端实现 | 不可以 | 历史讨论过的候选路径,当前不提供;前端统一使用 `GET /api/source-messages/{sourceMessageId}/conversation`。 | | `GET /api/reservation/message-notifications` | 未发现后端实现 | 不可以 | 如需独立信息提醒页再新增;第一版可先用任务接口过滤。 | | `GET /api/reservation/task-card-field-whitelist` | 未发现后端实现 | 不可以 | 若任务详情 `fields[]` 已补齐 3.0 元数据,可后置。 | @@ -155,7 +157,7 @@ GET /api/reservation/orders/{orderId} | `orderId` | 是 | 订单 ID。 | | `hotel_id` | 否 | 酒店 ID。第一版如果只有单酒店,可为空。 | | `include_tasks` | 否 | 是否返回任务时间线,默认 `true`。 | -| `include_source_summary` | 否 | 是否返回来源消息摘要,默认 `true`。 | +| `include_source_summary` | 否 | 是否返回来源消息摘要,默认 `true`;第一版参数保留,前端暂不要依赖它做字段裁剪。 | 建议返参: @@ -227,7 +229,7 @@ GET /api/reservation/orders | `order_status` | 否 | `TEMPORARY`、`ACTIVE`、`ENDED`、`LOGIC_DELETED`。 | | `group_code` | 否 | 按 Group Code 精确或模糊查询,后端决定。 | | `confirmation_number` | 否 | 按 Confirmation No 查询。 | -| `keyword` | 否 | 前端搜索框统一关键词。 | +| `keyword` | 否 | 前端搜索框统一关键词;后端匹配订单字段,也会匹配来源消息安全摘要命中的 SourceMessage ID。 | | `page_num` | 否 | 页码。 | | `page_size` | 否 | 每页条数。 | @@ -258,7 +260,48 @@ GET /api/reservation/orders } ``` -## 6. 邮件会话详情接口 +## 6. 前端联调演示数据 seed 接口 + +建议路径: + +```text +POST /api/system/reservation/demo-data +``` + +当前状态:后端已完成第一版。该接口只用于本地 / test 联调造数,默认关闭,不是生产业务页面接口。 + +启用条件: + +| 配置 | 说明 | +| --- | --- | +| `reservation.demo-data.enabled=true` | 显式启用接口,也可用环境变量 `RESERVATION_DEMO_DATA_ENABLED=true`。 | +| `reservation.demo-data.access-key` | 配置访问口令,也可用环境变量 `RESERVATION_DEMO_DATA_ACCESS_KEY`。 | +| `X-TH-Hotel-Demo-Data-Key` | 请求头必须携带,与后端配置口令一致。 | + +请求示例: + +```json +{ + "hotel_id": "HOTEL-TEST", + "run_label": "frontend-smoke" +} +``` + +返回说明: + +| 字段 | 说明 | +| --- | --- | +| `demo_run_id` | 本次 seed 唯一关键词,可用于任务列表 / 订单列表搜索。 | +| `source_messages[]` | 生成的来源消息 ID、外部消息 ID 和外部会话 ID。 | +| `orders[]` | 生成的订单 ID、订单状态和展示键。 | +| `tasks[]` | 生成的任务 ID、任务类型、任务 subtype 和任务状态。 | +| `entrypoints` | 可直接访问的任务列表、订单列表、订单详情、任务详情、邮件会话详情 URL。 | + +第一版 seed 覆盖:队列阻塞、已完成 OPERA 模拟、OPERA 失败可重试、Fallback 人工复核、Message Notification、邮件会话完整 HTML / 附件 / 内联图片。 + +前端注意:不要把访问口令写入前端仓库、浏览器环境变量或构建产物;该接口只能由本地联调人员手动调用或由受控测试脚本调用。 + +## 7. 邮件会话详情接口 建议优先路径: @@ -266,13 +309,13 @@ GET /api/reservation/orders GET /api/source-messages/{sourceMessageId}/conversation ``` -可选补充路径: +历史候选路径,当前不提供: ```text GET /api/source-message-conversations/{externalConversationId} ``` -当前状态:后端已完成第一版。前端入口从某个任务的 `source_message_id` 进入,后端根据该 SourceMessage 找到 `external_conversation_id`,再返回同一邮件会话下的全部邮件。 +当前状态:`GET /api/source-messages/{sourceMessageId}/conversation` 已完成第一版。前端入口从某个任务的 `source_message_id` 进入,后端根据该 SourceMessage 找到 `external_conversation_id`,再返回同一邮件会话下的全部邮件。 中文说明: @@ -347,7 +390,7 @@ GET /api/source-message-conversations/{externalConversationId} } ``` -## 7. 任务详情接口字段元数据扩展 +## 8. 任务详情接口字段元数据扩展 建议路径: @@ -451,7 +494,7 @@ GET /api/reservation/tasks/{taskId} - `result_type`、`task_type`、`task_subtype`、`default_value_source` 当前从后端字段矩阵定义透出。 - 如果后端已有更细的字段来源或适用场景元数据,可后续再扩展 `field_source`、`applicable_scenario`,不作为本轮 P0 阻塞项。 -## 8. Message Notification 列表 / 详情接口 +## 9. Message Notification 列表 / 详情接口 当前状态:未发现后端独立 Message Notification 列表 / 详情接口。当前后端已经支持 `INFORMATIONAL_MESSAGE` 任务类型进入任务体系,第一版前端可以先通过 `GET /api/reservation/tasks?task_type=INFORMATIONAL_MESSAGE` 和 `GET /api/reservation/tasks/{taskId}` 展示信息提醒任务。仅当产品确认需要独立“信息提醒页”时,再新增本节接口。 @@ -492,7 +535,7 @@ GET /api/reservation/message-notifications/{taskId} } ``` -## 9. 任务卡前端字段白名单元数据接口 +## 10. 任务卡前端字段白名单元数据接口 是否需要该接口待确认。如果任务详情接口 `fields[]` 已透出 3.0 所需元数据,则第一版可以不做独立白名单接口;如果后续需要字段矩阵调试页、版本对齐页或前端预加载全部任务卡配置,再补独立接口。 @@ -530,7 +573,7 @@ GET /api/reservation/task-card-field-whitelist } ``` -## 10. 已确认后置接口 +## 11. 已确认后置接口 普通任务切换订单接口继续后置,前端暂不开发提交能力。后续如果恢复开发,建议另行确认: @@ -559,7 +602,7 @@ POST /api/reservation/tasks/{taskId}/order-binding } ``` -## 11. 待确认问题 +## 12. 待确认问题 - 订单列表、任务列表当前统一使用 `items + page` 分页结构;邮件会话详情不分页,返回同一会话全部邮件。 - 邮件会话详情接口已优先使用 `GET /api/source-messages/{sourceMessageId}/conversation`。 diff --git a/docs/project/requirements/M002-backend-checkpoint-plan.md b/docs/project/requirements/M002-backend-checkpoint-plan.md index c82a788..596921f 100644 --- a/docs/project/requirements/M002-backend-checkpoint-plan.md +++ b/docs/project/requirements/M002-backend-checkpoint-plan.md @@ -317,8 +317,9 @@ Controller、Service、Service 实现类的方法必须有中文注释。Entity - 已补齐 `GET /api/reservation/tasks` 来源邮件会话摘要字段:`source_sender_summary`、`source_received_at`、`external_conversation_id`、`conversation_message_count`。 - 已补齐 `GET /api/reservation/orders/{orderId}` 的 `tasks[]` 来源邮件会话摘要字段。 - 已补齐 `GET /api/reservation/tasks/{taskId}` 顶层来源邮件会话字段,并在 `fields[]` 透出 `result_type`、`task_type`、`task_subtype`、`default_value_source`。 -- 已实现订单列表接口 `GET /api/reservation/orders`,默认查询全部订单状态,支持酒店、订单状态、Group Code、Confirmation No.、关键词和分页筛选;`open_task_count` 排除 `COMPLETED` 和 `FAILED`。 +- 已实现订单列表接口 `GET /api/reservation/orders`,默认查询全部订单状态,支持酒店、订单状态、Group Code、Confirmation No.、关键词和分页筛选;`keyword` 可匹配订单字段,也可匹配来源消息安全摘要命中的 SourceMessage ID;`open_task_count` 排除 `COMPLETED` 和 `FAILED`。 - 已实现邮件会话详情接口 `GET /api/source-messages/{sourceMessageId}/conversation`,根据 SourceMessage 定位外部会话,返回完整 text/html、附件外链、内联图片、来源摘要和关联订单 / 任务摘要;原文读取审计由后端内部写入。 +- 已实现 dev/test 受控演示数据 seed 接口 `POST /api/system/reservation/demo-data`,默认关闭,需配置 `reservation.demo-data.enabled=true` 和访问口令;生成真实落库的任务列表、订单列表、订单详情、任务详情和邮件会话详情演示数据。 - Message Notification 独立列表 / 详情、任务卡前端字段白名单独立接口继续后置;第一版分别复用任务列表 / 任务详情和 `fields[]` 元数据。 ## 12. 建议开发节奏 diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/request/ReservationDemoDataSeedRequest.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/request/ReservationDemoDataSeedRequest.java new file mode 100644 index 0000000..bb81572 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/request/ReservationDemoDataSeedRequest.java @@ -0,0 +1,17 @@ +package cn.nianxx.thhotel.workflows.reservation.common.request; + +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Reservation 前端演示数据生成请求。该请求只用于 dev/test 受控 seed,不允许作为生产业务入口。 + * + * @param hotelId 酒店上下文 ID,为空时使用演示默认酒店 + * @param runLabel 本次演示数据的人类可读标签,会参与生成可搜索的 demo_run_id + */ +public record ReservationDemoDataSeedRequest( + @JsonProperty("hotel_id") + String hotelId, + @JsonProperty("run_label") + String runLabel +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationDemoDataOrderResult.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationDemoDataOrderResult.java new file mode 100644 index 0000000..d423169 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationDemoDataOrderResult.java @@ -0,0 +1,23 @@ +package cn.nianxx.thhotel.workflows.reservation.common.result; + +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Reservation 演示数据中的订单摘要。用于前端快速跳转订单详情。 + * + * @param scenarioCode 演示场景代码 + * @param orderId 订单 ID + * @param orderStatus 订单状态 + * @param displayOrderKey 前端展示订单号或临时订单号 + */ +public record ReservationDemoDataOrderResult( + @JsonProperty("scenario_code") + String scenarioCode, + @JsonProperty("order_id") + String orderId, + @JsonProperty("order_status") + String orderStatus, + @JsonProperty("display_order_key") + String displayOrderKey +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationDemoDataSeedResult.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationDemoDataSeedResult.java new file mode 100644 index 0000000..de3768a --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationDemoDataSeedResult.java @@ -0,0 +1,30 @@ +package cn.nianxx.thhotel.workflows.reservation.common.result; + +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.List; +import java.util.Map; + +/** + * Reservation 前端演示数据生成结果。返回本次 seed 的关键 ID 和可直接访问的后端查询入口。 + * + * @param demoRunId 本次演示数据唯一运行 ID,可作为 keyword 过滤 + * @param hotelId 酒店上下文 ID + * @param sourceMessages 本次生成的来源消息列表 + * @param orders 本次生成的订单列表 + * @param tasks 本次生成的任务列表 + * @param entrypoints 前端可直接调用的后端查询 URL + * @param notes 使用注意事项 + */ +public record ReservationDemoDataSeedResult( + @JsonProperty("demo_run_id") + String demoRunId, + @JsonProperty("hotel_id") + String hotelId, + @JsonProperty("source_messages") + List sourceMessages, + List orders, + List tasks, + Map entrypoints, + List notes +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationDemoDataSourceMessageResult.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationDemoDataSourceMessageResult.java new file mode 100644 index 0000000..d6951dd --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationDemoDataSourceMessageResult.java @@ -0,0 +1,25 @@ +package cn.nianxx.thhotel.workflows.reservation.common.result; + +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Reservation 演示数据中的来源消息摘要。ID 均以字符串返回,避免前端 Long 精度问题。 + * + * @param scenarioCode 演示场景代码 + * @param sourceMessageId SourceMessage Inbox 内部 ID + * @param externalMessageId 外部邮件消息 ID + * @param externalConversationId 外部邮件会话 ID + * @param subject 邮件主题 + */ +public record ReservationDemoDataSourceMessageResult( + @JsonProperty("scenario_code") + String scenarioCode, + @JsonProperty("source_message_id") + String sourceMessageId, + @JsonProperty("external_message_id") + String externalMessageId, + @JsonProperty("external_conversation_id") + String externalConversationId, + String subject +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationDemoDataTaskResult.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationDemoDataTaskResult.java new file mode 100644 index 0000000..12e474c --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationDemoDataTaskResult.java @@ -0,0 +1,29 @@ +package cn.nianxx.thhotel.workflows.reservation.common.result; + +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Reservation 演示数据中的任务摘要。用于前端快速跳转任务详情和验证状态展示。 + * + * @param scenarioCode 演示场景代码 + * @param taskId 任务 ID + * @param orderId 任务所属订单 ID + * @param taskType 系统主任务类型 + * @param taskSubtype 任务 subtype + * @param taskStatus 任务状态 + */ +public record ReservationDemoDataTaskResult( + @JsonProperty("scenario_code") + String scenarioCode, + @JsonProperty("task_id") + String taskId, + @JsonProperty("order_id") + String orderId, + @JsonProperty("task_type") + String taskType, + @JsonProperty("task_subtype") + String taskSubtype, + @JsonProperty("task_status") + String taskStatus +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationDemoDataController.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationDemoDataController.java new file mode 100644 index 0000000..63a9a6c --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationDemoDataController.java @@ -0,0 +1,42 @@ +package cn.nianxx.thhotel.workflows.reservation.control; + +import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationDemoDataSeedRequest; +import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationDemoDataSeedResult; +import cn.nianxx.thhotel.workflows.reservation.service.ReservationDemoDataService; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * Reservation 演示数据 Controller。该入口只在 dev/test 显式开启时注册,用于前端联调造数。 + */ +@RestController +@RequestMapping("/api/system/reservation/demo-data") +@ConditionalOnProperty(prefix = "reservation.demo-data", name = "enabled", havingValue = "true") +public class ReservationDemoDataController { + + private final ReservationDemoDataService demoDataService; + + /** + * 注入演示数据服务,Controller 不直接访问持久化层。 + */ + public ReservationDemoDataController(ReservationDemoDataService demoDataService) { + this.demoDataService = demoDataService; + } + + /** + * 生成一批前端演示数据;调用方必须携带受控访问口令。 + */ + @PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity seed( + @RequestHeader(name = "X-TH-Hotel-Demo-Data-Key", required = false) String accessKey, + @RequestBody(required = false) ReservationDemoDataSeedRequest request) { + return ResponseEntity.status(HttpStatus.CREATED).body(demoDataService.seed(request, accessKey)); + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationTaskControllerAdvice.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationTaskControllerAdvice.java index e62be5d..3d51333 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationTaskControllerAdvice.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationTaskControllerAdvice.java @@ -11,7 +11,8 @@ import org.springframework.web.bind.annotation.RestControllerAdvice; */ @RestControllerAdvice(assignableTypes = { ReservationTaskController.class, - ReservationFrontendQueryController.class + ReservationFrontendQueryController.class, + ReservationDemoDataController.class }) public class ReservationTaskControllerAdvice { 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 7836a72..82865d2 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 @@ -397,8 +397,10 @@ public class MybatisReservationAiWorkflowRepository implements ReservationAiWork @Override public ReservationPageSnapshot queryFrontendOrders( ReservationOrderListQueryRequest request, + List keywordSourceMessageIds, int pageNum, int pageSize) { + List sourceMessageIds = keywordSourceMessageIds == null ? List.of() : keywordSourceMessageIds; Page page = orderMapper.selectPage(Page.of(pageNum, pageSize), Wrappers.lambdaQuery() .eq(ReservationOrderEntity::getHotelId, request.hotelId()) @@ -426,7 +428,11 @@ public class MybatisReservationAiWorkflowRepository implements ReservationAiWork .or() .like(ReservationOrderEntity::getOrderStatus, trim(request.keyword())) .or() - .like(ReservationOrderEntity::getDisplayName, trim(request.keyword()))) + .like(ReservationOrderEntity::getDisplayName, trim(request.keyword())) + .or(!sourceMessageIds.isEmpty()) + .in(!sourceMessageIds.isEmpty(), + ReservationOrderEntity::getSourceMessageId, + sourceMessageIds)) .orderByDesc(ReservationOrderEntity::getUpdatedAt) .orderByDesc(ReservationOrderEntity::getId)); return new ReservationPageSnapshot<>( 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 2521275..4a2a6e8 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 @@ -118,10 +118,11 @@ public interface ReservationAiWorkflowRepository { List sourceMessageIds); /** - * 分页查询前端订单列表,默认包含全部订单状态。 + * 分页查询前端订单列表,默认包含全部订单状态;keyword 可匹配订单字段或来源消息安全摘要命中的 ID。 */ ReservationPageSnapshot queryFrontendOrders( ReservationOrderListQueryRequest request, + List keywordSourceMessageIds, int pageNum, int pageSize); diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/ReservationDemoDataService.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/ReservationDemoDataService.java new file mode 100644 index 0000000..5a2462c --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/ReservationDemoDataService.java @@ -0,0 +1,15 @@ +package cn.nianxx.thhotel.workflows.reservation.service; + +import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationDemoDataSeedRequest; +import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationDemoDataSeedResult; + +/** + * Reservation 前端演示数据服务。只在 dev/test 受控启用,用已有业务服务生成真实落库数据。 + */ +public interface ReservationDemoDataService { + + /** + * 生成一批前端演示数据,并返回订单、任务和来源消息关键 ID。 + */ + ReservationDemoDataSeedResult seed(ReservationDemoDataSeedRequest request, String accessKey); +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationDemoDataProperties.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationDemoDataProperties.java new file mode 100644 index 0000000..e9c0e73 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationDemoDataProperties.java @@ -0,0 +1,43 @@ +package cn.nianxx.thhotel.workflows.reservation.service.impl; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +/** + * Reservation 演示数据 seed 配置。默认关闭,访问口令只能通过环境变量或本地配置注入。 + */ +@Component +@ConfigurationProperties(prefix = "reservation.demo-data") +public class ReservationDemoDataProperties { + + /** 是否启用演示数据 seed 接口。 */ + private boolean enabled = false; + /** 演示数据 seed 访问口令。 */ + private String accessKey = ""; + /** 演示数据默认酒店上下文。 */ + private String defaultHotelId = "HOTEL-TEST"; + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public String getAccessKey() { + return accessKey; + } + + public void setAccessKey(String accessKey) { + this.accessKey = accessKey; + } + + public String getDefaultHotelId() { + return defaultHotelId; + } + + public void setDefaultHotelId(String defaultHotelId) { + this.defaultHotelId = defaultHotelId; + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationDemoDataServiceImpl.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationDemoDataServiceImpl.java new file mode 100644 index 0000000..88228b0 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationDemoDataServiceImpl.java @@ -0,0 +1,758 @@ +package cn.nianxx.thhotel.workflows.reservation.service.impl; + +import cn.nianxx.thhotel.platform.message.common.enums.SourceMessageMediaType; +import cn.nianxx.thhotel.platform.message.common.request.CaptureSourceMessageCommand; +import cn.nianxx.thhotel.platform.message.common.request.CaptureSourceMessageMedia; +import cn.nianxx.thhotel.platform.message.common.result.SourceMessageCaptureResult; +import cn.nianxx.thhotel.platform.message.service.SourceMessageCaptureService; +import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationDemoDataSeedRequest; +import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationOperaSimulationRequest; +import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationTaskPayloadMutationRequest; +import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationDemoDataOrderResult; +import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationDemoDataSeedResult; +import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationDemoDataSourceMessageResult; +import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationDemoDataTaskResult; +import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationOperaOperationResult; +import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationTaskDetailResult; +import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationTaskPayloadMutationResult; +import cn.nianxx.thhotel.workflows.reservation.common.result.SuperAgentTaskResultItemResponse; +import cn.nianxx.thhotel.workflows.reservation.common.result.SuperAgentTaskResultResponse; +import cn.nianxx.thhotel.workflows.reservation.service.ReservationAiTaskIntakeService; +import cn.nianxx.thhotel.workflows.reservation.service.ReservationDemoDataService; +import cn.nianxx.thhotel.workflows.reservation.service.ReservationTaskWorkflowService; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.UUID; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * Reservation 演示数据服务实现。通过真实 SourceMessage、SuperAgent 入站、任务确认和 OPERA 模拟服务造数。 + */ +@Service +public class ReservationDemoDataServiceImpl implements ReservationDemoDataService { + + private static final String DEMO_CLIENT_ID = "reservation-demo-data-seed"; + private static final String SOURCE_PROVIDER = "AGENTBUS"; + private static final String SOURCE_CHANNEL = "EMAIL"; + private static final String SOURCE_SCHEMA_VERSION = "agentbus-outlook-v1"; + private static final String SCENARIO_QUEUE = "QUEUE_BLOCKED"; + private static final String SCENARIO_QUEUE_REPLY = "QUEUE_CONVERSATION_REPLY"; + private static final String SCENARIO_QUEUE_FIRST = "QUEUE_FIRST"; + private static final String SCENARIO_COMPLETED = "OPERA_COMPLETED"; + private static final String SCENARIO_FAILED = "OPERA_FAILED"; + private static final String SCENARIO_FALLBACK = "FALLBACK_REVIEW"; + private static final String SCENARIO_INFORMATIONAL = "MESSAGE_NOTIFICATION"; + private static final DateTimeFormatter RUN_ID_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMddHHmmss"); + + private final ReservationDemoDataProperties properties; + private final ObjectMapper objectMapper; + private final SourceMessageCaptureService captureService; + private final ReservationAiTaskIntakeService intakeService; + private final ReservationTaskWorkflowService taskWorkflowService; + + /** + * 注入演示配置、JSON 工具和已有业务服务,避免 seed 功能直接写表。 + */ + public ReservationDemoDataServiceImpl( + ReservationDemoDataProperties properties, + ObjectMapper objectMapper, + SourceMessageCaptureService captureService, + ReservationAiTaskIntakeService intakeService, + ReservationTaskWorkflowService taskWorkflowService) { + this.properties = properties; + this.objectMapper = objectMapper; + this.captureService = captureService; + this.intakeService = intakeService; + this.taskWorkflowService = taskWorkflowService; + } + + /** + * 生成前端 P0 页面可直接查看的一批演示数据。该方法默认事务回滚失败的半成品。 + */ + @Override + @Transactional + public ReservationDemoDataSeedResult seed(ReservationDemoDataSeedRequest request, String accessKey) { + ensureAuthorized(accessKey); + String hotelId = normalizeHotelId(request == null ? null : request.hotelId()); + String demoRunId = buildDemoRunId(request == null ? null : request.runLabel()); + SeedAccumulator accumulator = new SeedAccumulator(); + + seedQueueBlockedScenario(hotelId, demoRunId, accumulator); + seedCompletedOperaScenario(hotelId, demoRunId, accumulator); + seedFailedOperaScenario(hotelId, demoRunId, accumulator); + seedFallbackScenario(hotelId, demoRunId, accumulator); + seedInformationalScenario(hotelId, demoRunId, accumulator); + + return new ReservationDemoDataSeedResult( + demoRunId, + hotelId, + List.copyOf(accumulator.sourceMessages()), + List.copyOf(accumulator.orders()), + List.copyOf(accumulator.tasks()), + entrypoints(hotelId, demoRunId, accumulator), + notes()); + } + + /** + * 生成同订单两条任务,第一条未完成,第二条用于前端验证只读阻塞状态。 + */ + private void seedQueueBlockedScenario(String hotelId, String demoRunId, SeedAccumulator accumulator) { + String conversationId = code("thread-demo-queue", demoRunId); + String externalMessageId = code("demo-queue-main", demoRunId); + CapturedSourceMessage mainSource = captureSourceMessage( + hotelId, + externalMessageId, + conversationId, + "Demo " + demoRunId + " queue booking request", + "Please create group booking and then update the stay dates. Demo run " + demoRunId + ".", + "

Please create group booking and then update the stay dates.

" + + "", + Instant.now().minusSeconds(900), + List.of( + new CaptureSourceMessageMedia( + SourceMessageMediaType.ATTACHMENT.code(), + "demo-rooming-list-" + demoRunId + ".xlsx", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + 2048L, + "https://example.test/demo/" + demoRunId + "/rooming-list.xlsx", + code("att-rooming", demoRunId)), + new CaptureSourceMessageMedia( + SourceMessageMediaType.INLINE_IMAGE.code(), + "demo-inline-" + demoRunId + ".png", + "image/png", + 1024L, + "https://example.test/demo/" + demoRunId + "/inline.png", + code("img-inline", demoRunId)))); + CapturedSourceMessage replySource = captureSourceMessage( + hotelId, + code("demo-queue-reply", demoRunId), + conversationId, + "Re: Demo " + demoRunId + " queue booking request", + "Adding a note to the same conversation for demo run " + demoRunId + ".", + "

Adding a note to the same conversation.

", + Instant.now().minusSeconds(600), + List.of()); + + String groupCode = code("GRP-DEMO-QUEUE", demoRunId).toUpperCase(Locale.ROOT); + SuperAgentTaskResultResponse response = intake( + twoTaskBody(hotelId, mainSource.externalMessageId(), demoRunId, groupCode), + demoRunId, + SCENARIO_QUEUE); + SuperAgentTaskResultItemResponse first = response.items().get(0); + SuperAgentTaskResultItemResponse blocked = response.items().get(1); + + accumulator.sourceMessages().add(sourceMessageResult(SCENARIO_QUEUE, mainSource)); + accumulator.sourceMessages().add(sourceMessageResult(SCENARIO_QUEUE_REPLY, replySource)); + accumulator.orders().add(orderResult(SCENARIO_QUEUE, first, groupCode)); + accumulator.tasks().add(taskResult(SCENARIO_QUEUE_FIRST, first, "new_group_block")); + accumulator.tasks().add(taskResult(SCENARIO_QUEUE, blocked, "update_stay_dates")); + } + + /** + * 生成已确认并两条 OPERA 模拟操作全部成功的任务,用于前端查看 completed 状态。 + */ + private void seedCompletedOperaScenario(String hotelId, String demoRunId, SeedAccumulator accumulator) { + CapturedSourceMessage source = captureSimpleSourceMessage( + hotelId, + SCENARIO_COMPLETED.toLowerCase(Locale.ROOT), + demoRunId, + "Demo " + demoRunId + " completed booking request"); + String confirmationNumber = code("CNF-DEMO-DONE", demoRunId).toUpperCase(Locale.ROOT); + SuperAgentTaskResultResponse response = intake( + newBookingBody(hotelId, source.externalMessageId(), demoRunId, confirmationNumber), + demoRunId, + SCENARIO_COMPLETED); + SuperAgentTaskResultItemResponse item = response.items().get(0); + ReservationTaskPayloadMutationResult confirmed = confirmTask(item.taskId()); + executeOperation(item.taskId(), confirmed.operaOperations().get(0).operationId(), true); + executeOperation(item.taskId(), confirmed.operaOperations().get(1).operationId(), true); + ReservationTaskDetailResult detail = taskWorkflowService.getTaskDetail(Long.valueOf(item.taskId())); + + accumulator.sourceMessages().add(sourceMessageResult(SCENARIO_COMPLETED, source)); + accumulator.orders().add(orderResult(SCENARIO_COMPLETED, item, confirmationNumber)); + accumulator.tasks().add(taskResult(SCENARIO_COMPLETED, item, "new_fit_reservation", detail.taskStatus())); + } + + /** + * 生成已确认但第一条 OPERA 模拟失败的任务,用于前端查看失败和重试入口。 + */ + private void seedFailedOperaScenario(String hotelId, String demoRunId, SeedAccumulator accumulator) { + CapturedSourceMessage source = captureSimpleSourceMessage( + hotelId, + SCENARIO_FAILED.toLowerCase(Locale.ROOT), + demoRunId, + "Demo " + demoRunId + " failed operation booking request"); + String confirmationNumber = code("CNF-DEMO-FAIL", demoRunId).toUpperCase(Locale.ROOT); + SuperAgentTaskResultResponse response = intake( + newBookingBody(hotelId, source.externalMessageId(), demoRunId, confirmationNumber), + demoRunId, + SCENARIO_FAILED); + SuperAgentTaskResultItemResponse item = response.items().get(0); + ReservationTaskPayloadMutationResult confirmed = confirmTask(item.taskId()); + executeOperation(item.taskId(), confirmed.operaOperations().get(0).operationId(), false); + ReservationTaskDetailResult detail = taskWorkflowService.getTaskDetail(Long.valueOf(item.taskId())); + + accumulator.sourceMessages().add(sourceMessageResult(SCENARIO_FAILED, source)); + accumulator.orders().add(orderResult(SCENARIO_FAILED, item, confirmationNumber)); + accumulator.tasks().add(taskResult(SCENARIO_FAILED, item, "new_fit_reservation", detail.taskStatus())); + } + + /** + * 生成 Fallback 人工复核任务,用于前端查看异常任务转换前状态。 + */ + private void seedFallbackScenario(String hotelId, String demoRunId, SeedAccumulator accumulator) { + CapturedSourceMessage source = captureSimpleSourceMessage( + hotelId, + SCENARIO_FALLBACK.toLowerCase(Locale.ROOT), + demoRunId, + "Demo " + demoRunId + " fallback review request"); + SuperAgentTaskResultResponse response = intake( + fallbackBody(hotelId, source.externalMessageId(), demoRunId), + demoRunId, + SCENARIO_FALLBACK); + SuperAgentTaskResultItemResponse item = response.items().get(0); + + accumulator.sourceMessages().add(sourceMessageResult(SCENARIO_FALLBACK, source)); + accumulator.orders().add(orderResult(SCENARIO_FALLBACK, item, temporaryOrderCode(source))); + accumulator.tasks().add(taskResult(SCENARIO_FALLBACK, item, "manual_review")); + } + + /** + * 生成 Message Notification 任务,用于前端验证非执行队列消息提醒。 + */ + private void seedInformationalScenario(String hotelId, String demoRunId, SeedAccumulator accumulator) { + CapturedSourceMessage source = captureSimpleSourceMessage( + hotelId, + SCENARIO_INFORMATIONAL.toLowerCase(Locale.ROOT), + demoRunId, + "Demo " + demoRunId + " informational reply"); + SuperAgentTaskResultResponse response = intake( + informationalBody(hotelId, source.externalMessageId(), demoRunId), + demoRunId, + SCENARIO_INFORMATIONAL); + SuperAgentTaskResultItemResponse item = response.items().get(0); + + accumulator.sourceMessages().add(sourceMessageResult(SCENARIO_INFORMATIONAL, source)); + accumulator.orders().add(orderResult(SCENARIO_INFORMATIONAL, item, temporaryOrderCode(source))); + accumulator.tasks().add(taskResult(SCENARIO_INFORMATIONAL, item, "thank_you")); + } + + /** + * 校验 seed 开关和访问口令,口令比较使用常量时间比较,避免明显时序差异。 + */ + private void ensureAuthorized(String accessKey) { + if (!properties.isEnabled()) { + throw error(HttpStatus.SERVICE_UNAVAILABLE, "DEMO_DATA_DISABLED", "演示数据 seed 接口未启用。"); + } + String configuredKey = trimToNull(properties.getAccessKey()); + if (configuredKey == null) { + throw error(HttpStatus.SERVICE_UNAVAILABLE, "DEMO_DATA_ACCESS_KEY_NOT_CONFIGURED", "演示数据访问口令未配置。"); + } + if (!accessKeyMatches(configuredKey, accessKey)) { + throw error(HttpStatus.FORBIDDEN, "DEMO_DATA_ACCESS_DENIED", "演示数据访问被拒绝。"); + } + } + + /** + * 生成同订单两任务的 SuperAgent 入站请求体。 + */ + private String twoTaskBody(String hotelId, String externalMessageId, String demoRunId, String groupCode) { + return taskResultBody(hotelId, externalMessageId, List.of( + taskItem( + 1, + "S01", + "S01_new_booking_skill", + "normal_task", + "New Booking", + "new_group_block", + "AI extracted demo new group booking " + demoRunId + ".", + "Please create the group booking for " + demoRunId + ".", + Map.of("group_code", groupCode), + Map.of( + "booking_object_type", "Group Block", + "arrival_date", "2026-08-01", + "departure_date", "2026-08-04", + "room_quantity", 2, + "room_type", "Deluxe Twin", + "pms_room_type_code", "RM2", + "rate_code_result", Map.of( + "rate_code", "GRPA1-900", + "settlement_price", 900), + "fix_charge_required", false), + Map.of("demo_run_id", demoRunId)), + taskItem( + 2, + "S02", + "S02_update_booking_amendment_skill", + "normal_task", + "Update Booking", + "update_stay_dates", + "AI extracted demo update booking " + demoRunId + ".", + "Please update the group booking for " + demoRunId + ".", + Map.of("group_code", groupCode), + Map.of( + "update_subtypes", List.of("update_stay_dates"), + "before_after", Map.of( + "arrival_date", Map.of("before", "2026-08-01", "after", "2026-08-02"), + "departure_date", Map.of("before", "2026-08-04", "after", "2026-08-05"))), + Map.of("demo_run_id", demoRunId)))); + } + + /** + * 生成 New Booking 单任务 SuperAgent 入站请求体。 + */ + private String newBookingBody( + String hotelId, + String externalMessageId, + String demoRunId, + String confirmationNumber) { + return taskResultBody(hotelId, externalMessageId, List.of(taskItem( + 1, + "S01", + "S01_new_booking_skill", + "normal_task", + "New Booking", + "new_fit_reservation", + "AI extracted demo FIT booking " + demoRunId + ".", + "Please create the FIT booking for " + demoRunId + ".", + Map.of("confirmation_number", confirmationNumber), + Map.of( + "booking_object_type", "FIT Reservation", + "arrival_date", "2026-08-10", + "departure_date", "2026-08-12", + "room_quantity", 1, + "room_type", "Deluxe King", + "pms_room_type_code", "RM2", + "rate_code_result", Map.of( + "rate_code", "GRPA1-900", + "settlement_price", 900), + "fix_charge_required", false), + Map.of("demo_run_id", demoRunId)))); + } + + /** + * 生成 Fallback 人工复核 SuperAgent 入站请求体。 + */ + private String fallbackBody(String hotelId, String externalMessageId, String demoRunId) { + Map item = taskItem( + 1, + "S99", + "S99_fallback_manual_review_skill", + "manual_review", + "Fallback", + "manual_review", + "AI could not classify demo message " + demoRunId + ".", + "Please review ambiguous booking message for " + demoRunId + ".", + Map.of("confirmation_number", code("CNF-DEMO-FB", demoRunId).toUpperCase(Locale.ROOT)), + Map.of( + "booking_object_type", "FIT Reservation", + "arrival_date", "2026-09-01", + "departure_date", "2026-09-03"), + Map.of("demo_run_id", demoRunId)); + item.put("manual_review", Map.of( + "reason_code", "ambiguous_task_type", + "reason_text", "Demo fallback needs human classification.", + "candidate_task_types", List.of("New Booking", "Update Booking", "Cancel Booking"))); + return taskResultBody(hotelId, externalMessageId, List.of(item)); + } + + /** + * 生成 Message Notification SuperAgent 入站请求体。 + */ + private String informationalBody(String hotelId, String externalMessageId, String demoRunId) { + Map item = taskItem( + 1, + "S98", + "S98_message_notification_skill", + "informational_message", + "Message Notification", + "thank_you", + "AI extracted demo informational message " + demoRunId + ".", + "Guest replied thanks for " + demoRunId + ".", + Map.of(), + Map.of(), + Map.of("demo_run_id", demoRunId)); + item.put("informational_message", Map.of( + "category", "guest_reply", + "summary", "Guest acknowledged the reservation update.", + "severity", "low")); + return taskResultBody(hotelId, externalMessageId, List.of(item)); + } + + /** + * 组装 SuperAgent 顶层请求体。 + */ + private String taskResultBody(String hotelId, String externalMessageId, List> items) { + Map root = new LinkedHashMap<>(); + root.put("hotel_id", hotelId); + root.put("source_message_id", externalMessageId); + root.put("ai_task_results", items); + root.put("extraction_warnings", List.of()); + return toJson(root); + } + + /** + * 组装单条 ai_task_results item,保留 SuperAgent 已稳定字段。 + */ + private Map taskItem( + int sourceEventIndex, + String catalogCode, + String skillId, + String resultType, + String taskType, + String taskSubtype, + String visibleReason, + String relevantMessageExcerpt, + Map caseKeys, + Map extractedFields, + Map contextUsed) { + Map item = new LinkedHashMap<>(); + item.put("source_event_index", sourceEventIndex); + item.put("catalog_code", catalogCode); + item.put("skill_id", skillId); + item.put("result_type", resultType); + item.put("task_type", taskType); + item.put("task_subtype", taskSubtype); + item.put("current_or_history", "current"); + item.put("visible_reason", visibleReason); + item.put("relevant_message_excerpt", relevantMessageExcerpt); + item.put("attachments", List.of()); + item.put("file_references", List.of()); + item.put("context_used", contextUsed); + item.put("case_keys", caseKeys); + item.put("extracted_fields", extractedFields); + item.put("additional_operations", List.of()); + item.put("idempotency_key", null); + return item; + } + + /** + * 捕获一封简单演示邮件。 + */ + private CapturedSourceMessage captureSimpleSourceMessage( + String hotelId, + String scenarioCode, + String demoRunId, + String subject) { + return captureSourceMessage( + hotelId, + code("demo-" + scenarioCode, demoRunId), + code("thread-demo-" + scenarioCode, demoRunId), + subject, + "Demo mail for " + demoRunId + " scenario " + scenarioCode + ".", + "

Demo mail for " + demoRunId + " scenario " + scenarioCode + ".

", + Instant.now().minusSeconds(300), + List.of()); + } + + /** + * 调用 SourceMessage 捕获服务落库演示邮件。 + */ + private CapturedSourceMessage captureSourceMessage( + String hotelId, + String externalMessageId, + String externalConversationId, + String subject, + String textBody, + String htmlBody, + Instant sourceSentAt, + List mediaItems) { + SourceMessageCaptureResult result = captureService.capture(new CaptureSourceMessageCommand( + hotelId, + SOURCE_PROVIDER, + SOURCE_CHANNEL, + externalMessageId, + externalConversationId, + code("frame", externalMessageId), + code("session-demo-data", hotelId), + sourceSentAt, + "demo.guest@example.test", + subject, + textBody, + htmlBody, + toJson(Map.of( + "demo_run_id", externalMessageId, + "source", Map.of("external_message_id", externalMessageId))), + SOURCE_SCHEMA_VERSION, + mediaItems)); + if (!"RECEIVED".equals(result.captureStatus())) { + throw error(HttpStatus.CONFLICT, "DEMO_SOURCE_MESSAGE_CAPTURE_FAILED", "演示来源消息入库失败。"); + } + return new CapturedSourceMessage(result.inboxId(), externalMessageId, externalConversationId, subject); + } + + /** + * 调用 AI 入站服务创建订单、任务和任务卡。 + */ + private SuperAgentTaskResultResponse intake(String rawBody, String demoRunId, String scenarioCode) { + SuperAgentTaskResultResponse response = intakeService.accept( + rawBody, + DEMO_CLIENT_ID, + "demo-data-" + scenarioCode.toLowerCase(Locale.ROOT) + "-" + demoRunId); + if (response.items().isEmpty()) { + throw error(HttpStatus.CONFLICT, "DEMO_INTAKE_EMPTY", "演示 AI 入站未创建任务。"); + } + return response; + } + + /** + * 确认任务并生成固定两条 OPERA 模拟操作。 + */ + private ReservationTaskPayloadMutationResult confirmTask(String taskId) { + return taskWorkflowService.confirmTask( + Long.valueOf(taskId), + new ReservationTaskPayloadMutationRequest(Map.of())); + } + + /** + * 执行一条 OPERA 模拟操作,成功或失败由调用方指定。 + */ + private ReservationOperaOperationResult executeOperation(String taskId, String operationId, boolean success) { + return taskWorkflowService.executeOperaOperation( + Long.valueOf(taskId), + Long.valueOf(operationId), + new ReservationOperaSimulationRequest(success, success ? null : "Demo OPERA failure for retry.")); + } + + /** + * 转换来源消息为 seed 响应对象。 + */ + private ReservationDemoDataSourceMessageResult sourceMessageResult( + String scenarioCode, + CapturedSourceMessage source) { + return new ReservationDemoDataSourceMessageResult( + scenarioCode, + source.sourceMessageId().toString(), + source.externalMessageId(), + source.externalConversationId(), + source.subject()); + } + + /** + * 转换订单为 seed 响应对象。 + */ + private ReservationDemoDataOrderResult orderResult( + String scenarioCode, + SuperAgentTaskResultItemResponse item, + String displayOrderKey) { + return new ReservationDemoDataOrderResult( + scenarioCode, + item.orderId(), + item.orderStatus(), + displayOrderKey); + } + + /** + * 转换任务为 seed 响应对象,使用 AI 入站后的初始状态。 + */ + private ReservationDemoDataTaskResult taskResult( + String scenarioCode, + SuperAgentTaskResultItemResponse item, + String taskSubtype) { + return taskResult(scenarioCode, item, taskSubtype, item.taskStatus()); + } + + /** + * 转换任务为 seed 响应对象,允许覆盖任务后续状态。 + */ + private ReservationDemoDataTaskResult taskResult( + String scenarioCode, + SuperAgentTaskResultItemResponse item, + String taskSubtype, + String taskStatus) { + return new ReservationDemoDataTaskResult( + scenarioCode, + item.taskId(), + item.orderId(), + item.systemTaskType(), + taskSubtype, + taskStatus); + } + + /** + * 生成前端可直接调用的查询入口。 + */ + private Map entrypoints(String hotelId, String demoRunId, SeedAccumulator accumulator) { + String encodedHotelId = urlEncode(hotelId); + String encodedRunId = urlEncode(demoRunId); + String queueOrderId = accumulator.orders().stream() + .filter(order -> SCENARIO_QUEUE.equals(order.scenarioCode())) + .map(ReservationDemoDataOrderResult::orderId) + .findFirst() + .orElse(""); + String queueSourceMessageId = accumulator.sourceMessages().stream() + .filter(source -> SCENARIO_QUEUE.equals(source.scenarioCode())) + .map(ReservationDemoDataSourceMessageResult::sourceMessageId) + .findFirst() + .orElse(""); + String failedTaskId = accumulator.tasks().stream() + .filter(task -> SCENARIO_FAILED.equals(task.scenarioCode())) + .map(ReservationDemoDataTaskResult::taskId) + .findFirst() + .orElse(""); + Map entrypoints = new LinkedHashMap<>(); + entrypoints.put("task_list_url", "/api/reservation/tasks?hotel_id=" + encodedHotelId + + "&keyword=" + encodedRunId + "&page_num=1&page_size=20"); + entrypoints.put("order_list_url", "/api/reservation/orders?hotel_id=" + encodedHotelId + + "&keyword=" + encodedRunId + "&page_num=1&page_size=20"); + entrypoints.put("queue_order_detail_url", "/api/reservation/orders/" + queueOrderId + + "?hotel_id=" + encodedHotelId + "&include_tasks=true&include_source_summary=true"); + entrypoints.put("failed_task_detail_url", "/api/reservation/tasks/" + failedTaskId); + entrypoints.put("source_conversation_url", "/api/source-messages/" + queueSourceMessageId + "/conversation"); + return entrypoints; + } + + /** + * 返回演示数据使用注意事项。 + */ + private List notes() { + return List.of( + "该接口默认关闭,仅用于 dev/test 前端联调造数。", + "演示数据通过真实业务服务写入,可用于任务列表、订单列表、订单详情、任务详情和邮件会话详情。", + "OPERA 仍为模拟骨架,失败场景只用于验证前端重试入口。"); + } + + /** + * 生成安全短 run id,用于业务号、邮件主题和 keyword 查询。 + */ + private String buildDemoRunId(String rawRunLabel) { + String label = sanitizeCode(trimToNull(rawRunLabel) == null ? "demo" : rawRunLabel); + String time = LocalDateTime.now(ZoneOffset.UTC).format(RUN_ID_TIME_FORMATTER); + String suffix = UUID.randomUUID().toString().substring(0, 8); + return label + "-" + time + "-" + suffix; + } + + /** + * 标准化酒店上下文 ID。 + */ + private String normalizeHotelId(String rawHotelId) { + String hotelId = trimToNull(rawHotelId); + return hotelId == null ? properties.getDefaultHotelId() : hotelId; + } + + /** + * 拼接稳定代码,保持长度适合外部消息 ID 和业务号字段。 + */ + private String code(String prefix, String suffix) { + return prefix + "-" + sanitizeCode(suffix); + } + + /** + * 仅保留适合业务号和消息 ID 的字符,并限制长度。 + */ + private String sanitizeCode(String value) { + String sanitized = value == null + ? "demo" + : value.trim().replaceAll("[^A-Za-z0-9_-]", "-").replaceAll("-+", "-"); + if (sanitized.isBlank()) { + return "demo"; + } + return sanitized.length() > 48 ? sanitized.substring(0, 48) : sanitized; + } + + /** + * 访问口令常量时间比较。 + */ + private boolean accessKeyMatches(String configuredKey, String providedKey) { + if (providedKey == null) { + return false; + } + return MessageDigest.isEqual( + sha256Bytes(configuredKey), + sha256Bytes(providedKey)); + } + + /** + * 计算访问口令摘要,用固定长度字节数组做常量时间比较。 + */ + private byte[] sha256Bytes(String value) { + try { + return MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8)); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("当前 Java 运行时不支持 SHA-256", exception); + } + } + + /** + * 生成与订单创建逻辑一致的临时订单展示号。 + */ + private String temporaryOrderCode(CapturedSourceMessage source) { + return "TMP-SM-" + source.sourceMessageId() + "-1"; + } + + /** + * JSON 序列化工具,避免手工拼接 SuperAgent 请求体。 + */ + private String toJson(Object value) { + try { + return objectMapper.writeValueAsString(value); + } catch (JsonProcessingException exception) { + throw new IllegalStateException("演示数据 JSON 构建失败。", exception); + } + } + + /** + * URL 参数编码。 + */ + private String urlEncode(String value) { + return URLEncoder.encode(value, StandardCharsets.UTF_8); + } + + /** + * 清理空白字符串。 + */ + private String trimToNull(String value) { + if (value == null) { + return null; + } + String trimmed = value.trim(); + return trimmed.isEmpty() ? null : trimmed; + } + + /** + * 构建受控业务异常。 + */ + private ReservationTaskWorkflowException error(HttpStatus status, String errorCode, String message) { + return new ReservationTaskWorkflowException(status, errorCode, message); + } + + /** + * seed 过程中的来源消息引用。 + */ + private record CapturedSourceMessage( + Long sourceMessageId, + String externalMessageId, + String externalConversationId, + String subject + ) { + } + + /** + * seed 过程中的响应累加器,保持返回顺序稳定。 + */ + private record SeedAccumulator( + List sourceMessages, + List orders, + List tasks + ) { + + private SeedAccumulator() { + this(new ArrayList<>(), new ArrayList<>(), new ArrayList<>()); + } + } +} 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 efaef48..fb215ee 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 @@ -106,8 +106,11 @@ public class ReservationFrontendQueryServiceImpl implements ReservationFrontendQ ReservationOrderListQueryRequest normalizedRequest = normalizeOrderListRequest(request); int pageNum = normalizePageNum(normalizedRequest.pageNum()); int pageSize = normalizePageSize(normalizedRequest.pageSize()); + List keywordSourceMessageIds = findSourceMessageIdsByKeyword( + normalizedRequest.hotelId(), + normalizedRequest.keyword()); ReservationPageSnapshot page = - workflowRepository.queryFrontendOrders(normalizedRequest, pageNum, pageSize); + workflowRepository.queryFrontendOrders(normalizedRequest, keywordSourceMessageIds, pageNum, pageSize); List orderIds = page.items().stream().map(ReservationAiQueryOrderSnapshot::id).toList(); List orderTasks = workflowRepository.findAiQueryTasksByOrderIds( normalizedRequest.hotelId(), diff --git a/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationDemoDataControllerTest.java b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationDemoDataControllerTest.java new file mode 100644 index 0000000..0a27d1e --- /dev/null +++ b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationDemoDataControllerTest.java @@ -0,0 +1,125 @@ +package cn.nianxx.thhotel.workflows.reservation.control; + +import static org.hamcrest.Matchers.greaterThanOrEqualTo; +import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.not; +import static org.hamcrest.Matchers.blankOrNullString; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import cn.nianxx.thhotel.ThHotelApplication; +import com.jayway.jsonpath.JsonPath; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; + +@SpringBootTest( + classes = ThHotelApplication.class, + properties = { + "reservation.demo-data.enabled=true", + "reservation.demo-data.access-key=test-demo-data-key" + }) +@AutoConfigureMockMvc +@ActiveProfiles("test") +class ReservationDemoDataControllerTest { + + private static final String HOTEL_ID = "HOTEL-TEST"; + private static final String ACCESS_KEY = "test-demo-data-key"; + + @Autowired + private MockMvc mockMvc; + + @Test + void shouldRejectDemoDataSeedWhenAccessKeyMissing() throws Exception { + mockMvc.perform(post("/api/system/reservation/demo-data") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "hotel_id": "HOTEL-TEST" + } + """)) + .andExpect(status().isForbidden()); + } + + @Test + void shouldSeedFrontendDemoDataAndMakeItQueryable() throws Exception { + MvcResult seedResult = mockMvc.perform(post("/api/system/reservation/demo-data") + .header("X-TH-Hotel-Demo-Data-Key", ACCESS_KEY) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "hotel_id": "HOTEL-TEST", + "run_label": "controller-test" + } + """)) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.demo_run_id", not(blankOrNullString()))) + .andExpect(jsonPath("$.hotel_id").value(HOTEL_ID)) + .andExpect(jsonPath("$.source_messages", hasSize(greaterThanOrEqualTo(5)))) + .andExpect(jsonPath("$.orders", hasSize(greaterThanOrEqualTo(5)))) + .andExpect(jsonPath("$.tasks", hasSize(greaterThanOrEqualTo(6)))) + .andExpect(jsonPath("$.entrypoints.task_list_url", not(blankOrNullString()))) + .andReturn(); + + String response = seedResult.getResponse().getContentAsString(); + String demoRunId = JsonPath.read(response, "$.demo_run_id"); + String queueSourceMessageId = first(response, "$.source_messages[?(@.scenario_code=='QUEUE_BLOCKED')].source_message_id"); + String queueOrderId = first(response, "$.orders[?(@.scenario_code=='QUEUE_BLOCKED')].order_id"); + String blockedTaskId = first(response, "$.tasks[?(@.scenario_code=='QUEUE_BLOCKED')].task_id"); + String failedTaskId = first(response, "$.tasks[?(@.scenario_code=='OPERA_FAILED')].task_id"); + + mockMvc.perform(get("/api/reservation/tasks") + .param("hotel_id", HOTEL_ID) + .param("keyword", demoRunId) + .param("page_num", "1") + .param("page_size", "20")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.items", hasSize(greaterThanOrEqualTo(5)))); + + mockMvc.perform(get("/api/reservation/orders") + .param("hotel_id", HOTEL_ID) + .param("keyword", demoRunId) + .param("page_num", "1") + .param("page_size", "20")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.items", hasSize(greaterThanOrEqualTo(5)))); + + mockMvc.perform(get("/api/reservation/orders/{orderId}", queueOrderId) + .param("hotel_id", HOTEL_ID) + .param("include_tasks", "true") + .param("include_source_summary", "true")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.tasks", hasSize(greaterThanOrEqualTo(2)))) + .andExpect(jsonPath("$.tasks[1].readonly_reason_code").value("PREVIOUS_TASK_NOT_FINISHED")); + + mockMvc.perform(get("/api/reservation/tasks/{taskId}", blockedTaskId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.availability.blocked").value(true)) + .andExpect(jsonPath("$.availability.read_only").value(true)) + .andExpect(jsonPath("$.availability.blocked_by_task_id", not(blankOrNullString()))); + + mockMvc.perform(get("/api/reservation/tasks/{taskId}", failedTaskId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.opera_operations[0].operation_status").value("FAILED")) + .andExpect(jsonPath("$.opera_operations[0].attempt_count").value(1)); + + mockMvc.perform(get("/api/source-messages/{sourceMessageId}/conversation", queueSourceMessageId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.messages", hasSize(greaterThanOrEqualTo(2)))) + .andExpect(jsonPath("$.messages[0].html_sanitize_required").value(true)) + .andExpect(jsonPath("$.messages[0].attachments", hasSize(greaterThanOrEqualTo(1)))); + } + + private String first(String json, String path) { + List values = JsonPath.read(json, path); + return values.get(0); + } +}