实现前端演示数据受控生成接口
This commit is contained in:
@@ -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. 需要持续提醒的后置事项
|
||||
|
||||
@@ -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`。
|
||||
|
||||
@@ -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. 建议开发节奏
|
||||
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
@@ -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<ReservationDemoDataSourceMessageResult> sourceMessages,
|
||||
List<ReservationDemoDataOrderResult> orders,
|
||||
List<ReservationDemoDataTaskResult> tasks,
|
||||
Map<String, String> entrypoints,
|
||||
List<String> notes
|
||||
) {
|
||||
}
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
@@ -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<ReservationDemoDataSeedResult> 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));
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,8 @@ import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
*/
|
||||
@RestControllerAdvice(assignableTypes = {
|
||||
ReservationTaskController.class,
|
||||
ReservationFrontendQueryController.class
|
||||
ReservationFrontendQueryController.class,
|
||||
ReservationDemoDataController.class
|
||||
})
|
||||
public class ReservationTaskControllerAdvice {
|
||||
|
||||
|
||||
@@ -397,8 +397,10 @@ public class MybatisReservationAiWorkflowRepository implements ReservationAiWork
|
||||
@Override
|
||||
public ReservationPageSnapshot<ReservationAiQueryOrderSnapshot> queryFrontendOrders(
|
||||
ReservationOrderListQueryRequest request,
|
||||
List<Long> keywordSourceMessageIds,
|
||||
int pageNum,
|
||||
int pageSize) {
|
||||
List<Long> sourceMessageIds = keywordSourceMessageIds == null ? List.of() : keywordSourceMessageIds;
|
||||
Page<ReservationOrderEntity> page = orderMapper.selectPage(Page.of(pageNum, pageSize),
|
||||
Wrappers.<ReservationOrderEntity>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<>(
|
||||
|
||||
@@ -118,10 +118,11 @@ public interface ReservationAiWorkflowRepository {
|
||||
List<Long> sourceMessageIds);
|
||||
|
||||
/**
|
||||
* 分页查询前端订单列表,默认包含全部订单状态。
|
||||
* 分页查询前端订单列表,默认包含全部订单状态;keyword 可匹配订单字段或来源消息安全摘要命中的 ID。
|
||||
*/
|
||||
ReservationPageSnapshot<ReservationAiQueryOrderSnapshot> queryFrontendOrders(
|
||||
ReservationOrderListQueryRequest request,
|
||||
List<Long> keywordSourceMessageIds,
|
||||
int pageNum,
|
||||
int pageSize);
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 + ".",
|
||||
"<html><body><p>Please create group booking and then update the stay dates.</p>"
|
||||
+ "<img src=\"cid:demo-inline\" /></body></html>",
|
||||
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 + ".",
|
||||
"<html><body><p>Adding a note to the same conversation.</p></body></html>",
|
||||
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<String, Object> 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<String, Object> 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<Map<String, Object>> items) {
|
||||
Map<String, Object> 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<String, Object> taskItem(
|
||||
int sourceEventIndex,
|
||||
String catalogCode,
|
||||
String skillId,
|
||||
String resultType,
|
||||
String taskType,
|
||||
String taskSubtype,
|
||||
String visibleReason,
|
||||
String relevantMessageExcerpt,
|
||||
Map<String, Object> caseKeys,
|
||||
Map<String, Object> extractedFields,
|
||||
Map<String, Object> contextUsed) {
|
||||
Map<String, Object> 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 + ".",
|
||||
"<html><body><p>Demo mail for " + demoRunId + " scenario " + scenarioCode + ".</p></body></html>",
|
||||
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<CaptureSourceMessageMedia> 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<String, String> 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<String, String> 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<String> 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<ReservationDemoDataSourceMessageResult> sourceMessages,
|
||||
List<ReservationDemoDataOrderResult> orders,
|
||||
List<ReservationDemoDataTaskResult> tasks
|
||||
) {
|
||||
|
||||
private SeedAccumulator() {
|
||||
this(new ArrayList<>(), new ArrayList<>(), new ArrayList<>());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -106,8 +106,11 @@ public class ReservationFrontendQueryServiceImpl implements ReservationFrontendQ
|
||||
ReservationOrderListQueryRequest normalizedRequest = normalizeOrderListRequest(request);
|
||||
int pageNum = normalizePageNum(normalizedRequest.pageNum());
|
||||
int pageSize = normalizePageSize(normalizedRequest.pageSize());
|
||||
List<Long> keywordSourceMessageIds = findSourceMessageIdsByKeyword(
|
||||
normalizedRequest.hotelId(),
|
||||
normalizedRequest.keyword());
|
||||
ReservationPageSnapshot<ReservationAiQueryOrderSnapshot> page =
|
||||
workflowRepository.queryFrontendOrders(normalizedRequest, pageNum, pageSize);
|
||||
workflowRepository.queryFrontendOrders(normalizedRequest, keywordSourceMessageIds, pageNum, pageSize);
|
||||
List<Long> orderIds = page.items().stream().map(ReservationAiQueryOrderSnapshot::id).toList();
|
||||
List<ReservationAiQueryTaskSnapshot> orderTasks = workflowRepository.findAiQueryTasksByOrderIds(
|
||||
normalizedRequest.hotelId(),
|
||||
|
||||
@@ -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<String> values = JsonPath.read(json, path);
|
||||
return values.get(0);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user