实现前端演示数据受控生成接口

This commit is contained in:
andy
2026-07-08 19:09:17 +08:00
parent 3c1649a567
commit c83935a26c
17 changed files with 1271 additions and 16 deletions

View File

@@ -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. 需要持续提醒的后置事项

View File

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

View File

@@ -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. 建议开发节奏

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -11,7 +11,8 @@ import org.springframework.web.bind.annotation.RestControllerAdvice;
*/
@RestControllerAdvice(assignableTypes = {
ReservationTaskController.class,
ReservationFrontendQueryController.class
ReservationFrontendQueryController.class,
ReservationDemoDataController.class
})
public class ReservationTaskControllerAdvice {

View File

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

View File

@@ -118,10 +118,11 @@ public interface ReservationAiWorkflowRepository {
List<Long> sourceMessageIds);
/**
* 分页查询前端订单列表,默认包含全部订单状态。
* 分页查询前端订单列表,默认包含全部订单状态keyword 可匹配订单字段或来源消息安全摘要命中的 ID
*/
ReservationPageSnapshot<ReservationAiQueryOrderSnapshot> queryFrontendOrders(
ReservationOrderListQueryRequest request,
List<Long> keywordSourceMessageIds,
int pageNum,
int pageSize);

View File

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

View File

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

View File

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

View File

@@ -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(),

View File

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