修正 SuperAgent 来源消息 ID 契约

This commit is contained in:
andy
2026-07-08 11:12:11 +08:00
parent fb82386fdb
commit b58c6baafe
12 changed files with 254 additions and 84 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 SuperAgent 透传的外部来源消息 ID本接口第一版接收但不作为查询边界
* @param sourceEventIndex SuperAgent 透传的 AI 事件序号,本接口第一版接收但不作为查询边界
* @param groupCode Group Code / Allotment Code 查询 key
* @param confirmationNumber Confirmation Number 查询 key
* @param reservationNo OPERA reservation no第一版无可靠表源仅参与入参完整性校验

View File

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

View File

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

View File

@@ -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。
*/