统一 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

@@ -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第一版无可靠表源仅参与入参完整性校验

View File

@@ -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<ReservationAiCaseContextResult> queryCaseContext(
@RequestHeader("X-Request-Id") String requestId,
public ResponseEntity<ReservationAiQueryResponse<ReservationAiCaseContextResult>> 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<ReservationAiObjectDetailResult> queryObjectDetail(
@RequestHeader("X-Request-Id") String requestId,
public ResponseEntity<ReservationAiQueryResponse<ReservationAiObjectDetailResult>> 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> 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;
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<ReservationAiQueryResponse<Object>> 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<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)
public ResponseEntity<ReservationAiQueryResponse<Object>> 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<ReservationAiQueryResponse<Object>> failure(
int status,
HttpServletRequest request,
String code,
String message,
Map<String, Object> 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;
}
}

View File

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