From fb82386fdb498d9b3a10cfcb554696a8efc969df Mon Sep 17 00:00:00 2001 From: andy Date: Wed, 8 Jul 2026 10:10:00 +0800 Subject: [PATCH] =?UTF-8?q?=E7=BB=9F=E4=B8=80=20SuperAgent=20=E6=9F=A5?= =?UTF-8?q?=E8=AF=A2=E6=8E=A5=E5=8F=A3=E9=89=B4=E6=9D=83=E5=A5=91=E7=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/project/README.md | 1 + docs/project/go-live-notes.md | 18 +- .../integrations/superagent-api-contract.md | 393 ++++++++++++++++++ .../M002-ai-query-minimal-fields.md | 38 +- .../M002-order-task-workflow-v2.md | 4 +- .../ReservationAiCaseContextQueryRequest.java | 4 +- .../control/ReservationAiQueryController.java | 167 +++++++- .../ReservationAiQueryControllerAdvice.java | 81 +++- .../impl/ReservationAiQueryServiceImpl.java | 6 +- .../ReservationAiQueryControllerTest.java | 192 ++++++++- 10 files changed, 832 insertions(+), 72 deletions(-) create mode 100644 docs/project/integrations/superagent-api-contract.md diff --git a/docs/project/README.md b/docs/project/README.md index 5b1394e..282181e 100644 --- a/docs/project/README.md +++ b/docs/project/README.md @@ -16,3 +16,4 @@ - `requirements/M002-backend-data-model-design.md`:M002 后端数据模型设计,记录 AI 过渡层、订单、任务、任务卡、审计和 OPERA 模拟结果表。 - `requirements/M002-backend-checkpoint-plan.md`:M002 后端 checkpoint 计划,记录后端实现拆分、交付物和验收标准。 - `integrations/superagent-agentbus-project-integration-guide.md`:当前项目 SuperAgent 与 AgentBus 验证记录和项目级接入细节。 +- `integrations/superagent-api-contract.md`:给 SuperAgent 对接方使用的接口契约,记录查询上下文、对象详情、任务结果通知和统一 HMAC 规则。 diff --git a/docs/project/go-live-notes.md b/docs/project/go-live-notes.md index 9bee525..d64d21c 100644 --- a/docs/project/go-live-notes.md +++ b/docs/project/go-live-notes.md @@ -16,6 +16,7 @@ - Reservation 任务详情接口:返回任务字段、队列可处理状态和 OPERA 模拟操作摘要。 - Reservation 任务草稿保存和最终确认接口:按任务卡矩阵做第一版后端校验,确认后生成 `confirmed_payload_json`。 - Reservation OPERA 模拟骨架:已确认任务固定生成两条模拟操作,支持执行、失败重试、attempt 记录和任务审计列表。 +- SuperAgent 查询上下文接口 1、2:支持 HMAC 鉴权的订单上下文查询和对象详情查询。 当前不要把以下能力当作已上线: @@ -24,7 +25,7 @@ - 自动 ACK、`task.result` 或客户回复。 - 业务前端页面展示邮件原文。 - OHIP / OPERA 或其他业务系统真实写操作。 -- SuperAgent 查询上下文接口。 +- SuperAgent 查询上下文接口 3、4。 - 普通任务切换订单接口。 - 用户身份、权限和真实审计 actor。 @@ -82,6 +83,21 @@ - `AGENTBUS_CAPTURE_ENABLED=false` 时,业务 frame 会被忽略,不会写入 SourceMessage Inbox。 - 当前实现不发送 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 secret,secret 轮换需要协调部署窗口。 + ## 4. 数据库上线注意事项 当前 SourceMessage 相关 migration: diff --git a/docs/project/integrations/superagent-api-contract.md b/docs/project/integrations/superagent-api-contract.md new file mode 100644 index 0000000..c9a2608 --- /dev/null +++ b/docs/project/integrations/superagent-api-contract.md @@ -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=` | +| `X-TH-Hotel-Request-Id` | 否 | 调用方请求 ID,用于日志串联 | +| `X-TH-Hotel-AI-Trace-Id` | 否 | AI 运行链路 ID,查询接口会原样带回 `trace_id` | + +### 2.2 签名串 + +签名串使用接口 path,不包含域名、query string 或 fragment。 + +```text +POST + + + + + +``` + +签名算法: + +```text +signature = HMAC_SHA256(SUPERAGENT_TASK_RESULT_HMAC_SECRET, canonical_string) +``` + +Header 写法: + +```text +X-TH-Hotel-SuperAgent-Signature: sha256= +``` + +### 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. 接口 3:SuperAgent 通知 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,轮换窗口内需要协调发布顺序。 diff --git a/docs/project/requirements/M002-ai-query-minimal-fields.md b/docs/project/requirements/M002-ai-query-minimal-fields.md index 88df401..c1226c6 100644 --- a/docs/project/requirements/M002-ai-query-minimal-fields.md +++ b/docs/project/requirements/M002-ai-query-minimal-fields.md @@ -4,8 +4,8 @@ | 项目 | 内容 | | --- | --- | -| 文档版本 | 0.1 | -| 日期 | 2026-07-07 | +| 文档版本 | 0.2 | +| 日期 | 2026-07-08 | | 状态 | 第一版后端实现依据与落地记录 | | 适用范围 | SuperAgent / Main Agent 调用本系统查询订单和任务上下文 | | 主要读者 | 后端、SuperAgent 对接方、测试、后续协作 agent | @@ -32,7 +32,8 @@ - `body_current` 才能触发业务动作;`body_thread` 只能作为目标绑定证据。 - 如果查询 key 来自历史线程,调用方必须传 `target_key_source=body_thread_evidence` 和 `body_thread_used_only_as_evidence=true`。 - 第一版不伪造 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 的实际需要 @@ -58,14 +59,28 @@ ### 4.1 通用请求头 -安全方向上建议后续复用 SuperAgent 服务到服务鉴权思路,具体签名规则可参考任务结果接收接口。当前已落地的最小字段版暂不启用 HMAC,只强制 `X-Request-Id`,接口补签名规则前不得把该接口暴露到不可信网络。 +查询接口 1、2 已复用 SuperAgent 任务结果接收接口的 HMAC-SHA256 鉴权规则。签名规则、secret、timestamp 窗口、nonce 防重放和请求体大小配置与任务结果接收接口保持一致。 | Header | 是否必填 | 中文说明 | | --- | --- | --- | | `Content-Type` | 是 | 固定 `application/json` | -| `X-Request-Id` | 是 | 调用方生成的请求 ID,用于日志串联 | -| `X-AI-Trace-Id` | 否 | AI 运行链路 ID | -| `X-Source-Message-Id` | 否 | 来源消息 ID,便于排查 | +| `X-TH-Hotel-SuperAgent-Client-Id` | 是 | SuperAgent 调用方客户端 ID | +| `X-TH-Hotel-SuperAgent-Timestamp` | 是 | UTC ISO-8601 时间 | +| `X-TH-Hotel-SuperAgent-Nonce` | 是 | 每次请求唯一随机值,用于防重放 | +| `X-TH-Hotel-SuperAgent-Signature` | 是 | HMAC 签名,格式 `sha256=` | +| `X-TH-Hotel-Request-Id` | 否 | 调用方生成的请求 ID,用于日志串联 | +| `X-TH-Hotel-AI-Trace-Id` | 否 | AI 运行链路 ID | + +规范签名串: + +```text +POST + + + + + +``` ### 4.2 通用响应包 @@ -126,8 +141,8 @@ POST /api/ai-query/v1/case-context | 字段 | 是否必填 | 中文说明 | 当前系统来源或用途 | | --- | --- | --- | --- | | `hotel_id` | 是 | 酒店或业务上下文 ID | 用于隔离 `workflow_reservation_*` 表 | -| `source_message_id` | 是 | 当前 SourceMessage ID | 串联来源消息、AI 过渡记录和任务 | -| `source_event_index` | 是 | 当前 current 事件序号 | 和 AI 拆分结果保持一致 | +| `source_message_id` | 否 | 当前 SourceMessage ID | 全局上下文查询可不传;传入时只做格式校验和排查辅助 | +| `source_event_index` | 否 | 当前 current 事件序号 | 全局上下文查询可不传;传入时必须为正整数 | | `group_code` | 条件必填 | Group / Allotment 优先业务 key | 查询 `GROUP_CODE` 类型订单和 AI 过渡记录 | | `confirmation_number` | 条件必填 | FIT 优先业务 key | 查询 `CONFIRMATION_NUMBER` 类型订单和 AI 过渡记录 | | `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`。 - `active_workflows` 当前无独立表源,固定返回空数组。 - 接口 2 支持 `ORDER:{order_id}` 查询本系统订单快照。 +- 查询接口 1、2 已启用与任务结果接收接口一致的 HMAC-SHA256 鉴权。 +- 查询接口错误响应统一返回 `success=false` 包装,非法 JSON、非法 `Content-Type` 和鉴权错误不会暴露 Secret、签名原文或完整请求体。 - 对外 JSON 中内部长整型 ID 按字符串返回。 - `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`。 - 接口 4 `query_parent_task_context`,后续改为订单及其下面任务查询后再定义。 -- SuperAgent 查询接口的 HMAC 鉴权。当前第一版只要求 `X-Request-Id` 作为请求追踪头。 - 附件解析、OCR、Excel、voucher、rooming list 解析。 - 真实 OPERA / OHIP 对象投影。 diff --git a/docs/project/requirements/M002-order-task-workflow-v2.md b/docs/project/requirements/M002-order-task-workflow-v2.md index 187185b..815a3c0 100644 --- a/docs/project/requirements/M002-order-task-workflow-v2.md +++ b/docs/project/requirements/M002-order-task-workflow-v2.md @@ -483,9 +483,9 @@ Fallback 处理规则: ## 18. 待确认问题 - SuperAgent 创建任务接口的 URL、Method、Header、鉴权和错误响应格式。 -- SuperAgent 查询上下文接口 1、2 已实现最小字段版;接口 3 文件解析和接口 4 订单及任务查询仍需后续确认与实现,后续梳理未完成事项时必须持续提醒。 +- SuperAgent 查询上下文接口 1、2 已实现最小字段版,并已启用与任务结果接收接口一致的 HMAC;接口 3 文件解析和接口 4 订单及任务查询仍需后续确认与实现,后续梳理未完成事项时必须持续提醒。 - `source_event_index`、批次 item index、`execution_order` 的最终编号规则是否都从 1 开始。 -- SuperAgent 查询上下文接口 3、4 的 URL、入参、返回字段和鉴权方式;接口 1、2 后续是否补 HMAC 鉴权也需确认。 +- SuperAgent 查询上下文接口 3、4 的 URL、入参、返回字段和鉴权方式。 - `Message Notification` 是否需要在前端订单列表上单独标识为只读提醒。 - OPERA 模拟结果中 Confirmation No.、Group Code、Block Code、Allotment Code 的具体字段路径。 - 临时订单在无任务后是否立即逻辑删除,还是保留一段时间便于追溯。 diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/request/ReservationAiCaseContextQueryRequest.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/request/ReservationAiCaseContextQueryRequest.java index 06ee151..1e0d582 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/request/ReservationAiCaseContextQueryRequest.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/request/ReservationAiCaseContextQueryRequest.java @@ -6,8 +6,8 @@ import com.fasterxml.jackson.annotation.JsonProperty; * SuperAgent 查询订单上下文请求。该请求只用于只读查询,不触发任务创建或 OPERA 写入。 * * @param hotelId 酒店上下文 ID,用于隔离订单、任务和 AI transition 数据 - * @param sourceMessageId 当前 SourceMessage ID,外部以字符串传入避免长整型精度问题 - * @param sourceEventIndex 当前 AI 事件序号,用于和拆分结果保持一致 + * @param sourceMessageId 当前 SourceMessage ID,全局上下文查询可不传 + * @param sourceEventIndex 当前 AI 事件序号,全局上下文查询可不传,传入时必须为正整数 * @param groupCode Group Code / Allotment Code 查询 key * @param confirmationNumber Confirmation Number 查询 key * @param reservationNo OPERA reservation no,第一版无可靠表源,仅参与入参完整性校验 diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationAiQueryController.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationAiQueryController.java index 836fc43..0b2d8c0 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationAiQueryController.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationAiQueryController.java @@ -1,13 +1,24 @@ 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.ReservationAiObjectDetailQueryRequest; 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.ReservationAiQueryResponse; 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 org.springframework.http.HttpStatus; +import org.springframework.http.InvalidMediaTypeException; 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; @@ -21,13 +32,26 @@ import org.springframework.web.bind.annotation.RestController; @RequestMapping("/api/ai-query/v1") 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 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.securityService = securityService; + this.securityProperties = securityProperties; + this.objectMapper = objectMapper; } /** @@ -35,14 +59,28 @@ public class ReservationAiQueryController { */ @PostMapping( value = "/case-context", - consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE) - public ReservationAiQueryResponse queryCaseContext( - @RequestHeader("X-Request-Id") String requestId, + public ResponseEntity> queryCaseContext( + @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, - @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); - 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( value = "/object-detail", - consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE) - public ReservationAiQueryResponse queryObjectDetail( - @RequestHeader("X-Request-Id") String requestId, + public ResponseEntity> queryObjectDetail( + @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, - @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); - 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 readBody(String rawBody, Class 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; } } diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationAiQueryControllerAdvice.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationAiQueryControllerAdvice.java index d5e9c11..47752b3 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationAiQueryControllerAdvice.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationAiQueryControllerAdvice.java @@ -1,12 +1,14 @@ 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.ReservationAiQueryResponse; 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.web.bind.MissingRequestHeaderException; import org.springframework.web.bind.annotation.ExceptionHandler; -import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RestControllerAdvice; /** @@ -21,16 +23,28 @@ public class ReservationAiQueryControllerAdvice { @ExceptionHandler(ReservationAiQueryException.class) public ResponseEntity> handleAiQueryException( ReservationAiQueryException exception, - @RequestHeader(value = "X-Request-Id", required = false) String requestId, - @RequestHeader(value = "X-AI-Trace-Id", required = false) String traceId) { - return ResponseEntity.status(exception.getStatus()) - .body(ReservationAiQueryResponse.failure( - requestId, - traceId, - new ReservationAiQueryErrorResult( - exception.getErrorCode(), - exception.getMessage(), - exception.getDetails()))); + HttpServletRequest request) { + return failure( + exception.getStatus().value(), + request, + exception.getErrorCode(), + exception.getMessage(), + exception.getDetails()); + } + + /** + * 处理 HMAC、时间窗口、nonce 和请求体大小等协议层错误。 + */ + @ExceptionHandler(SuperAgentTaskResultException.class) + public ResponseEntity> 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) public ResponseEntity> handleMissingHeader( MissingRequestHeaderException exception, - @RequestHeader(value = "X-AI-Trace-Id", required = false) String traceId) { - return ResponseEntity.badRequest() + HttpServletRequest request) { + return failure( + 400, + request, + "REQUEST_HEADER_REQUIRED", + exception.getHeaderName() + " 请求头不能为空", + Map.of()); + } + + /** + * 构造查询接口统一失败响应,异常路径只返回安全错误码和必要追踪字段。 + */ + private ResponseEntity> failure( + int status, + HttpServletRequest request, + String code, + String message, + Map details) { + return ResponseEntity.status(status) .body(ReservationAiQueryResponse.failure( - null, - traceId, - new ReservationAiQueryErrorResult( - "REQUEST_HEADER_REQUIRED", - exception.getHeaderName() + " 请求头不能为空", - java.util.Map.of()))); + firstHeader(request, "X-TH-Hotel-Request-Id", "X-Request-Id"), + firstHeader(request, "X-TH-Hotel-AI-Trace-Id", "X-AI-Trace-Id"), + new ReservationAiQueryErrorResult(code, message, details))); + } + + /** + * 读取第一个非空 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; } } diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationAiQueryServiceImpl.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationAiQueryServiceImpl.java index 53a3feb..67dbd42 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationAiQueryServiceImpl.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationAiQueryServiceImpl.java @@ -293,8 +293,10 @@ public class ReservationAiQueryServiceImpl implements ReservationAiQueryService throw badRequest("MISSING_REQUEST_BODY", "请求体不能为空"); } requireText(request.hotelId(), "HOTEL_ID_REQUIRED", "hotel_id 不能为空"); - parseLong(request.sourceMessageId(), "SOURCE_MESSAGE_ID_INVALID", "source_message_id 必须是数字字符串"); - if (request.sourceEventIndex() == null || request.sourceEventIndex() <= 0) { + if (trimToNull(request.sourceMessageId()) != null) { + 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 必须是正整数"); } if (trimToNull(request.groupCode()) == null diff --git a/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationAiQueryControllerTest.java b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationAiQueryControllerTest.java index 2a193f4..d9b231f 100644 --- a/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationAiQueryControllerTest.java +++ b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationAiQueryControllerTest.java @@ -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.result.SourceMessageCaptureResult; import cn.nianxx.thhotel.platform.message.service.SourceMessageCaptureService; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; import java.time.Instant; +import java.util.HexFormat; import java.util.List; +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; 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.test.context.ActiveProfiles; 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 @ActiveProfiles("test") class ReservationAiQueryControllerTest { 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 private MockMvc mockMvc; @@ -46,11 +63,7 @@ class ReservationAiQueryControllerTest { insertTransition(920000000000000201L, source.inboxId(), 1, "GRP-AIQUERY-001"); insertTask(920000000000000301L, 920000000000000101L, source.inboxId(), 920000000000000201L, "PENDING_CONFIRM"); - mockMvc.perform(post("/api/ai-query/v1/case-context") - .contentType(MediaType.APPLICATION_JSON) - .header("X-Request-Id", "req-ai-query-case-001") - .header("X-AI-Trace-Id", "trace-ai-query-case-001") - .content(""" + String body = """ { "hotel_id": "HOTEL-TEST", "source_message_id": "%s", @@ -59,7 +72,10 @@ class ReservationAiQueryControllerTest { "target_key_source": "body_current", "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(jsonPath("$.success").value(true)) .andExpect(jsonPath("$.request_id").value("req-ai-query-case-001")) @@ -85,17 +101,16 @@ class ReservationAiQueryControllerTest { void shouldReturnSuccessfulEmptyCaseContextWhenNoObjectMatches() throws Exception { SourceMessageCaptureResult source = captureSourceMessage("mail-ai-query-empty-001"); - mockMvc.perform(post("/api/ai-query/v1/case-context") - .contentType(MediaType.APPLICATION_JSON) - .header("X-Request-Id", "req-ai-query-empty-001") - .content(""" + String body = """ { "hotel_id": "HOTEL-TEST", "source_message_id": "%s", "source_event_index": 1, "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(jsonPath("$.success").value(true)) .andExpect(jsonPath("$.data.matched_order_records.length()").value(0)) @@ -110,17 +125,17 @@ class ReservationAiQueryControllerTest { void shouldNotAllowCreationWhenOnlyUnsupportedReservationNoProvided() throws Exception { SourceMessageCaptureResult source = captureSourceMessage("mail-ai-query-reservation-no-001"); - mockMvc.perform(post("/api/ai-query/v1/case-context") - .contentType(MediaType.APPLICATION_JSON) - .header("X-Request-Id", "req-ai-query-reservation-no-001") - .content(""" + String body = """ { "hotel_id": "HOTEL-TEST", "source_message_id": "%s", "source_event_index": 1, "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(jsonPath("$.success").value(true)) .andExpect(jsonPath("$.data.matched_order_records.length()").value(0)) @@ -131,22 +146,104 @@ class ReservationAiQueryControllerTest { .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 void shouldReturnObjectDetailWithNullableOperaProjectionWarning() throws Exception { SourceMessageCaptureResult source = captureSourceMessage("mail-ai-query-detail-001"); insertActiveGroupOrder(920000000000000401L, source.inboxId(), "GRP-AIQUERY-DETAIL-001"); - mockMvc.perform(post("/api/ai-query/v1/object-detail") - .contentType(MediaType.APPLICATION_JSON) - .header("X-Request-Id", "req-ai-query-detail-001") - .header("X-AI-Trace-Id", "trace-ai-query-detail-001") - .content(""" + String body = """ { "hotel_id": "HOTEL-TEST", "object_id": "ORDER:920000000000000401", "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(jsonPath("$.success").value(true)) .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")); } + @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) { return captureService.capture(new CaptureSourceMessageCommand( HOTEL_ID, @@ -224,4 +346,28 @@ class ReservationAiQueryControllerTest { CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) """, 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))); + } }