修正 SuperAgent 来源消息 ID 契约
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 SuperAgent 透传的外部来源消息 ID,本接口第一版接收但不作为查询边界
|
||||
* @param sourceEventIndex SuperAgent 透传的 AI 事件序号,本接口第一版接收但不作为查询边界
|
||||
* @param groupCode Group Code / Allotment Code 查询 key
|
||||
* @param confirmationNumber Confirmation Number 查询 key
|
||||
* @param reservationNo OPERA reservation no,第一版无可靠表源,仅参与入参完整性校验
|
||||
|
||||
@@ -4,7 +4,7 @@ import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* SuperAgent 任务结果入站响应。创建成功和幂等重放都使用该结构。
|
||||
* SuperAgent 任务结果入站响应。source_message_id 对外回显 SuperAgent 传入的外部邮件 ID。
|
||||
*/
|
||||
public record SuperAgentTaskResultResponse(
|
||||
@JsonProperty("request_id")
|
||||
|
||||
@@ -293,12 +293,6 @@ public class ReservationAiQueryServiceImpl implements ReservationAiQueryService
|
||||
throw badRequest("MISSING_REQUEST_BODY", "请求体不能为空");
|
||||
}
|
||||
requireText(request.hotelId(), "HOTEL_ID_REQUIRED", "hotel_id 不能为空");
|
||||
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
|
||||
&& trimToNull(request.confirmationNumber()) == null
|
||||
&& trimToNull(request.reservationNo()) == null) {
|
||||
|
||||
@@ -45,9 +45,12 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta
|
||||
private static final String FIELD_CONTRACT_VERSION = "code-v1";
|
||||
private static final String BATCH_KEY_PREFIX = "superagent-task-result-batch:v1";
|
||||
private static final String ITEM_KEY_PREFIX = "superagent-task-result-item:v1";
|
||||
private static final String DEFAULT_SOURCE_PROVIDER = "AGENTBUS";
|
||||
private static final String DEFAULT_SOURCE_CHANNEL = "EMAIL";
|
||||
private static final int LENGTH_32 = 32;
|
||||
private static final int LENGTH_64 = 64;
|
||||
private static final int LENGTH_128 = 128;
|
||||
private static final int LENGTH_256 = 256;
|
||||
private static final int MAX_QUEUE_ORDER_RETRY = 5;
|
||||
|
||||
private final ObjectMapper objectMapper;
|
||||
@@ -73,14 +76,15 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta
|
||||
@Transactional
|
||||
public SuperAgentTaskResultResponse accept(String rawBody, String clientId, String requestId) {
|
||||
JsonNode root = parseJson(rawBody);
|
||||
Long sourceMessageId = parseSourceMessageId(root);
|
||||
ResolvedSourceMessage resolvedSourceMessage = resolveSourceMessage(root);
|
||||
SourceMessageInboxSnapshot sourceMessage = resolvedSourceMessage.snapshot();
|
||||
Long sourceMessageId = sourceMessage.id();
|
||||
String responseSourceMessageId = resolvedSourceMessage.responseSourceMessageId();
|
||||
JsonNode taskResults = root.path("ai_task_results");
|
||||
if (!taskResults.isArray() || taskResults.isEmpty()) {
|
||||
throw error(HttpStatus.BAD_REQUEST, "TASK_RESULTS_EMPTY", "ai_task_results 不能为空。");
|
||||
}
|
||||
|
||||
SourceMessageInboxSnapshot sourceMessage = sourceMessageInboxRepository.findById(sourceMessageId)
|
||||
.orElseThrow(() -> error(HttpStatus.NOT_FOUND, "SOURCE_MESSAGE_NOT_FOUND", "SourceMessage 不存在。"));
|
||||
String hotelId = sourceMessage.hotelId();
|
||||
String requestPayloadSha256 = sha256(rawBody == null ? "" : rawBody);
|
||||
String batchIdempotencyKey = sha256(BATCH_KEY_PREFIX + "|" + sourceMessageId + "|" + requestPayloadSha256);
|
||||
@@ -89,7 +93,7 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta
|
||||
.findBatchBySourceMessageId(hotelId, sourceMessageId)
|
||||
.orElse(null);
|
||||
if (existingBatch != null) {
|
||||
return handleExistingBatch(requestId, sourceMessageId, requestPayloadSha256, existingBatch);
|
||||
return handleExistingBatch(requestId, responseSourceMessageId, requestPayloadSha256, existingBatch);
|
||||
}
|
||||
|
||||
LocalDateTime now = nowUtc();
|
||||
@@ -109,7 +113,7 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta
|
||||
if (batchId == null) {
|
||||
return handleExistingBatch(
|
||||
requestId,
|
||||
sourceMessageId,
|
||||
responseSourceMessageId,
|
||||
requestPayloadSha256,
|
||||
workflowRepository.findBatchBySourceMessageId(hotelId, sourceMessageId)
|
||||
.orElseThrow(() -> error(HttpStatus.CONFLICT, "IDEMPOTENCY_CONFLICT", "AI 批次并发写入状态不确定。")));
|
||||
@@ -123,7 +127,7 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta
|
||||
|
||||
return new SuperAgentTaskResultResponse(
|
||||
safeRequestId,
|
||||
sourceMessageId.toString(),
|
||||
responseSourceMessageId,
|
||||
batchId.toString(),
|
||||
false,
|
||||
responseItems.size(),
|
||||
@@ -148,7 +152,7 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta
|
||||
*/
|
||||
private SuperAgentTaskResultResponse handleExistingBatch(
|
||||
String requestId,
|
||||
Long sourceMessageId,
|
||||
String responseSourceMessageId,
|
||||
String requestPayloadSha256,
|
||||
ReservationAiBatchSnapshot existingBatch) {
|
||||
if (!requestPayloadSha256.equals(existingBatch.requestPayloadSha256())) {
|
||||
@@ -156,7 +160,7 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta
|
||||
}
|
||||
return new SuperAgentTaskResultResponse(
|
||||
requestId,
|
||||
sourceMessageId.toString(),
|
||||
responseSourceMessageId,
|
||||
existingBatch.id().toString(),
|
||||
true,
|
||||
existingBatch.itemCount() == null ? 0 : existingBatch.itemCount(),
|
||||
@@ -484,18 +488,46 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 SourceMessage ID,接口一次只能处理一个来源消息。
|
||||
* 解析 SuperAgent 来源消息引用。正式契约使用外部邮件 ID,旧本地夹具仍兼容内部 SourceMessage ID。
|
||||
*/
|
||||
private Long parseSourceMessageId(JsonNode root) {
|
||||
String rawId = trimToNull(textAt(root, "source_message_id"));
|
||||
if (rawId == null) {
|
||||
private ResolvedSourceMessage resolveSourceMessage(JsonNode root) {
|
||||
String sourceMessageReference = trimToNull(textAt(root, "source_message_id"));
|
||||
if (sourceMessageReference == null) {
|
||||
throw error(HttpStatus.BAD_REQUEST, "SOURCE_MESSAGE_REQUIRED", "source_message_id 缺失。");
|
||||
}
|
||||
try {
|
||||
return Long.valueOf(rawId);
|
||||
} catch (NumberFormatException exception) {
|
||||
throw error(HttpStatus.BAD_REQUEST, "SOURCE_MESSAGE_REQUIRED", "source_message_id 格式无效。");
|
||||
validateLength(sourceMessageReference, "source_message_id", LENGTH_256);
|
||||
|
||||
String hotelId = trimToNull(textAt(root, "hotel_id"));
|
||||
if (hotelId == null) {
|
||||
return resolveLegacyInternalSourceMessage(sourceMessageReference);
|
||||
}
|
||||
validateLength(hotelId, "hotel_id", LENGTH_64);
|
||||
String sourceProvider = optionalText(textAt(root, "source_provider"), "source_provider", LENGTH_32);
|
||||
String sourceChannel = optionalText(textAt(root, "source_channel"), "source_channel", LENGTH_32);
|
||||
String provider = sourceProvider == null ? DEFAULT_SOURCE_PROVIDER : sourceProvider;
|
||||
String channel = sourceChannel == null ? DEFAULT_SOURCE_CHANNEL : sourceChannel;
|
||||
SourceMessageInboxSnapshot sourceMessage = sourceMessageInboxRepository
|
||||
.findByIdempotencyKey(hotelId, provider, channel, sourceMessageReference)
|
||||
.orElseThrow(() -> error(HttpStatus.NOT_FOUND, "SOURCE_MESSAGE_NOT_FOUND", "SourceMessage 不存在。"));
|
||||
return new ResolvedSourceMessage(sourceMessage, sourceMessageReference);
|
||||
}
|
||||
|
||||
/**
|
||||
* 兼容历史本地测试和旧接口调用:无 hotel_id 时按内部 SourceMessage ID 查询。
|
||||
*/
|
||||
private ResolvedSourceMessage resolveLegacyInternalSourceMessage(String rawId) {
|
||||
Long sourceMessageId;
|
||||
try {
|
||||
sourceMessageId = Long.valueOf(rawId);
|
||||
} catch (NumberFormatException exception) {
|
||||
throw error(HttpStatus.BAD_REQUEST, "HOTEL_ID_REQUIRED", "使用外部 source_message_id 时 hotel_id 不能为空。");
|
||||
}
|
||||
SourceMessageInboxSnapshot sourceMessage = sourceMessageInboxRepository.findById(sourceMessageId)
|
||||
.orElseThrow(() -> error(HttpStatus.NOT_FOUND, "SOURCE_MESSAGE_NOT_FOUND", "SourceMessage 不存在。"));
|
||||
String responseSourceMessageId = trimToNull(sourceMessage.externalMessageId()) == null
|
||||
? sourceMessageId.toString()
|
||||
: sourceMessage.externalMessageId();
|
||||
return new ResolvedSourceMessage(sourceMessage, responseSourceMessageId);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -619,6 +651,15 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta
|
||||
return value != null && !value.trim().isEmpty();
|
||||
}
|
||||
|
||||
/**
|
||||
* 已解析的来源消息。snapshot 使用内部 ID 参与落库,responseSourceMessageId 面向 SuperAgent 回显外部 ID。
|
||||
*/
|
||||
private record ResolvedSourceMessage(
|
||||
SourceMessageInboxSnapshot snapshot,
|
||||
String responseSourceMessageId
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 将空白字符串转换为 null。
|
||||
*/
|
||||
|
||||
@@ -72,7 +72,7 @@ class ReservationAiQueryControllerTest {
|
||||
"target_key_source": "body_current",
|
||||
"body_thread_used_only_as_evidence": false
|
||||
}
|
||||
""".formatted(source.inboxId());
|
||||
""".formatted("mail-ai-query-case-001");
|
||||
|
||||
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"))
|
||||
@@ -108,7 +108,7 @@ class ReservationAiQueryControllerTest {
|
||||
"source_event_index": 1,
|
||||
"group_code": "GRP-AIQUERY-NOT-FOUND"
|
||||
}
|
||||
""".formatted(source.inboxId());
|
||||
""".formatted("mail-ai-query-empty-001");
|
||||
|
||||
mockMvc.perform(signedPost(CASE_CONTEXT_ENDPOINT, body, "nonce-ai-query-empty-001", "req-ai-query-empty-001"))
|
||||
.andExpect(status().isOk())
|
||||
@@ -132,7 +132,7 @@ class ReservationAiQueryControllerTest {
|
||||
"source_event_index": 1,
|
||||
"reservation_no": "RESV-AIQUERY-001"
|
||||
}
|
||||
""".formatted(source.inboxId());
|
||||
""".formatted("mail-ai-query-reservation-no-001");
|
||||
|
||||
mockMvc.perform(signedPost(CASE_CONTEXT_ENDPOINT, body, "nonce-ai-query-reservation-no-001",
|
||||
"req-ai-query-reservation-no-001"))
|
||||
@@ -165,6 +165,27 @@ class ReservationAiQueryControllerTest {
|
||||
.andExpect(jsonPath("$.data.target_object_validation.status").value("single"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldIgnoreExternalSourceMessageIdInGlobalCaseContextQuery() throws Exception {
|
||||
SourceMessageCaptureResult source = captureSourceMessage("mail-ai-query-external-001");
|
||||
insertActiveGroupOrder(920000000000000601L, source.inboxId(), "GRP-AIQUERY-EXTERNAL-001");
|
||||
String body = """
|
||||
{
|
||||
"hotel_id": "HOTEL-TEST",
|
||||
"source_message_id": "mail-ai-query-external-001",
|
||||
"source_event_index": -1,
|
||||
"group_code": "GRP-AIQUERY-EXTERNAL-001"
|
||||
}
|
||||
""";
|
||||
|
||||
mockMvc.perform(signedPost(CASE_CONTEXT_ENDPOINT, body, "nonce-ai-query-external-001",
|
||||
"req-ai-query-external-001"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.success").value(true))
|
||||
.andExpect(jsonPath("$.data.matched_order_records[0].object_id").value("ORDER:920000000000000601"))
|
||||
.andExpect(jsonPath("$.data.target_object_validation.status").value("single"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectCaseContextWhenHmacSignatureInvalid() throws Exception {
|
||||
String body = """
|
||||
|
||||
@@ -108,7 +108,7 @@ class SuperAgentTaskResultControllerTest {
|
||||
|
||||
mockMvc.perform(signedPost(body, "nonce-new-booking-temp-001"))
|
||||
.andExpect(status().isCreated())
|
||||
.andExpect(jsonPath("$.source_message_id").value(source.inboxId().toString()))
|
||||
.andExpect(jsonPath("$.source_message_id").value("mail-new-booking-temp-001"))
|
||||
.andExpect(jsonPath("$.idempotent_replay").value(false))
|
||||
.andExpect(jsonPath("$.accepted_count").value(1))
|
||||
.andExpect(jsonPath("$.items[0].source_event_index").value(1))
|
||||
@@ -145,6 +145,42 @@ class SuperAgentTaskResultControllerTest {
|
||||
org.assertj.core.api.Assertions.assertThat(taskCardCount).isGreaterThanOrEqualTo(1L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldResolveTaskResultSourceMessageByExternalMessageId() throws Exception {
|
||||
String externalMessageId = "mail-external-source-id-001";
|
||||
SourceMessageCaptureResult source = captureSourceMessage(externalMessageId);
|
||||
String body = minimalBodyWithHotel(externalMessageId, "New Booking", "normal_task", "new_fit_reservation", """
|
||||
"case_keys": {},
|
||||
"extracted_fields": {}
|
||||
""");
|
||||
|
||||
mockMvc.perform(signedPost(body, "nonce-external-source-id-001"))
|
||||
.andExpect(status().isCreated())
|
||||
.andExpect(jsonPath("$.source_message_id").value(externalMessageId))
|
||||
.andExpect(jsonPath("$.accepted_count").value(1))
|
||||
.andExpect(jsonPath("$.items[0].task_status").value("PENDING_CONFIRM"));
|
||||
|
||||
Long transitionCount = jdbcTemplate.queryForObject("""
|
||||
SELECT COUNT(*)
|
||||
FROM workflow_reservation_ai_transition
|
||||
WHERE source_message_id = ?
|
||||
""", Long.class, source.inboxId());
|
||||
assertThat(transitionCount).isEqualTo(1L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectExternalSourceMessageIdWhenHotelIdMissing() throws Exception {
|
||||
String body = minimalBody("mail-external-without-hotel-001", "New Booking", "normal_task", "new_fit_reservation", """
|
||||
"case_keys": {},
|
||||
"extracted_fields": {}
|
||||
""");
|
||||
|
||||
mockMvc.perform(signedPost(body, "nonce-external-source-without-hotel-001"))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.error_code").value("HOTEL_ID_REQUIRED"))
|
||||
.andExpect(jsonPath("$.message").value("使用外部 source_message_id 时 hotel_id 不能为空。"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldReturnIdempotentReplayForSameBodyWithNewNonce() throws Exception {
|
||||
SourceMessageCaptureResult source = captureSourceMessage("mail-idempotent-replay-001");
|
||||
@@ -1125,6 +1161,40 @@ class SuperAgentTaskResultControllerTest {
|
||||
""".formatted(sourceMessageId, resultType, taskType, taskSubtype, itemFields);
|
||||
}
|
||||
|
||||
private String minimalBodyWithHotel(
|
||||
String sourceMessageId,
|
||||
String taskType,
|
||||
String resultType,
|
||||
String taskSubtype,
|
||||
String itemFields) {
|
||||
return """
|
||||
{
|
||||
"hotel_id": "HOTEL-TEST",
|
||||
"source_message_id": "%s",
|
||||
"ai_task_results": [
|
||||
{
|
||||
"source_event_index": 1,
|
||||
"catalog_code": "S01",
|
||||
"skill_id": "S01_new_booking_skill",
|
||||
"result_type": "%s",
|
||||
"task_type": "%s",
|
||||
"task_subtype": "%s",
|
||||
"current_or_history": "current",
|
||||
"visible_reason": "AI extracted a task result.",
|
||||
"relevant_message_excerpt": "Please handle booking message.",
|
||||
"attachments": [],
|
||||
"file_references": [],
|
||||
"context_used": {},
|
||||
%s,
|
||||
"additional_operations": [],
|
||||
"idempotency_key": null
|
||||
}
|
||||
],
|
||||
"extraction_warnings": []
|
||||
}
|
||||
""".formatted(sourceMessageId, resultType, taskType, taskSubtype, itemFields);
|
||||
}
|
||||
|
||||
private String twoTaskBody(String sourceMessageId, String groupCode) {
|
||||
return """
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user