统一 SuperAgent 查询接口鉴权契约

This commit is contained in:
andy
2026-07-08 10:10:00 +08:00
parent 652c5c10c5
commit fb82386fdb
10 changed files with 832 additions and 72 deletions

View File

@@ -16,3 +16,4 @@
- `requirements/M002-backend-data-model-design.md`M002 后端数据模型设计,记录 AI 过渡层、订单、任务、任务卡、审计和 OPERA 模拟结果表。 - `requirements/M002-backend-data-model-design.md`M002 后端数据模型设计,记录 AI 过渡层、订单、任务、任务卡、审计和 OPERA 模拟结果表。
- `requirements/M002-backend-checkpoint-plan.md`M002 后端 checkpoint 计划,记录后端实现拆分、交付物和验收标准。 - `requirements/M002-backend-checkpoint-plan.md`M002 后端 checkpoint 计划,记录后端实现拆分、交付物和验收标准。
- `integrations/superagent-agentbus-project-integration-guide.md`:当前项目 SuperAgent 与 AgentBus 验证记录和项目级接入细节。 - `integrations/superagent-agentbus-project-integration-guide.md`:当前项目 SuperAgent 与 AgentBus 验证记录和项目级接入细节。
- `integrations/superagent-api-contract.md`:给 SuperAgent 对接方使用的接口契约,记录查询上下文、对象详情、任务结果通知和统一 HMAC 规则。

View File

@@ -16,6 +16,7 @@
- Reservation 任务详情接口:返回任务字段、队列可处理状态和 OPERA 模拟操作摘要。 - Reservation 任务详情接口:返回任务字段、队列可处理状态和 OPERA 模拟操作摘要。
- Reservation 任务草稿保存和最终确认接口:按任务卡矩阵做第一版后端校验,确认后生成 `confirmed_payload_json` - Reservation 任务草稿保存和最终确认接口:按任务卡矩阵做第一版后端校验,确认后生成 `confirmed_payload_json`
- Reservation OPERA 模拟骨架已确认任务固定生成两条模拟操作支持执行、失败重试、attempt 记录和任务审计列表。 - Reservation OPERA 模拟骨架已确认任务固定生成两条模拟操作支持执行、失败重试、attempt 记录和任务审计列表。
- SuperAgent 查询上下文接口 1、2支持 HMAC 鉴权的订单上下文查询和对象详情查询。
当前不要把以下能力当作已上线: 当前不要把以下能力当作已上线:
@@ -24,7 +25,7 @@
- 自动 ACK、`task.result` 或客户回复。 - 自动 ACK、`task.result` 或客户回复。
- 业务前端页面展示邮件原文。 - 业务前端页面展示邮件原文。
- OHIP / OPERA 或其他业务系统真实写操作。 - OHIP / OPERA 或其他业务系统真实写操作。
- SuperAgent 查询上下文接口。 - SuperAgent 查询上下文接口 3、4
- 普通任务切换订单接口。 - 普通任务切换订单接口。
- 用户身份、权限和真实审计 actor。 - 用户身份、权限和真实审计 actor。
@@ -82,6 +83,21 @@
- `AGENTBUS_CAPTURE_ENABLED=false` 时,业务 frame 会被忽略,不会写入 SourceMessage Inbox。 - `AGENTBUS_CAPTURE_ENABLED=false` 时,业务 frame 会被忽略,不会写入 SourceMessage Inbox。
- 当前实现不发送 ACK、不发送 `task.result`、不自动回复客户。 - 当前实现不发送 ACK、不发送 `task.result`、不自动回复客户。
### 3.4 SuperAgent HMAC
| 变量 | 是否 Secret | 上线注意事项 |
| --- | --- | --- |
| `SUPERAGENT_TASK_RESULT_HMAC_SECRET` | 是 | SuperAgent 查询接口 1、2 和任务结果通知接口共用的 HMAC secret。生产不能为空只能通过 Secret 注入。 |
| `SUPERAGENT_TASK_RESULT_CLOCK_SKEW_SECONDS` | 否 | 请求时间允许偏移,默认 `300` 秒。上线前确认双方机器时间已同步。 |
| `SUPERAGENT_TASK_RESULT_NONCE_TTL_SECONDS` | 否 | nonce 防重放窗口,默认 `600` 秒。 |
| `SUPERAGENT_TASK_RESULT_MAX_BODY_BYTES` | 否 | SuperAgent 入站请求体最大字节数,默认 `1048576`。 |
注意:
- 查询接口和任务结果通知接口使用同一套 Header、签名串、secret、timestamp 和 nonce 规则。
- SuperAgent 侧也需要配置同一个 secret并按原始请求体计算 SHA-256。
- 当前第一版只支持一个 HMAC secretsecret 轮换需要协调部署窗口。
## 4. 数据库上线注意事项 ## 4. 数据库上线注意事项
当前 SourceMessage 相关 migration 当前 SourceMessage 相关 migration

View File

@@ -0,0 +1,393 @@
# TH Hotel SuperAgent API 对接契约
## 文档信息
| 项目 | 内容 |
| --- | --- |
| 文档版本 | 0.1 |
| 日期 | 2026-07-08 |
| 状态 | 第一版后端已实现接口契约 |
| 适用范围 | SuperAgent 调用本系统查询上下文、提交 AI 任务结果 |
| 主要读者 | SuperAgent 对接方、后端、测试、运维 |
## 1. 基础约定
请求地址先使用占位符:
```text
{TH_HOTEL_API_BASE_URL}
```
上线或联调时由环境提供实际域名,例如 UAT、生产内网域名或 API Gateway 地址。本文所有接口均为 SuperAgent 到本系统的服务到服务调用,不给前端直接调用。
## 2. 通用 HMAC 鉴权
查询接口和任务结果通知接口使用同一套 HMAC-SHA256 规则。
### 2.1 通用 Header
| Header | 是否必填 | 中文说明 |
| --- | --- | --- |
| `Content-Type` | 是 | 固定 `application/json` |
| `X-TH-Hotel-SuperAgent-Client-Id` | 是 | SuperAgent 调用方客户端 ID |
| `X-TH-Hotel-SuperAgent-Timestamp` | 是 | UTC ISO-8601 时间,例如 `2026-07-08T01:30:00Z` |
| `X-TH-Hotel-SuperAgent-Nonce` | 是 | 每次请求唯一随机值,用于防重放 |
| `X-TH-Hotel-SuperAgent-Signature` | 是 | HMAC 签名,格式 `sha256=<lowercase-hex>` |
| `X-TH-Hotel-Request-Id` | 否 | 调用方请求 ID用于日志串联 |
| `X-TH-Hotel-AI-Trace-Id` | 否 | AI 运行链路 ID查询接口会原样带回 `trace_id` |
### 2.2 签名串
签名串使用接口 path不包含域名、query string 或 fragment。
```text
POST
<request_path>
<X-TH-Hotel-SuperAgent-Timestamp>
<X-TH-Hotel-SuperAgent-Nonce>
<X-TH-Hotel-SuperAgent-Client-Id>
<lowercase-hex-sha256-of-raw-body>
```
签名算法:
```text
signature = HMAC_SHA256(SUPERAGENT_TASK_RESULT_HMAC_SECRET, canonical_string)
```
Header 写法:
```text
X-TH-Hotel-SuperAgent-Signature: sha256=<lowercase-hex-signature>
```
### 2.3 服务端校验顺序
1. 校验请求体大小。
2. 校验 HMAC 相关 Header 是否存在。
3. 校验 timestamp 是否在允许时间窗口内。
4. 计算原始请求体 SHA-256。
5. 使用共享 secret 重新计算 HMAC。
6. 常量时间比较签名。
7. 校验并记录 `client_id + nonce`,防止重放。
8. 查询接口校验 `Content-Type` 是否为 `application/json`
9. 鉴权和协议校验通过后再解析业务 JSON。
## 3. 接口 1查询订单上下文
### 3.1 请求
| 项目 | 内容 |
| --- | --- |
| Method | `POST` |
| URL | `{TH_HOTEL_API_BASE_URL}/api/ai-query/v1/case-context` |
| request_path | `/api/ai-query/v1/case-context` |
| Content-Type | `application/json` |
| 业务动作 | 只读查询,不创建任务、不修改订单、不写 OPERA |
### 3.2 请求体
```json
{
"hotel_id": "HOTEL-TEST",
"source_message_id": "1900000000000000001",
"source_event_index": 1,
"group_code": "GRP-001",
"confirmation_number": null,
"reservation_no": null,
"object_type_hint": "group_block",
"target_key_source": "body_current",
"body_thread_used_only_as_evidence": false
}
```
字段说明:
| 字段 | 是否必填 | 中文说明 |
| --- | --- | --- |
| `hotel_id` | 是 | 酒店上下文 ID |
| `source_message_id` | 否 | 当前 SourceMessage ID全局上下文查询时可不传只作为审计和排查字段 |
| `source_event_index` | 否 | 当前 AI 事件序号;全局上下文查询可不传,传入时必须为正整数 |
| `group_code` | 条件必填 | Group / Allotment 查询 key |
| `confirmation_number` | 条件必填 | FIT Confirmation Number 查询 key |
| `reservation_no` | 条件必填 | OPERA reservation no当前系统无可靠表源只传该字段时会返回人工复核原因 |
| `object_type_hint` | 否 | 调用方推测的对象类型,只作为提示 |
| `target_key_source` | 否 | key 来源,例如 `body_current``body_thread_evidence` |
| `body_thread_used_only_as_evidence` | 否 | 历史线程 key 是否仅作为证据 |
`group_code``confirmation_number``reservation_no` 至少一个非空。当前稳定查询能力优先支持 `group_code``confirmation_number`
全局上下文查询只依赖 `hotel_id + 业务 key``source_message_id``source_event_index` 仅用于审计、追踪或排查,不作为查询边界。
### 3.3 成功响应
```json
{
"success": true,
"request_id": "req-001",
"trace_id": "trace-001",
"data": {
"matched_order_records": [],
"pending_or_open_tasks": [],
"active_workflows": [],
"terminated_records": [],
"target_object_validation": {
"status": "none",
"matched_object_id": null,
"matched_object_type": null,
"can_create_new_booking_task": true,
"can_create_update_task": false,
"can_create_cancel_task": false,
"can_attach_voucher": false,
"can_attach_rooming_list": false,
"needs_manual_review_reason": null
},
"key_relationships": {
"group_code_and_confirmation_same_object": null,
"relationship_evidence": ""
}
},
"warnings": [],
"error": null
}
```
### 3.4 主要数据来源
| 返回字段 | 来源 |
| --- | --- |
| `matched_order_records[]` | `workflow_reservation_order` |
| `pending_or_open_tasks[]` | `workflow_reservation_task` + `workflow_reservation_ai_transition` |
| `terminated_records[]` | 订单 `ENDED` / `LOGIC_DELETED`,任务 `FAILED` / `COMPLETED` |
| `active_workflows[]` | 当前无独立 workflow 表,固定空数组 |
## 4. 接口 2查询对象详情
### 4.1 请求
| 项目 | 内容 |
| --- | --- |
| Method | `POST` |
| URL | `{TH_HOTEL_API_BASE_URL}/api/ai-query/v1/object-detail` |
| request_path | `/api/ai-query/v1/object-detail` |
| Content-Type | `application/json` |
| 业务动作 | 只读查询对象详情,不创建任务、不修改订单、不写 OPERA |
### 4.2 请求体
```json
{
"hotel_id": "HOTEL-TEST",
"object_id": "ORDER:1900000000000000100",
"object_type": "group_block"
}
```
字段说明:
| 字段 | 是否必填 | 中文说明 |
| --- | --- | --- |
| `hotel_id` | 是 | 酒店上下文 ID |
| `object_id` | 是 | 查询对象 ID第一版只支持 `ORDER:{order_id}` |
| `object_type` | 否 | 调用方对象类型提示,第一版不作为强校验 |
### 4.3 成功响应
```json
{
"success": true,
"request_id": "req-002",
"trace_id": "trace-001",
"data": {
"object_id": "ORDER:1900000000000000100",
"object_type": "group_block",
"order_id": "1900000000000000100",
"order_key_type": "GROUP_CODE",
"group_code": "GRP-001",
"confirmation_number": null,
"reservation_no": null,
"block_id": null,
"temporary_order_code": "TMP-1900000000000000100",
"display_name": "GRP-001",
"status": "ACTIVE",
"source_message_id": "1900000000000000001",
"created_from_task_id": null,
"created_at": "2026-07-08T01:00:00",
"last_updated_at": "2026-07-08T01:10:00",
"arrival_date": null,
"departure_date": null,
"nights": null,
"guest_count": null,
"room_items": [],
"rate_code": null,
"rate_code_price": null,
"reservation_type": null,
"cancel_status": "not_cancelled",
"can_update": true,
"can_cancel": true,
"hard_validation_warnings": [
{
"code": "OPERA_PROJECTION_UNAVAILABLE",
"message": "当前系统尚未接入 OPERA 对象投影,日期、房型、房价等字段无法确认。"
}
]
},
"warnings": [],
"error": null
}
```
## 5. 接口 3SuperAgent 通知 AI 任务结果
### 5.1 请求
| 项目 | 内容 |
| --- | --- |
| Method | `POST` |
| URL | `{TH_HOTEL_API_BASE_URL}/api/integrations/superagent/task-results` |
| request_path | `/api/integrations/superagent/task-results` |
| Content-Type | `application/json` |
| 业务动作 | 接收 AI 任务结果,写入 AI 过渡层、订单、任务和任务卡 |
### 5.2 请求体
```json
{
"source_message_id": "1900000000000000001",
"ai_task_results": [
{
"source_event_index": 1,
"catalog_code": "S01",
"skill_id": "S01_new_booking_skill",
"result_type": "normal_task",
"task_type": "New Booking",
"task_subtype": "new_fit_reservation",
"current_or_history": "current",
"case_keys": {
"group_code": null,
"confirmation_number": null
},
"visible_reason": "邮件正文包含新建预订请求。",
"relevant_message_excerpt": "Please create a new booking...",
"attachments": [],
"file_references": [],
"context_used": {},
"extracted_fields": {},
"manual_review": null,
"informational_message": null,
"additional_operations": [],
"idempotency_key": null
}
],
"extraction_warnings": []
}
```
字段说明:
| 字段 | 是否必填 | 中文说明 |
| --- | --- | --- |
| `source_message_id` | 是 | 本系统 SourceMessage ID一次请求只能有一个 |
| `ai_task_results[]` | 是 | AI 拆分出的任务结果列表,必须保留数组顺序 |
| `ai_task_results[].source_event_index` | 是 | AI current 事件序号 |
| `ai_task_results[].catalog_code` | 是 | Skill 目录代码 |
| `ai_task_results[].skill_id` | 是 | Skill 标识 |
| `ai_task_results[].result_type` | 是 | `normal_task``manual_review``informational_message` |
| `ai_task_results[].task_type` | 是 | AI 原始任务类型 |
| `ai_task_results[].task_subtype` | 否 | 业务动作 subtype |
| `ai_task_results[].case_keys` | 否 | 订单关联候选键 |
| `ai_task_results[].extracted_fields` | 否 | 业务字段主体 |
### 5.3 成功响应
```json
{
"request_id": "req-003",
"source_message_id": "1900000000000000001",
"batch_id": "1900000000000000200",
"idempotent_replay": false,
"accepted_count": 1,
"items": [
{
"source_event_index": 1,
"array_index": 1,
"order_id": "1900000000000000300",
"task_id": "1900000000000000400",
"task_status": "PENDING_CONFIRM",
"order_status": "TEMPORARY",
"system_task_type": "NEW_BOOKING",
"task_card_type": "NEW_BOOKING",
"warnings": []
}
],
"warnings": []
}
```
## 6. 错误响应
### 6.1 查询接口错误响应
```json
{
"success": false,
"request_id": "req-001",
"trace_id": "trace-001",
"data": null,
"warnings": [],
"error": {
"code": "AUTH_SIGNATURE_INVALID",
"message": "签名校验失败。",
"details": {}
}
}
```
### 6.2 任务结果通知接口错误响应
```json
{
"request_id": null,
"error_code": "AUTH_SIGNATURE_INVALID",
"message": "签名校验失败。",
"details": []
}
```
### 6.3 常见错误码
| 错误码 | HTTP 状态 | 中文说明 |
| --- | --- | --- |
| `AUTH_HEADER_MISSING` | 401 | HMAC 必要 Header 缺失 |
| `AUTH_HEADER_INVALID` | 401 | HMAC Header 格式或长度无效 |
| `AUTH_TIMESTAMP_INVALID` | 401 | timestamp 格式错误或超出时间窗口 |
| `AUTH_SIGNATURE_INVALID` | 401 | 签名不匹配或服务端未配置 secret |
| `AUTH_NONCE_REPLAY` | 409 | nonce 已被使用 |
| `REQUEST_BODY_TOO_LARGE` | 413 | 请求体超过大小限制 |
| `REQUEST_CONTENT_TYPE_UNSUPPORTED` | 415 | `Content-Type` 不是 `application/json` |
| `REQUEST_BODY_INVALID` | 400 | JSON 不合法 |
| `QUERY_KEY_REQUIRED` | 400 | 查询接口缺少可用业务 key |
| `OBJECT_NOT_FOUND` | 404 | 对象详情查询目标不存在 |
| `SOURCE_MESSAGE_NOT_FOUND` | 400 | 任务结果通知引用的 SourceMessage 不存在 |
## 7. HMAC 上线配置
上线需要配置:
| 配置项 | 是否必填 | 中文说明 |
| --- | --- | --- |
| `SUPERAGENT_TASK_RESULT_HMAC_SECRET` | 是 | HMAC 共享密钥;查询接口和任务结果通知接口共用 |
| `SUPERAGENT_TASK_RESULT_CLOCK_SKEW_SECONDS` | 否 | 请求时间允许偏移,默认 `300` 秒 |
| `SUPERAGENT_TASK_RESULT_NONCE_TTL_SECONDS` | 否 | nonce 防重放保存时间,默认 `600` 秒 |
| `SUPERAGENT_TASK_RESULT_MAX_BODY_BYTES` | 否 | 请求体最大字节数,默认 `1048576` |
上线注意事项:
- 本系统和 SuperAgent 必须配置同一个 HMAC secret。
- secret 只能放在部署平台 Secret 或环境变量中,不能写入仓库、镜像、前端配置或普通文档。
- SuperAgent 必须使用原始请求体计算 SHA-256不能使用格式化后 JSON。
- HMAC canonical string 的第二行必须使用 `request_path`,例如 `/api/ai-query/v1/case-context`
- SuperAgent 每次请求必须生成全新的 nonce同一个 `client_id + nonce` 在 TTL 窗口内不能重复使用。
- 调用双方服务器时间必须同步,建议使用 NTP。
- 建议所有接口只暴露在 HTTPS 和可信网络边界内。
- 轮换 secret 时需要安排双写或短窗口切换;当前第一版后端只支持一个 secret轮换窗口内需要协调发布顺序。

View File

@@ -4,8 +4,8 @@
| 项目 | 内容 | | 项目 | 内容 |
| --- | --- | | --- | --- |
| 文档版本 | 0.1 | | 文档版本 | 0.2 |
| 日期 | 2026-07-07 | | 日期 | 2026-07-08 |
| 状态 | 第一版后端实现依据与落地记录 | | 状态 | 第一版后端实现依据与落地记录 |
| 适用范围 | SuperAgent / Main Agent 调用本系统查询订单和任务上下文 | | 适用范围 | SuperAgent / Main Agent 调用本系统查询订单和任务上下文 |
| 主要读者 | 后端、SuperAgent 对接方、测试、后续协作 agent | | 主要读者 | 后端、SuperAgent 对接方、测试、后续协作 agent |
@@ -32,7 +32,8 @@
- `body_current` 才能触发业务动作;`body_thread` 只能作为目标绑定证据。 - `body_current` 才能触发业务动作;`body_thread` 只能作为目标绑定证据。
- 如果查询 key 来自历史线程,调用方必须传 `target_key_source=body_thread_evidence``body_thread_used_only_as_evidence=true` - 如果查询 key 来自历史线程,调用方必须传 `target_key_source=body_thread_evidence``body_thread_used_only_as_evidence=true`
- 第一版不伪造 OPERA 字段。当前系统没有可靠来源的字段返回 `null`,并在 `warnings``hard_validation_warnings` 中说明。 - 第一版不伪造 OPERA 字段。当前系统没有可靠来源的字段返回 `null`,并在 `warnings``hard_validation_warnings` 中说明。
- 导入契约没有显式要求 `hotel_id`,但本系统订单、任务和 AI 过渡表均按 `hotel_id` 隔离。第一版建议请求体显式传 `hotel_id`;如果后续改为从鉴权或 `source_message_id` 解析酒店,需要在实现前统一 - SuperAgent 允许作为全局上下文查询方按任意业务 key 查询;`source_message_id``source_event_index` 只作为可选审计和排查字段,不作为查询边界
- 导入契约没有显式要求 `hotel_id`,但本系统订单、任务和 AI 过渡表均按 `hotel_id` 隔离。第一版请求体必须显式传 `hotel_id`
## 3. Skill 对接口 1、2 的实际需要 ## 3. Skill 对接口 1、2 的实际需要
@@ -58,14 +59,28 @@
### 4.1 通用请求头 ### 4.1 通用请求头
安全方向上建议后续复用 SuperAgent 服务到服务鉴权思路,具体签名规则可参考任务结果接收接口。当前已落地的最小字段版暂不启用 HMAC只强制 `X-Request-Id`,接口补签名规则前不得把该接口暴露到不可信网络 查询接口 1、2 已复用 SuperAgent 任务结果接收接口的 HMAC-SHA256 鉴权规则。签名规则、secret、timestamp 窗口、nonce 防重放和请求体大小配置与任务结果接收接口保持一致
| Header | 是否必填 | 中文说明 | | Header | 是否必填 | 中文说明 |
| --- | --- | --- | | --- | --- | --- |
| `Content-Type` | 是 | 固定 `application/json` | | `Content-Type` | 是 | 固定 `application/json` |
| `X-Request-Id` | 是 | 调用方生成的请求 ID用于日志串联 | | `X-TH-Hotel-SuperAgent-Client-Id` | 是 | SuperAgent 调用方客户端 ID |
| `X-AI-Trace-Id` | | AI 运行链路 ID | | `X-TH-Hotel-SuperAgent-Timestamp` | | UTC ISO-8601 时间 |
| `X-Source-Message-Id` | | 来源消息 ID便于排查 | | `X-TH-Hotel-SuperAgent-Nonce` | | 每次请求唯一随机值,用于防重放 |
| `X-TH-Hotel-SuperAgent-Signature` | 是 | HMAC 签名,格式 `sha256=<lowercase-hex>` |
| `X-TH-Hotel-Request-Id` | 否 | 调用方生成的请求 ID用于日志串联 |
| `X-TH-Hotel-AI-Trace-Id` | 否 | AI 运行链路 ID |
规范签名串:
```text
POST
<request_path>
<X-TH-Hotel-SuperAgent-Timestamp>
<X-TH-Hotel-SuperAgent-Nonce>
<X-TH-Hotel-SuperAgent-Client-Id>
<lowercase-hex-sha256-of-raw-body>
```
### 4.2 通用响应包 ### 4.2 通用响应包
@@ -126,8 +141,8 @@ POST /api/ai-query/v1/case-context
| 字段 | 是否必填 | 中文说明 | 当前系统来源或用途 | | 字段 | 是否必填 | 中文说明 | 当前系统来源或用途 |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| `hotel_id` | 是 | 酒店或业务上下文 ID | 用于隔离 `workflow_reservation_*` 表 | | `hotel_id` | 是 | 酒店或业务上下文 ID | 用于隔离 `workflow_reservation_*` 表 |
| `source_message_id` | | 当前 SourceMessage ID | 串联来源消息、AI 过渡记录和任务 | | `source_message_id` | | 当前 SourceMessage ID | 全局上下文查询可不传;传入时只做格式校验和排查辅助 |
| `source_event_index` | | 当前 current 事件序号 | 和 AI 拆分结果保持一致 | | `source_event_index` | | 当前 current 事件序号 | 全局上下文查询可不传;传入时必须为正整数 |
| `group_code` | 条件必填 | Group / Allotment 优先业务 key | 查询 `GROUP_CODE` 类型订单和 AI 过渡记录 | | `group_code` | 条件必填 | Group / Allotment 优先业务 key | 查询 `GROUP_CODE` 类型订单和 AI 过渡记录 |
| `confirmation_number` | 条件必填 | FIT 优先业务 key | 查询 `CONFIRMATION_NUMBER` 类型订单和 AI 过渡记录 | | `confirmation_number` | 条件必填 | FIT 优先业务 key | 查询 `CONFIRMATION_NUMBER` 类型订单和 AI 过渡记录 |
| `reservation_no` | 否 | OPERA reservation no | 当前无可靠表源,第一版不作为主查询条件 | | `reservation_no` | 否 | OPERA reservation no | 当前无可靠表源,第一版不作为主查询条件 |
@@ -440,10 +455,12 @@ POST /api/ai-query/v1/object-detail
已落地能力: 已落地能力:
- 接口 1 可按 `hotel_id + group_code``hotel_id + confirmation_number` 查询订单上下文。 - 接口 1 可按 `hotel_id + group_code``hotel_id + confirmation_number` 查询订单上下文,允许不传 `source_message_id``source_event_index` 的全局上下文查询
- 接口 1 返回 `matched_order_records``pending_or_open_tasks``active_workflows``terminated_records``target_object_validation``key_relationships` - 接口 1 返回 `matched_order_records``pending_or_open_tasks``active_workflows``terminated_records``target_object_validation``key_relationships`
- `active_workflows` 当前无独立表源,固定返回空数组。 - `active_workflows` 当前无独立表源,固定返回空数组。
- 接口 2 支持 `ORDER:{order_id}` 查询本系统订单快照。 - 接口 2 支持 `ORDER:{order_id}` 查询本系统订单快照。
- 查询接口 1、2 已启用与任务结果接收接口一致的 HMAC-SHA256 鉴权。
- 查询接口错误响应统一返回 `success=false` 包装,非法 JSON、非法 `Content-Type` 和鉴权错误不会暴露 Secret、签名原文或完整请求体。
- 对外 JSON 中内部长整型 ID 按字符串返回。 - 对外 JSON 中内部长整型 ID 按字符串返回。
- `reservation_no``block_id``room_items``rate_code_price` 等当前无可靠来源字段按本文约定返回 `null`、空数组或 warning。 - `reservation_no``block_id``room_items``rate_code_price` 等当前无可靠来源字段按本文约定返回 `null`、空数组或 warning。
@@ -451,6 +468,5 @@ POST /api/ai-query/v1/object-detail
- 接口 3 `query_file_parse_context` - 接口 3 `query_file_parse_context`
- 接口 4 `query_parent_task_context`,后续改为订单及其下面任务查询后再定义。 - 接口 4 `query_parent_task_context`,后续改为订单及其下面任务查询后再定义。
- SuperAgent 查询接口的 HMAC 鉴权。当前第一版只要求 `X-Request-Id` 作为请求追踪头。
- 附件解析、OCR、Excel、voucher、rooming list 解析。 - 附件解析、OCR、Excel、voucher、rooming list 解析。
- 真实 OPERA / OHIP 对象投影。 - 真实 OPERA / OHIP 对象投影。

View File

@@ -483,9 +483,9 @@ Fallback 处理规则:
## 18. 待确认问题 ## 18. 待确认问题
- SuperAgent 创建任务接口的 URL、Method、Header、鉴权和错误响应格式。 - SuperAgent 创建任务接口的 URL、Method、Header、鉴权和错误响应格式。
- SuperAgent 查询上下文接口 1、2 已实现最小字段版;接口 3 文件解析和接口 4 订单及任务查询仍需后续确认与实现,后续梳理未完成事项时必须持续提醒。 - SuperAgent 查询上下文接口 1、2 已实现最小字段版,并已启用与任务结果接收接口一致的 HMAC;接口 3 文件解析和接口 4 订单及任务查询仍需后续确认与实现,后续梳理未完成事项时必须持续提醒。
- `source_event_index`、批次 item index、`execution_order` 的最终编号规则是否都从 1 开始。 - `source_event_index`、批次 item index、`execution_order` 的最终编号规则是否都从 1 开始。
- SuperAgent 查询上下文接口 3、4 的 URL、入参、返回字段和鉴权方式;接口 1、2 后续是否补 HMAC 鉴权也需确认 - SuperAgent 查询上下文接口 3、4 的 URL、入参、返回字段和鉴权方式。
- `Message Notification` 是否需要在前端订单列表上单独标识为只读提醒。 - `Message Notification` 是否需要在前端订单列表上单独标识为只读提醒。
- OPERA 模拟结果中 Confirmation No.、Group Code、Block Code、Allotment Code 的具体字段路径。 - OPERA 模拟结果中 Confirmation No.、Group Code、Block Code、Allotment Code 的具体字段路径。
- 临时订单在无任务后是否立即逻辑删除,还是保留一段时间便于追溯。 - 临时订单在无任务后是否立即逻辑删除,还是保留一段时间便于追溯。

View File

@@ -6,8 +6,8 @@ import com.fasterxml.jackson.annotation.JsonProperty;
* SuperAgent 查询订单上下文请求。该请求只用于只读查询,不触发任务创建或 OPERA 写入。 * SuperAgent 查询订单上下文请求。该请求只用于只读查询,不触发任务创建或 OPERA 写入。
* *
* @param hotelId 酒店上下文 ID用于隔离订单、任务和 AI transition 数据 * @param hotelId 酒店上下文 ID用于隔离订单、任务和 AI transition 数据
* @param sourceMessageId 当前 SourceMessage ID外部以字符串传入避免长整型精度问题 * @param sourceMessageId 当前 SourceMessage ID全局上下文查询可不传
* @param sourceEventIndex 当前 AI 事件序号,用于和拆分结果保持一致 * @param sourceEventIndex 当前 AI 事件序号,全局上下文查询可不传,传入时必须为正整数
* @param groupCode Group Code / Allotment Code 查询 key * @param groupCode Group Code / Allotment Code 查询 key
* @param confirmationNumber Confirmation Number 查询 key * @param confirmationNumber Confirmation Number 查询 key
* @param reservationNo OPERA reservation no第一版无可靠表源仅参与入参完整性校验 * @param reservationNo OPERA reservation no第一版无可靠表源仅参与入参完整性校验

View File

@@ -1,13 +1,24 @@
package cn.nianxx.thhotel.workflows.reservation.control; package cn.nianxx.thhotel.workflows.reservation.control;
import cn.nianxx.thhotel.integrations.ai.superagent.common.request.SuperAgentTaskResultSecurityRequest;
import cn.nianxx.thhotel.integrations.ai.superagent.service.SuperAgentTaskResultSecurityService;
import cn.nianxx.thhotel.integrations.ai.superagent.service.impl.SuperAgentTaskResultException;
import cn.nianxx.thhotel.integrations.ai.superagent.service.impl.SuperAgentTaskResultProperties;
import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationAiCaseContextQueryRequest; import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationAiCaseContextQueryRequest;
import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationAiObjectDetailQueryRequest; import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationAiObjectDetailQueryRequest;
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationAiCaseContextResult; import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationAiCaseContextResult;
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationAiObjectDetailResult; import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationAiObjectDetailResult;
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationAiQueryResponse; import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationAiQueryResponse;
import cn.nianxx.thhotel.workflows.reservation.service.ReservationAiQueryService; import cn.nianxx.thhotel.workflows.reservation.service.ReservationAiQueryService;
import cn.nianxx.thhotel.workflows.reservation.service.impl.ReservationAiQueryException;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.nio.charset.StandardCharsets;
import java.util.List; import java.util.List;
import org.springframework.http.HttpStatus;
import org.springframework.http.InvalidMediaTypeException;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestHeader;
@@ -21,13 +32,26 @@ import org.springframework.web.bind.annotation.RestController;
@RequestMapping("/api/ai-query/v1") @RequestMapping("/api/ai-query/v1")
public class ReservationAiQueryController { public class ReservationAiQueryController {
private static final String CASE_CONTEXT_PATH = "/api/ai-query/v1/case-context";
private static final String OBJECT_DETAIL_PATH = "/api/ai-query/v1/object-detail";
private final ReservationAiQueryService aiQueryService; private final ReservationAiQueryService aiQueryService;
private final SuperAgentTaskResultSecurityService securityService;
private final SuperAgentTaskResultProperties securityProperties;
private final ObjectMapper objectMapper;
/** /**
* 注入只读查询服务Controller 负责请求响应契约映射 * 注入只读查询服务、安全服务和 JSON 解析器Controller 负责先鉴权再解析请求。
*/ */
public ReservationAiQueryController(ReservationAiQueryService aiQueryService) { public ReservationAiQueryController(
ReservationAiQueryService aiQueryService,
SuperAgentTaskResultSecurityService securityService,
SuperAgentTaskResultProperties securityProperties,
ObjectMapper objectMapper) {
this.aiQueryService = aiQueryService; this.aiQueryService = aiQueryService;
this.securityService = securityService;
this.securityProperties = securityProperties;
this.objectMapper = objectMapper;
} }
/** /**
@@ -35,14 +59,28 @@ public class ReservationAiQueryController {
*/ */
@PostMapping( @PostMapping(
value = "/case-context", value = "/case-context",
consumes = MediaType.APPLICATION_JSON_VALUE,
produces = MediaType.APPLICATION_JSON_VALUE) produces = MediaType.APPLICATION_JSON_VALUE)
public ReservationAiQueryResponse<ReservationAiCaseContextResult> queryCaseContext( public ResponseEntity<ReservationAiQueryResponse<ReservationAiCaseContextResult>> queryCaseContext(
@RequestHeader("X-Request-Id") String requestId, @RequestBody(required = false) String rawBody,
@RequestHeader(value = "Content-Type", required = false) String contentType,
@RequestHeader(name = "X-TH-Hotel-SuperAgent-Client-Id", required = false) String clientId,
@RequestHeader(name = "X-TH-Hotel-SuperAgent-Timestamp", required = false) String timestamp,
@RequestHeader(name = "X-TH-Hotel-SuperAgent-Nonce", required = false) String nonce,
@RequestHeader(name = "X-TH-Hotel-SuperAgent-Signature", required = false) String signature,
@RequestHeader(value = "X-TH-Hotel-Request-Id", required = false) String requestId,
@RequestHeader(value = "X-Request-Id", required = false) String legacyRequestId,
@RequestHeader(value = "X-AI-Trace-Id", required = false) String traceId, @RequestHeader(value = "X-AI-Trace-Id", required = false) String traceId,
@RequestBody(required = false) ReservationAiCaseContextQueryRequest request) { @RequestHeader(value = "X-TH-Hotel-AI-Trace-Id", required = false) String thHotelTraceId) {
String requestBody = rawBody == null ? "" : rawBody;
verifyHmac(CASE_CONTEXT_PATH, clientId, timestamp, nonce, signature, requestBody);
requireJsonContentType(contentType);
ReservationAiCaseContextQueryRequest request = readBody(requestBody, ReservationAiCaseContextQueryRequest.class);
ReservationAiCaseContextResult result = aiQueryService.queryCaseContext(request); ReservationAiCaseContextResult result = aiQueryService.queryCaseContext(request);
return ReservationAiQueryResponse.success(requestId, traceId, result, List.of()); return ResponseEntity.ok(ReservationAiQueryResponse.success(
firstText(requestId, legacyRequestId),
firstText(thHotelTraceId, traceId),
result,
List.of()));
} }
/** /**
@@ -50,13 +88,118 @@ public class ReservationAiQueryController {
*/ */
@PostMapping( @PostMapping(
value = "/object-detail", value = "/object-detail",
consumes = MediaType.APPLICATION_JSON_VALUE,
produces = MediaType.APPLICATION_JSON_VALUE) produces = MediaType.APPLICATION_JSON_VALUE)
public ReservationAiQueryResponse<ReservationAiObjectDetailResult> queryObjectDetail( public ResponseEntity<ReservationAiQueryResponse<ReservationAiObjectDetailResult>> queryObjectDetail(
@RequestHeader("X-Request-Id") String requestId, @RequestBody(required = false) String rawBody,
@RequestHeader(value = "Content-Type", required = false) String contentType,
@RequestHeader(name = "X-TH-Hotel-SuperAgent-Client-Id", required = false) String clientId,
@RequestHeader(name = "X-TH-Hotel-SuperAgent-Timestamp", required = false) String timestamp,
@RequestHeader(name = "X-TH-Hotel-SuperAgent-Nonce", required = false) String nonce,
@RequestHeader(name = "X-TH-Hotel-SuperAgent-Signature", required = false) String signature,
@RequestHeader(value = "X-TH-Hotel-Request-Id", required = false) String requestId,
@RequestHeader(value = "X-Request-Id", required = false) String legacyRequestId,
@RequestHeader(value = "X-AI-Trace-Id", required = false) String traceId, @RequestHeader(value = "X-AI-Trace-Id", required = false) String traceId,
@RequestBody(required = false) ReservationAiObjectDetailQueryRequest request) { @RequestHeader(value = "X-TH-Hotel-AI-Trace-Id", required = false) String thHotelTraceId) {
String requestBody = rawBody == null ? "" : rawBody;
verifyHmac(OBJECT_DETAIL_PATH, clientId, timestamp, nonce, signature, requestBody);
requireJsonContentType(contentType);
ReservationAiObjectDetailQueryRequest request = readBody(requestBody, ReservationAiObjectDetailQueryRequest.class);
ReservationAiObjectDetailResult result = aiQueryService.queryObjectDetail(request); ReservationAiObjectDetailResult result = aiQueryService.queryObjectDetail(request);
return ReservationAiQueryResponse.success(requestId, traceId, result, List.of()); return ResponseEntity.ok(ReservationAiQueryResponse.success(
firstText(requestId, legacyRequestId),
firstText(thHotelTraceId, traceId),
result,
List.of()));
}
/**
* 使用任务结果接收接口同一套 HMAC 规则校验查询请求,校验通过后才允许解析业务 JSON。
*/
private void verifyHmac(
String requestPath,
String clientId,
String timestamp,
String nonce,
String signature,
String requestBody) {
rejectBodyWhenTooLarge(requestBody);
securityService.verify(new SuperAgentTaskResultSecurityRequest(
"POST",
requestPath,
clientId,
timestamp,
nonce,
signature,
requestBody
));
}
/**
* 限制查询接口请求体大小,避免鉴权前后处理超大外部输入。
*/
private void rejectBodyWhenTooLarge(String rawBody) {
long maxBodyBytes = securityProperties.getMaxBodyBytes();
int actualBytes = rawBody.getBytes(StandardCharsets.UTF_8).length;
if (maxBodyBytes >= 0 && actualBytes > maxBodyBytes) {
throw new SuperAgentTaskResultException(
HttpStatus.PAYLOAD_TOO_LARGE,
"REQUEST_BODY_TOO_LARGE",
"请求体超过允许大小。");
}
}
/**
* 手动校验 JSON Content-Type确保协议错误也使用查询接口统一错误包。
*/
private void requireJsonContentType(String contentType) {
String text = firstText(contentType, null);
if (text == null) {
throw unsupportedContentType();
}
try {
MediaType mediaType = MediaType.parseMediaType(text);
if (!MediaType.APPLICATION_JSON.isCompatibleWith(mediaType)) {
throw unsupportedContentType();
}
} catch (InvalidMediaTypeException exception) {
throw unsupportedContentType();
}
}
/**
* 构造 Content-Type 错误,避免外部协议错误落到 Spring 默认错误结构。
*/
private ReservationAiQueryException unsupportedContentType() {
return new ReservationAiQueryException(
HttpStatus.UNSUPPORTED_MEDIA_TYPE,
"REQUEST_CONTENT_TYPE_UNSUPPORTED",
"Content-Type 必须是 application/json。");
}
/**
* 解析已通过鉴权的 JSON 请求体,失败时返回查询接口统一错误响应。
*/
private <T> T readBody(String rawBody, Class<T> requestType) {
try {
return objectMapper.readValue(rawBody, requestType);
} catch (JsonProcessingException exception) {
throw new ReservationAiQueryException(
HttpStatus.BAD_REQUEST,
"REQUEST_BODY_INVALID",
"请求体 JSON 不合法。");
}
}
/**
* 取第一个非空白文本,兼容新旧追踪 Header。
*/
private String firstText(String first, String second) {
if (first != null && !first.isBlank()) {
return first;
}
if (second != null && !second.isBlank()) {
return second;
}
return null;
} }
} }

View File

@@ -1,12 +1,14 @@
package cn.nianxx.thhotel.workflows.reservation.control; package cn.nianxx.thhotel.workflows.reservation.control;
import cn.nianxx.thhotel.integrations.ai.superagent.service.impl.SuperAgentTaskResultException;
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationAiQueryErrorResult; import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationAiQueryErrorResult;
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationAiQueryResponse; import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationAiQueryResponse;
import cn.nianxx.thhotel.workflows.reservation.service.impl.ReservationAiQueryException; import cn.nianxx.thhotel.workflows.reservation.service.impl.ReservationAiQueryException;
import jakarta.servlet.http.HttpServletRequest;
import java.util.Map;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MissingRequestHeaderException; import org.springframework.web.bind.MissingRequestHeaderException;
import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RestControllerAdvice; import org.springframework.web.bind.annotation.RestControllerAdvice;
/** /**
@@ -21,16 +23,28 @@ public class ReservationAiQueryControllerAdvice {
@ExceptionHandler(ReservationAiQueryException.class) @ExceptionHandler(ReservationAiQueryException.class)
public ResponseEntity<ReservationAiQueryResponse<Object>> handleAiQueryException( public ResponseEntity<ReservationAiQueryResponse<Object>> handleAiQueryException(
ReservationAiQueryException exception, ReservationAiQueryException exception,
@RequestHeader(value = "X-Request-Id", required = false) String requestId, HttpServletRequest request) {
@RequestHeader(value = "X-AI-Trace-Id", required = false) String traceId) { return failure(
return ResponseEntity.status(exception.getStatus()) exception.getStatus().value(),
.body(ReservationAiQueryResponse.failure( request,
requestId, exception.getErrorCode(),
traceId, exception.getMessage(),
new ReservationAiQueryErrorResult( exception.getDetails());
exception.getErrorCode(), }
exception.getMessage(),
exception.getDetails()))); /**
* 处理 HMAC、时间窗口、nonce 和请求体大小等协议层错误。
*/
@ExceptionHandler(SuperAgentTaskResultException.class)
public ResponseEntity<ReservationAiQueryResponse<Object>> handleSecurityException(
SuperAgentTaskResultException exception,
HttpServletRequest request) {
return failure(
exception.getStatus().value(),
request,
exception.getErrorCode(),
exception.getMessage(),
Map.of());
} }
/** /**
@@ -39,14 +53,43 @@ public class ReservationAiQueryControllerAdvice {
@ExceptionHandler(MissingRequestHeaderException.class) @ExceptionHandler(MissingRequestHeaderException.class)
public ResponseEntity<ReservationAiQueryResponse<Object>> handleMissingHeader( public ResponseEntity<ReservationAiQueryResponse<Object>> handleMissingHeader(
MissingRequestHeaderException exception, MissingRequestHeaderException exception,
@RequestHeader(value = "X-AI-Trace-Id", required = false) String traceId) { HttpServletRequest request) {
return ResponseEntity.badRequest() return failure(
400,
request,
"REQUEST_HEADER_REQUIRED",
exception.getHeaderName() + " 请求头不能为空",
Map.of());
}
/**
* 构造查询接口统一失败响应,异常路径只返回安全错误码和必要追踪字段。
*/
private ResponseEntity<ReservationAiQueryResponse<Object>> failure(
int status,
HttpServletRequest request,
String code,
String message,
Map<String, Object> details) {
return ResponseEntity.status(status)
.body(ReservationAiQueryResponse.failure( .body(ReservationAiQueryResponse.failure(
null, firstHeader(request, "X-TH-Hotel-Request-Id", "X-Request-Id"),
traceId, firstHeader(request, "X-TH-Hotel-AI-Trace-Id", "X-AI-Trace-Id"),
new ReservationAiQueryErrorResult( new ReservationAiQueryErrorResult(code, message, details)));
"REQUEST_HEADER_REQUIRED", }
exception.getHeaderName() + " 请求头不能为空",
java.util.Map.of()))); /**
* 读取第一个非空 Header兼容旧查询接口追踪头和新的 SuperAgent 统一追踪头。
*/
private String firstHeader(HttpServletRequest request, String first, String second) {
String firstValue = request.getHeader(first);
if (firstValue != null && !firstValue.isBlank()) {
return firstValue;
}
String secondValue = request.getHeader(second);
if (secondValue != null && !secondValue.isBlank()) {
return secondValue;
}
return null;
} }
} }

View File

@@ -293,8 +293,10 @@ public class ReservationAiQueryServiceImpl implements ReservationAiQueryService
throw badRequest("MISSING_REQUEST_BODY", "请求体不能为空"); throw badRequest("MISSING_REQUEST_BODY", "请求体不能为空");
} }
requireText(request.hotelId(), "HOTEL_ID_REQUIRED", "hotel_id 不能为空"); requireText(request.hotelId(), "HOTEL_ID_REQUIRED", "hotel_id 不能为空");
parseLong(request.sourceMessageId(), "SOURCE_MESSAGE_ID_INVALID", "source_message_id 必须是数字字符串"); if (trimToNull(request.sourceMessageId()) != null) {
if (request.sourceEventIndex() == null || request.sourceEventIndex() <= 0) { parseLong(request.sourceMessageId(), "SOURCE_MESSAGE_ID_INVALID", "source_message_id 必须是数字字符串");
}
if (request.sourceEventIndex() != null && request.sourceEventIndex() <= 0) {
throw badRequest("SOURCE_EVENT_INDEX_INVALID", "source_event_index 必须是正整数"); throw badRequest("SOURCE_EVENT_INDEX_INVALID", "source_event_index 必须是正整数");
} }
if (trimToNull(request.groupCode()) == null if (trimToNull(request.groupCode()) == null

View File

@@ -12,8 +12,13 @@ import cn.nianxx.thhotel.ThHotelApplication;
import cn.nianxx.thhotel.platform.message.common.request.CaptureSourceMessageCommand; import cn.nianxx.thhotel.platform.message.common.request.CaptureSourceMessageCommand;
import cn.nianxx.thhotel.platform.message.common.result.SourceMessageCaptureResult; import cn.nianxx.thhotel.platform.message.common.result.SourceMessageCaptureResult;
import cn.nianxx.thhotel.platform.message.service.SourceMessageCaptureService; import cn.nianxx.thhotel.platform.message.service.SourceMessageCaptureService;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.Instant; import java.time.Instant;
import java.util.HexFormat;
import java.util.List; import java.util.List;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
@@ -22,13 +27,25 @@ import org.springframework.http.MediaType;
import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.context.ActiveProfiles; import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;
@SpringBootTest(classes = ThHotelApplication.class) @SpringBootTest(
classes = ThHotelApplication.class,
properties = {
"superagent.task-result.hmac-secret=test-superagent-secret",
"superagent.task-result.clock-skew-seconds=300",
"superagent.task-result.nonce-ttl-seconds=600",
"superagent.task-result.max-body-bytes=12000"
})
@AutoConfigureMockMvc @AutoConfigureMockMvc
@ActiveProfiles("test") @ActiveProfiles("test")
class ReservationAiQueryControllerTest { class ReservationAiQueryControllerTest {
private static final String HOTEL_ID = "HOTEL-TEST"; private static final String HOTEL_ID = "HOTEL-TEST";
private static final String CASE_CONTEXT_ENDPOINT = "/api/ai-query/v1/case-context";
private static final String OBJECT_DETAIL_ENDPOINT = "/api/ai-query/v1/object-detail";
private static final String CLIENT_ID = "superagent-test-client";
private static final String SECRET = "test-superagent-secret";
@Autowired @Autowired
private MockMvc mockMvc; private MockMvc mockMvc;
@@ -46,11 +63,7 @@ class ReservationAiQueryControllerTest {
insertTransition(920000000000000201L, source.inboxId(), 1, "GRP-AIQUERY-001"); insertTransition(920000000000000201L, source.inboxId(), 1, "GRP-AIQUERY-001");
insertTask(920000000000000301L, 920000000000000101L, source.inboxId(), 920000000000000201L, "PENDING_CONFIRM"); insertTask(920000000000000301L, 920000000000000101L, source.inboxId(), 920000000000000201L, "PENDING_CONFIRM");
mockMvc.perform(post("/api/ai-query/v1/case-context") String body = """
.contentType(MediaType.APPLICATION_JSON)
.header("X-Request-Id", "req-ai-query-case-001")
.header("X-AI-Trace-Id", "trace-ai-query-case-001")
.content("""
{ {
"hotel_id": "HOTEL-TEST", "hotel_id": "HOTEL-TEST",
"source_message_id": "%s", "source_message_id": "%s",
@@ -59,7 +72,10 @@ class ReservationAiQueryControllerTest {
"target_key_source": "body_current", "target_key_source": "body_current",
"body_thread_used_only_as_evidence": false "body_thread_used_only_as_evidence": false
} }
""".formatted(source.inboxId()))) """.formatted(source.inboxId());
mockMvc.perform(signedPost(CASE_CONTEXT_ENDPOINT, body, "nonce-ai-query-case-001", "req-ai-query-case-001")
.header("X-AI-Trace-Id", "trace-ai-query-case-001"))
.andExpect(status().isOk()) .andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(true)) .andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.request_id").value("req-ai-query-case-001")) .andExpect(jsonPath("$.request_id").value("req-ai-query-case-001"))
@@ -85,17 +101,16 @@ class ReservationAiQueryControllerTest {
void shouldReturnSuccessfulEmptyCaseContextWhenNoObjectMatches() throws Exception { void shouldReturnSuccessfulEmptyCaseContextWhenNoObjectMatches() throws Exception {
SourceMessageCaptureResult source = captureSourceMessage("mail-ai-query-empty-001"); SourceMessageCaptureResult source = captureSourceMessage("mail-ai-query-empty-001");
mockMvc.perform(post("/api/ai-query/v1/case-context") String body = """
.contentType(MediaType.APPLICATION_JSON)
.header("X-Request-Id", "req-ai-query-empty-001")
.content("""
{ {
"hotel_id": "HOTEL-TEST", "hotel_id": "HOTEL-TEST",
"source_message_id": "%s", "source_message_id": "%s",
"source_event_index": 1, "source_event_index": 1,
"group_code": "GRP-AIQUERY-NOT-FOUND" "group_code": "GRP-AIQUERY-NOT-FOUND"
} }
""".formatted(source.inboxId()))) """.formatted(source.inboxId());
mockMvc.perform(signedPost(CASE_CONTEXT_ENDPOINT, body, "nonce-ai-query-empty-001", "req-ai-query-empty-001"))
.andExpect(status().isOk()) .andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(true)) .andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.matched_order_records.length()").value(0)) .andExpect(jsonPath("$.data.matched_order_records.length()").value(0))
@@ -110,17 +125,17 @@ class ReservationAiQueryControllerTest {
void shouldNotAllowCreationWhenOnlyUnsupportedReservationNoProvided() throws Exception { void shouldNotAllowCreationWhenOnlyUnsupportedReservationNoProvided() throws Exception {
SourceMessageCaptureResult source = captureSourceMessage("mail-ai-query-reservation-no-001"); SourceMessageCaptureResult source = captureSourceMessage("mail-ai-query-reservation-no-001");
mockMvc.perform(post("/api/ai-query/v1/case-context") String body = """
.contentType(MediaType.APPLICATION_JSON)
.header("X-Request-Id", "req-ai-query-reservation-no-001")
.content("""
{ {
"hotel_id": "HOTEL-TEST", "hotel_id": "HOTEL-TEST",
"source_message_id": "%s", "source_message_id": "%s",
"source_event_index": 1, "source_event_index": 1,
"reservation_no": "RESV-AIQUERY-001" "reservation_no": "RESV-AIQUERY-001"
} }
""".formatted(source.inboxId()))) """.formatted(source.inboxId());
mockMvc.perform(signedPost(CASE_CONTEXT_ENDPOINT, body, "nonce-ai-query-reservation-no-001",
"req-ai-query-reservation-no-001"))
.andExpect(status().isOk()) .andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(true)) .andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.matched_order_records.length()").value(0)) .andExpect(jsonPath("$.data.matched_order_records.length()").value(0))
@@ -131,22 +146,104 @@ class ReservationAiQueryControllerTest {
.value("UNSUPPORTED_RESERVATION_NO_QUERY")); .value("UNSUPPORTED_RESERVATION_NO_QUERY"));
} }
@Test
void shouldAllowGlobalCaseContextQueryWithoutSourceMessageAndEventIndex() throws Exception {
SourceMessageCaptureResult source = captureSourceMessage("mail-ai-query-global-001");
insertActiveGroupOrder(920000000000000501L, source.inboxId(), "GRP-AIQUERY-GLOBAL-001");
String body = """
{
"hotel_id": "HOTEL-TEST",
"group_code": "GRP-AIQUERY-GLOBAL-001"
}
""";
mockMvc.perform(signedPost(CASE_CONTEXT_ENDPOINT, body, "nonce-ai-query-global-001",
"req-ai-query-global-001"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.matched_order_records[0].object_id").value("ORDER:920000000000000501"))
.andExpect(jsonPath("$.data.target_object_validation.status").value("single"));
}
@Test
void shouldRejectCaseContextWhenHmacSignatureInvalid() throws Exception {
String body = """
{
"hotel_id": "HOTEL-TEST",
"source_event_index": 1,
"group_code": "GRP-AIQUERY-HMAC-INVALID"
}
""";
mockMvc.perform(post(CASE_CONTEXT_ENDPOINT)
.contentType(MediaType.APPLICATION_JSON)
.header("X-TH-Hotel-Request-Id", "req-ai-query-hmac-invalid")
.header("X-TH-Hotel-SuperAgent-Client-Id", CLIENT_ID)
.header("X-TH-Hotel-SuperAgent-Timestamp", Instant.now().toString())
.header("X-TH-Hotel-SuperAgent-Nonce", "nonce-ai-query-hmac-invalid")
.header("X-TH-Hotel-SuperAgent-Signature", "sha256=invalid")
.content(body))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.success").value(false))
.andExpect(jsonPath("$.error.code").value("AUTH_SIGNATURE_INVALID"))
.andExpect(content().string(not(containsString(SECRET))));
}
@Test
void shouldReturnUnifiedErrorWhenCaseContextJsonInvalid() throws Exception {
String body = "{\"hotel_id\":";
mockMvc.perform(signedPost(CASE_CONTEXT_ENDPOINT, body, "nonce-ai-query-invalid-json",
"req-ai-query-invalid-json"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.success").value(false))
.andExpect(jsonPath("$.request_id").value("req-ai-query-invalid-json"))
.andExpect(jsonPath("$.error.code").value("REQUEST_BODY_INVALID"))
.andExpect(content().string(not(containsString(SECRET))));
}
@Test
void shouldReturnUnifiedErrorWhenCaseContextContentTypeUnsupported() throws Exception {
String body = """
{
"hotel_id": "HOTEL-TEST",
"group_code": "GRP-AIQUERY-CONTENT-TYPE"
}
""";
String timestamp = Instant.now().toString();
mockMvc.perform(post(CASE_CONTEXT_ENDPOINT)
.contentType(MediaType.TEXT_PLAIN)
.header("X-TH-Hotel-Request-Id", "req-ai-query-content-type")
.header("X-TH-Hotel-SuperAgent-Client-Id", CLIENT_ID)
.header("X-TH-Hotel-SuperAgent-Timestamp", timestamp)
.header("X-TH-Hotel-SuperAgent-Nonce", "nonce-ai-query-content-type")
.header("X-TH-Hotel-SuperAgent-Signature",
signature(CASE_CONTEXT_ENDPOINT, body, "nonce-ai-query-content-type", timestamp))
.content(body))
.andExpect(status().isUnsupportedMediaType())
.andExpect(jsonPath("$.success").value(false))
.andExpect(jsonPath("$.request_id").value("req-ai-query-content-type"))
.andExpect(jsonPath("$.error.code").value("REQUEST_CONTENT_TYPE_UNSUPPORTED"))
.andExpect(content().string(not(containsString(SECRET))));
}
@Test @Test
void shouldReturnObjectDetailWithNullableOperaProjectionWarning() throws Exception { void shouldReturnObjectDetailWithNullableOperaProjectionWarning() throws Exception {
SourceMessageCaptureResult source = captureSourceMessage("mail-ai-query-detail-001"); SourceMessageCaptureResult source = captureSourceMessage("mail-ai-query-detail-001");
insertActiveGroupOrder(920000000000000401L, source.inboxId(), "GRP-AIQUERY-DETAIL-001"); insertActiveGroupOrder(920000000000000401L, source.inboxId(), "GRP-AIQUERY-DETAIL-001");
mockMvc.perform(post("/api/ai-query/v1/object-detail") String body = """
.contentType(MediaType.APPLICATION_JSON)
.header("X-Request-Id", "req-ai-query-detail-001")
.header("X-AI-Trace-Id", "trace-ai-query-detail-001")
.content("""
{ {
"hotel_id": "HOTEL-TEST", "hotel_id": "HOTEL-TEST",
"object_id": "ORDER:920000000000000401", "object_id": "ORDER:920000000000000401",
"object_type": "group_block" "object_type": "group_block"
} }
""")) """;
mockMvc.perform(signedPost(OBJECT_DETAIL_ENDPOINT, body, "nonce-ai-query-detail-001",
"req-ai-query-detail-001")
.header("X-AI-Trace-Id", "trace-ai-query-detail-001"))
.andExpect(status().isOk()) .andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(true)) .andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.object_id").value("ORDER:920000000000000401")) .andExpect(jsonPath("$.data.object_id").value("ORDER:920000000000000401"))
@@ -162,6 +259,31 @@ class ReservationAiQueryControllerTest {
.andExpect(jsonPath("$.data.hard_validation_warnings[0].code").value("OPERA_PROJECTION_UNAVAILABLE")); .andExpect(jsonPath("$.data.hard_validation_warnings[0].code").value("OPERA_PROJECTION_UNAVAILABLE"));
} }
@Test
void shouldRejectObjectDetailWhenHmacSignatureInvalid() throws Exception {
String body = """
{
"hotel_id": "HOTEL-TEST",
"object_id": "ORDER:920000000000000401",
"object_type": "group_block"
}
""";
mockMvc.perform(post(OBJECT_DETAIL_ENDPOINT)
.contentType(MediaType.APPLICATION_JSON)
.header("X-TH-Hotel-Request-Id", "req-ai-query-detail-hmac-invalid")
.header("X-TH-Hotel-SuperAgent-Client-Id", CLIENT_ID)
.header("X-TH-Hotel-SuperAgent-Timestamp", Instant.now().toString())
.header("X-TH-Hotel-SuperAgent-Nonce", "nonce-ai-query-detail-hmac-invalid")
.header("X-TH-Hotel-SuperAgent-Signature", "sha256=invalid")
.content(body))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.success").value(false))
.andExpect(jsonPath("$.request_id").value("req-ai-query-detail-hmac-invalid"))
.andExpect(jsonPath("$.error.code").value("AUTH_SIGNATURE_INVALID"))
.andExpect(content().string(not(containsString(SECRET))));
}
private SourceMessageCaptureResult captureSourceMessage(String externalMessageId) { private SourceMessageCaptureResult captureSourceMessage(String externalMessageId) {
return captureService.capture(new CaptureSourceMessageCommand( return captureService.capture(new CaptureSourceMessageCommand(
HOTEL_ID, HOTEL_ID,
@@ -224,4 +346,28 @@ class ReservationAiQueryControllerTest {
CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
""", taskId, HOTEL_ID, orderId, sourceMessageId, transitionId, taskStatus); """, taskId, HOTEL_ID, orderId, sourceMessageId, transitionId, taskStatus);
} }
private MockHttpServletRequestBuilder signedPost(String endpoint, String body, String nonce, String requestId) throws Exception {
String timestamp = Instant.now().toString();
return post(endpoint)
.contentType(MediaType.APPLICATION_JSON)
.header("X-TH-Hotel-Request-Id", requestId)
.header("X-TH-Hotel-SuperAgent-Client-Id", CLIENT_ID)
.header("X-TH-Hotel-SuperAgent-Timestamp", timestamp)
.header("X-TH-Hotel-SuperAgent-Nonce", nonce)
.header("X-TH-Hotel-SuperAgent-Signature", signature(endpoint, body, nonce, timestamp))
.content(body);
}
private String signature(String endpoint, String body, String nonce, String timestamp) throws Exception {
String canonical = "POST\n" + endpoint + "\n" + timestamp + "\n" + nonce + "\n" + CLIENT_ID + "\n" + sha256(body);
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(SECRET.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
return "sha256=" + HexFormat.of().formatHex(mac.doFinal(canonical.getBytes(StandardCharsets.UTF_8)));
}
private String sha256(String body) throws Exception {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
return HexFormat.of().formatHex(digest.digest(body.getBytes(StandardCharsets.UTF_8)));
}
} }