统一 SuperAgent 查询接口鉴权契约
This commit is contained in:
@@ -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,第一版无可靠表源,仅参与入参完整性校验
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user