修正SuperAgent任务结果来源消息定位
This commit is contained in:
@@ -358,8 +358,8 @@ public class SuperAgentMcpServiceImpl implements SuperAgentMcpService {
|
||||
private Map<String, Object> submitTaskResultsSchema() {
|
||||
Map<String, Object> propertiesMap = new LinkedHashMap<>();
|
||||
propertiesMap.put("hotel_id", stringField("可选酒店上下文 ID;缺省由 TH Hotel 后端解析系统酒店"));
|
||||
propertiesMap.put("source_provider", nullableStringField("来源提供方,默认 AGENTBUS"));
|
||||
propertiesMap.put("source_channel", nullableStringField("来源渠道,默认 EMAIL"));
|
||||
propertiesMap.put("source_provider", nullableStringField("兼容字段;写入工具通常不需要传,后端写入定位不使用该字段"));
|
||||
propertiesMap.put("source_channel", nullableStringField("兼容字段;写入工具通常不需要传,后端写入定位不使用该字段,AgentBus 实际入库渠道可能是 OUTLOOK"));
|
||||
propertiesMap.put("source_message_id", stringField("外部来源消息 ID,对应 AgentBus source.external_message_id"));
|
||||
propertiesMap.put("ai_task_results", Map.of(
|
||||
"type", "array",
|
||||
|
||||
@@ -157,6 +157,24 @@ public class MybatisSourceMessageInboxRepository implements SourceMessageInboxRe
|
||||
return Optional.ofNullable(entity).map(this::toSnapshot);
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据酒店和外部邮件 ID 查询所有候选 Inbox 记录,调用方负责处理零条或多条的业务语义。
|
||||
*/
|
||||
@Override
|
||||
public List<SourceMessageInboxSnapshot> findByExternalMessageId(String hotelId, String externalMessageId) {
|
||||
if (!hasText(hotelId) || !hasText(externalMessageId)) {
|
||||
return List.of();
|
||||
}
|
||||
return inboxMapper.selectList(Wrappers.<SourceMessageInboxEntity>lambdaQuery()
|
||||
.eq(SourceMessageInboxEntity::getHotelId, trim(hotelId))
|
||||
.eq(SourceMessageInboxEntity::getExternalMessageId, trim(externalMessageId))
|
||||
.orderByDesc(SourceMessageInboxEntity::getReceivedAt)
|
||||
.orderByDesc(SourceMessageInboxEntity::getId))
|
||||
.stream()
|
||||
.map(this::toSnapshot)
|
||||
.toList();
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行 SourceMessage 安全摘要分页查询,只读取列表和详情需要的索引字段。
|
||||
*/
|
||||
|
||||
@@ -50,6 +50,11 @@ public interface SourceMessageInboxRepository {
|
||||
String channel,
|
||||
String externalMessageId);
|
||||
|
||||
/**
|
||||
* 按酒店和外部邮件 ID 查询 Inbox 快照,用于外部回调只携带 AgentBus external_message_id 的场景。
|
||||
*/
|
||||
List<SourceMessageInboxSnapshot> findByExternalMessageId(String hotelId, String externalMessageId);
|
||||
|
||||
/**
|
||||
* 按查询条件分页读取 Inbox 快照,分页参数已经由 Service 层归一化。
|
||||
*/
|
||||
|
||||
@@ -39,6 +39,7 @@ import java.util.HexFormat;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
@@ -151,17 +152,21 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta
|
||||
private final ObjectMapper objectMapper;
|
||||
private final SourceMessageInboxRepository sourceMessageInboxRepository;
|
||||
private final ReservationAiWorkflowRepository workflowRepository;
|
||||
private final boolean allowLegacyInternalSourceMessageId;
|
||||
|
||||
/**
|
||||
* 注入 JSON 解析器、SourceMessage 查询边界和 Reservation 持久化边界。
|
||||
* 注入 JSON 解析器、SourceMessage 查询边界、Reservation 持久化边界和本地旧夹具兼容开关。
|
||||
*/
|
||||
public ReservationAiTaskIntakeServiceImpl(
|
||||
ObjectMapper objectMapper,
|
||||
SourceMessageInboxRepository sourceMessageInboxRepository,
|
||||
ReservationAiWorkflowRepository workflowRepository) {
|
||||
ReservationAiWorkflowRepository workflowRepository,
|
||||
@Value("${superagent.task-result.allow-legacy-internal-source-message-id:false}")
|
||||
boolean allowLegacyInternalSourceMessageId) {
|
||||
this.objectMapper = objectMapper;
|
||||
this.sourceMessageInboxRepository = sourceMessageInboxRepository;
|
||||
this.workflowRepository = workflowRepository;
|
||||
this.allowLegacyInternalSourceMessageId = allowLegacyInternalSourceMessageId;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -370,7 +375,8 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta
|
||||
List<SuperAgentTaskResultItemResponse> responseItems = new ArrayList<>(itemCount);
|
||||
Set<String> acceptedParentSplitCodes = new LinkedHashSet<>();
|
||||
for (int index = 0; index < messageEvents.size(); index++) {
|
||||
JsonNode event = messageEvents.get(index);
|
||||
JsonNode rawEvent = messageEvents.get(index);
|
||||
JsonNode event = normalizeV3ParentSplitEvent(rawEvent);
|
||||
int arrayIndex = index + 1;
|
||||
V3EventContractIssue contractIssue = inspectV3EventContractIssue(event, messageEvents);
|
||||
if (contractIssue == null) {
|
||||
@@ -381,9 +387,9 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta
|
||||
hotelId,
|
||||
sourceMessageId,
|
||||
batchId,
|
||||
event,
|
||||
rawEvent,
|
||||
arrayIndex,
|
||||
sourceEventIndex(event, arrayIndex),
|
||||
sourceEventIndex(rawEvent, arrayIndex),
|
||||
null,
|
||||
contractIssue.code(),
|
||||
contractIssue.message(),
|
||||
@@ -396,16 +402,16 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta
|
||||
hotelId,
|
||||
sourceMessageId,
|
||||
batchId,
|
||||
event,
|
||||
rawEvent,
|
||||
arrayIndex,
|
||||
sourceEventIndex(event, arrayIndex),
|
||||
sourceEventIndex(rawEvent, arrayIndex),
|
||||
null,
|
||||
"EVENT_ROUTE_UNSUPPORTED",
|
||||
"message_events[" + index + "] 无法派生稳定路由。",
|
||||
now));
|
||||
continue;
|
||||
}
|
||||
ObjectNode item = normalizeV3EventToTaskResultItem(event, route, arrayIndex);
|
||||
ObjectNode item = normalizeV3EventToTaskResultItem(rawEvent, event, route, arrayIndex);
|
||||
responseItems.add(createItem(hotelId, sourceMessageId, batchId, item, arrayIndex, now));
|
||||
}
|
||||
for (int index = 0; index < unhandledIntents.size(); index++) {
|
||||
@@ -636,31 +642,32 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta
|
||||
|
||||
|
||||
/**
|
||||
* 将 V3 业务 event 转为旧任务结果 item 形态,复用现有订单、任务和任务卡创建逻辑。
|
||||
* 将 V3 业务 event 转为旧任务结果 item 形态,业务列使用 adapter 派生 event,原始 event 保留在 v3_message_event。
|
||||
*/
|
||||
private ObjectNode normalizeV3EventToTaskResultItem(
|
||||
JsonNode event,
|
||||
JsonNode rawEvent,
|
||||
JsonNode derivedEvent,
|
||||
ReservationAiRouteDefinition route,
|
||||
int arrayIndex) {
|
||||
ObjectNode item = objectMapper.createObjectNode();
|
||||
item.put("source_event_index", sourceEventIndex(event, arrayIndex));
|
||||
item.put("source_event_index", sourceEventIndex(rawEvent, arrayIndex));
|
||||
item.put("catalog_code", V3_CATALOG_CODE);
|
||||
item.put("skill_id", V3_SKILL_ID);
|
||||
item.put("result_type", route.resultType());
|
||||
item.put("task_type", route.taskType());
|
||||
item.put("task_subtype", route.taskSubtype());
|
||||
copyIfPresent(event, item, "current_or_history");
|
||||
copyIfPresent(event, item, "relevant_message_excerpt");
|
||||
copyIfPresent(event, item, "case_keys");
|
||||
copyIfPresent(event, item, "extracted_fields");
|
||||
copyIfPresent(event, item, "manual_review");
|
||||
copyIfPresent(event, item, "attachments");
|
||||
copyIfPresent(event, item, "file_references");
|
||||
copyIfPresent(event, item, "context_used");
|
||||
copyIfPresent(event, item, "parent_source_event_index");
|
||||
copyIfPresent(event, item, "linked_task_group_id");
|
||||
copyIfPresent(event, item, "blocked_until_parent_completed");
|
||||
item.set("v3_message_event", event);
|
||||
copyIfPresent(rawEvent, item, "current_or_history");
|
||||
copyIfPresent(rawEvent, item, "relevant_message_excerpt");
|
||||
copyIfPresent(derivedEvent, item, "case_keys");
|
||||
copyIfPresent(rawEvent, item, "extracted_fields");
|
||||
copyIfPresent(rawEvent, item, "manual_review");
|
||||
copyIfPresent(rawEvent, item, "attachments");
|
||||
copyIfPresent(rawEvent, item, "file_references");
|
||||
copyIfPresent(rawEvent, item, "context_used");
|
||||
copyIfPresent(rawEvent, item, "parent_source_event_index");
|
||||
copyIfPresent(rawEvent, item, "linked_task_group_id");
|
||||
copyIfPresent(rawEvent, item, "blocked_until_parent_completed");
|
||||
item.set("v3_message_event", rawEvent);
|
||||
item.put("route_code", route.routeCode());
|
||||
item.put("system_process_category", route.systemProcessCategory().name());
|
||||
return item;
|
||||
@@ -804,6 +811,36 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta
|
||||
return "linked_parent_release_after_child_split".equals(firstText(extractedFields, event, "relationship_type"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 归一 Parent split 的 Parent Group key;只补齐 adapter 派生用副本,不修改请求原文。
|
||||
*/
|
||||
private JsonNode normalizeV3ParentSplitEvent(JsonNode event) {
|
||||
if (event == null || !event.isObject() || !isLinkedParentReleaseCandidate(event)) {
|
||||
return event;
|
||||
}
|
||||
ObjectNode normalizedEvent = event.deepCopy();
|
||||
JsonNode caseKeysNode = normalizedEvent.path("case_keys");
|
||||
if (!caseKeysNode.isObject()) {
|
||||
return normalizedEvent;
|
||||
}
|
||||
ObjectNode caseKeys = (ObjectNode) caseKeysNode;
|
||||
String groupCode = trimToNull(textAt(caseKeys, "group_code"));
|
||||
String blockCode = trimToNull(textAt(caseKeys, "block_code"));
|
||||
if (groupCode == null && blockCode != null) {
|
||||
caseKeys.put("group_code", blockCode);
|
||||
return normalizedEvent;
|
||||
}
|
||||
if (groupCode != null && blockCode == null) {
|
||||
caseKeys.put("block_code", groupCode);
|
||||
return normalizedEvent;
|
||||
}
|
||||
if (groupCode != null && blockCode != null && !groupCode.equals(blockCode)) {
|
||||
caseKeys.putNull("group_code");
|
||||
caseKeys.putNull("block_code");
|
||||
}
|
||||
return normalizedEvent;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断当前 producer 是否仍在输出旧 Parent Cancel Booking 组合。
|
||||
*/
|
||||
@@ -824,8 +861,8 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta
|
||||
if (!isLinkedParentReleaseCandidate(event)) {
|
||||
return null;
|
||||
}
|
||||
String parentGroupCode = trimToNull(textAt(event.path("case_keys"), "group_code"));
|
||||
if (parentGroupCode != null && !acceptedParentSplitCodes.add(parentGroupCode)) {
|
||||
ParentSplitIdentity identity = parentSplitIdentity(event);
|
||||
if (identity.normalizedParentCode() != null && !acceptedParentSplitCodes.add(identity.normalizedParentCode())) {
|
||||
return new V3EventContractIssue(
|
||||
"LINKED_PARENT_RELEASE_DUPLICATE_PARENT",
|
||||
"同一个 Parent split cluster 只能有一个 Parent Cancel Allotment 候选。");
|
||||
@@ -838,25 +875,25 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta
|
||||
*/
|
||||
private boolean validLinkedParentReleaseCandidate(JsonNode event, JsonNode messageEvents) {
|
||||
JsonNode extractedFields = event == null ? null : event.path("extracted_fields");
|
||||
JsonNode caseKeys = event == null ? null : event.path("case_keys");
|
||||
String parentGroupCode = trimToNull(textAt(caseKeys, "group_code"));
|
||||
String parentBlockCode = trimToNull(textAt(caseKeys, "block_code"));
|
||||
ParentSplitIdentity identity = parentSplitIdentity(event);
|
||||
if (!("Cancel Allotment".equals(trimToNull(textAt(event, "event_type")))
|
||||
&& "entire_allotment_control_block".equals(firstText(extractedFields, event, "cancel_scope"))
|
||||
&& isBooleanTrue(extractedFields.path("parent_release_or_cancel_candidate"))
|
||||
&& "parent_to_child_allocation_split".equals(firstText(extractedFields, event, "release_reason"))
|
||||
&& parentGroupCode != null
|
||||
&& parentGroupCode.equals(firstText(extractedFields, event, "parent_group_code"))
|
||||
&& isBooleanTrue(extractedFields.path("allocation_split_from_parent"))
|
||||
&& "New Booking".equals(trimToNull(textAt(event, "related_event_type")))
|
||||
&& event != null
|
||||
&& isBooleanTrue(event.path("requires_downstream_hard_validation"))
|
||||
&& parentGroupCode != null
|
||||
&& parentGroupCode.equals(parentBlockCode)
|
||||
&& nonEmptyArray(extractedFields, "child_group_codes")
|
||||
&& nonEmptyArray(event, "related_source_event_indices"))) {
|
||||
return false;
|
||||
}
|
||||
if (!identity.targetObjectUnclearReview() && !validConcreteParentIdentity(identity, extractedFields)) {
|
||||
return false;
|
||||
}
|
||||
if (identity.targetObjectUnclearReview() && !nonEmptyArray(event.path("context_used"), "parent_identity_candidates")) {
|
||||
return false;
|
||||
}
|
||||
List<String> childGroupCodes = stringArrayValues(extractedFields.path("child_group_codes"));
|
||||
List<String> relatedSourceEventIndices = stringArrayValues(event.path("related_source_event_indices"));
|
||||
if (childGroupCodes.isEmpty()
|
||||
@@ -888,6 +925,30 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验普通 Parent split 事件是否已经具备明确 Parent Group identity。
|
||||
*/
|
||||
private boolean validConcreteParentIdentity(ParentSplitIdentity identity, JsonNode extractedFields) {
|
||||
return identity.normalizedParentCode() != null
|
||||
&& identity.normalizedParentCode().equals(identity.groupCode())
|
||||
&& identity.normalizedParentCode().equals(identity.blockCode())
|
||||
&& identity.normalizedParentCode().equals(trimToNull(textAt(extractedFields, "parent_group_code")));
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取 Parent split identity,封装正常归一与 target_object_unclear 复核两种形态。
|
||||
*/
|
||||
private ParentSplitIdentity parentSplitIdentity(JsonNode event) {
|
||||
JsonNode caseKeys = event == null ? null : event.path("case_keys");
|
||||
String groupCode = trimToNull(textAt(caseKeys, "group_code"));
|
||||
String blockCode = trimToNull(textAt(caseKeys, "block_code"));
|
||||
String normalizedParentCode = groupCode != null && groupCode.equals(blockCode) ? groupCode : null;
|
||||
boolean targetObjectUnclearReview = groupCode == null
|
||||
&& blockCode == null
|
||||
&& "target_object_unclear".equals(textAt(event == null ? null : event.path("manual_review"), "reason_code"));
|
||||
return new ParentSplitIdentity(groupCode, blockCode, normalizedParentCode, targetObjectUnclearReview);
|
||||
}
|
||||
|
||||
/**
|
||||
* 接收 S000/S999 文本入口结果,创建只读来源消息任务,并隐藏其技术订单。
|
||||
*/
|
||||
@@ -914,13 +975,7 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta
|
||||
? requireText(defaultHotelId, "default_hotel_id", LENGTH_64)
|
||||
: requireText(resolvedSourceMessage.hotelId(), "source_message.hotel_id", LENGTH_64);
|
||||
SourceMessageInboxSnapshot sourceMessage = resolvedSourceMessage == null
|
||||
? sourceMessageInboxRepository
|
||||
.findByIdempotencyKey(
|
||||
hotelId,
|
||||
DEFAULT_SOURCE_PROVIDER,
|
||||
DEFAULT_SOURCE_CHANNEL,
|
||||
entryResult.externalSourceMessageId())
|
||||
.orElseThrow(() -> error(HttpStatus.NOT_FOUND, "SOURCE_MESSAGE_NOT_FOUND", "SourceMessage 不存在。"))
|
||||
? resolveExternalSourceMessageByHotel(hotelId, entryResult.externalSourceMessageId())
|
||||
: resolvedSourceMessage;
|
||||
String requestPayloadSha256 = sha256(rawBody == null ? "" : rawBody);
|
||||
String batchIdempotencyKey = sha256(BATCH_KEY_PREFIX + "|" + sourceMessage.id() + "|" + requestPayloadSha256);
|
||||
@@ -1575,22 +1630,21 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta
|
||||
if (requestHotelId != null && systemHotelId != null && !requestHotelId.equals(systemHotelId)) {
|
||||
throw error(HttpStatus.BAD_REQUEST, "HOTEL_ID_MISMATCH", "请求 hotel_id 与系统酒店不一致。");
|
||||
}
|
||||
if (requestHotelId == null && isLongText(sourceMessageReference)) {
|
||||
return resolveLegacyInternalSourceMessage(sourceMessageReference, systemHotelId);
|
||||
}
|
||||
String hotelId = requestHotelId == null ? systemHotelId : requestHotelId;
|
||||
if (hotelId == null) {
|
||||
return resolveLegacyInternalSourceMessage(sourceMessageReference, null);
|
||||
if (allowLegacyInternalSourceMessageId) {
|
||||
return resolveLegacyInternalSourceMessage(sourceMessageReference, null);
|
||||
}
|
||||
throw error(HttpStatus.BAD_REQUEST, "HOTEL_ID_REQUIRED", "使用外部 source_message_id 时 hotel_id 不能为空。");
|
||||
}
|
||||
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);
|
||||
// 旧 V2 字段只做格式兼容校验;写入定位统一按系统酒店 + external_message_id 匹配真实 Inbox。
|
||||
optionalText(textAt(root, "source_provider"), "source_provider", LENGTH_32);
|
||||
optionalText(textAt(root, "source_channel"), "source_channel", LENGTH_32);
|
||||
return resolveExternalSourceMessageReferenceByHotel(
|
||||
hotelId,
|
||||
sourceMessageReference,
|
||||
requestHotelId == null && allowLegacyInternalSourceMessageId);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1614,6 +1668,42 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta
|
||||
return new ResolvedSourceMessage(sourceMessage, responseSourceMessageId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按系统酒店和 AgentBus 外部邮件 ID 解析真实入库 SourceMessage,不要求 SuperAgent 了解数据库 provider/channel。
|
||||
*/
|
||||
private SourceMessageInboxSnapshot resolveExternalSourceMessageByHotel(String hotelId, String externalSourceMessageId) {
|
||||
List<SourceMessageInboxSnapshot> candidates =
|
||||
sourceMessageInboxRepository.findByExternalMessageId(hotelId, externalSourceMessageId);
|
||||
if (candidates.isEmpty()) {
|
||||
throw error(HttpStatus.NOT_FOUND, "SOURCE_MESSAGE_NOT_FOUND", "SourceMessage 不存在。");
|
||||
}
|
||||
if (candidates.size() > 1) {
|
||||
throw error(HttpStatus.CONFLICT, "SOURCE_MESSAGE_AMBIGUOUS", "同一酒店下存在多个匹配的 SourceMessage。");
|
||||
}
|
||||
return candidates.get(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* V2 写入优先按外部消息 ID 解析;仅在无显式 hotel_id 的旧本地夹具场景下兜底内部 SourceMessage ID。
|
||||
*/
|
||||
private ResolvedSourceMessage resolveExternalSourceMessageReferenceByHotel(
|
||||
String hotelId,
|
||||
String sourceMessageReference,
|
||||
boolean allowLegacyInternalFallback) {
|
||||
List<SourceMessageInboxSnapshot> candidates =
|
||||
sourceMessageInboxRepository.findByExternalMessageId(hotelId, sourceMessageReference);
|
||||
if (candidates.isEmpty()) {
|
||||
if (allowLegacyInternalFallback && isLongText(sourceMessageReference)) {
|
||||
return resolveLegacyInternalSourceMessage(sourceMessageReference, hotelId);
|
||||
}
|
||||
throw error(HttpStatus.NOT_FOUND, "SOURCE_MESSAGE_NOT_FOUND", "SourceMessage 不存在。");
|
||||
}
|
||||
if (candidates.size() > 1) {
|
||||
throw error(HttpStatus.CONFLICT, "SOURCE_MESSAGE_AMBIGUOUS", "同一酒店下存在多个匹配的 SourceMessage。");
|
||||
}
|
||||
return new ResolvedSourceMessage(candidates.get(0), sourceMessageReference);
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断来源消息引用是否为历史本地兼容的内部 SourceMessage ID。
|
||||
*/
|
||||
@@ -1823,13 +1913,8 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta
|
||||
}
|
||||
validateLength(externalSourceMessageId, "source_message.source_message_id", LENGTH_256);
|
||||
String hotelId = requireText(defaultHotelId, "default_hotel_id", LENGTH_64);
|
||||
SourceMessageInboxSnapshot sourceMessage = sourceMessageInboxRepository
|
||||
.findByIdempotencyKey(
|
||||
hotelId,
|
||||
DEFAULT_SOURCE_PROVIDER,
|
||||
DEFAULT_SOURCE_CHANNEL,
|
||||
externalSourceMessageId)
|
||||
.orElseThrow(() -> error(HttpStatus.NOT_FOUND, "SOURCE_MESSAGE_NOT_FOUND", "SourceMessage 不存在。"));
|
||||
SourceMessageInboxSnapshot sourceMessage =
|
||||
resolveExternalSourceMessageByHotel(hotelId, externalSourceMessageId);
|
||||
return new ResolvedSourceMessage(sourceMessage, externalSourceMessageId);
|
||||
}
|
||||
|
||||
@@ -2221,6 +2306,17 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Parent split 父事件身份归一结果。target_object_unclear 代表已知卡型但目标对象需人工确认。
|
||||
*/
|
||||
private record ParentSplitIdentity(
|
||||
String groupCode,
|
||||
String blockCode,
|
||||
String normalizedParentCode,
|
||||
boolean targetObjectUnclearReview
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 将空白字符串转换为 null。
|
||||
*/
|
||||
|
||||
@@ -35,6 +35,8 @@ superagent:
|
||||
task-result:
|
||||
# test SuperAgent HMAC 密钥;优先使用 test 专属变量,兼容旧通用变量。
|
||||
hmac-secret: ${SUPERAGENT_TEST_TASK_RESULT_HMAC_SECRET:${SUPERAGENT_TASK_RESULT_HMAC_SECRET:}}
|
||||
# 仅用于旧本地测试夹具;正式 REST / MCP 联调不得依赖内部 SourceMessage ID。
|
||||
allow-legacy-internal-source-message-id: ${SUPERAGENT_TEST_ALLOW_LEGACY_INTERNAL_SOURCE_MESSAGE_ID:true}
|
||||
open-api:
|
||||
# test Debug EML 调用 SuperAgent Open API;默认关闭,联调时显式开启。
|
||||
enabled: ${SUPERAGENT_TEST_OPEN_API_ENABLED:${SUPERAGENT_OPEN_API_ENABLED:false}}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
-- M002 SuperAgent 任务结果回调:按系统酒店和外部邮件 ID 反查 SourceMessage Inbox。
|
||||
CREATE INDEX idx_source_message_inbox_hotel_external_message
|
||||
ON platform_source_message_inbox (hotel_id, external_message_id);
|
||||
@@ -132,6 +132,10 @@ class SuperAgentMcpControllerTest {
|
||||
.andExpect(jsonPath("$.result.tools[4].name").value("th_hotel_submit_task_results"))
|
||||
.andExpect(jsonPath("$.result.tools[4].inputSchema.required[0]").value("source_message_id"))
|
||||
.andExpect(jsonPath("$.result.tools[4].inputSchema.required[1]").value("ai_task_results"))
|
||||
.andExpect(jsonPath("$.result.tools[4].inputSchema.properties.source_provider.description")
|
||||
.value(containsString("兼容字段")))
|
||||
.andExpect(jsonPath("$.result.tools[4].inputSchema.properties.source_channel.description")
|
||||
.value(containsString("OUTLOOK")))
|
||||
.andExpect(jsonPath("$.result.tools[4].annotations.readOnlyHint").value(false))
|
||||
.andExpect(jsonPath("$.result.tools[4].annotations.destructiveHint").value(true));
|
||||
}
|
||||
|
||||
@@ -42,7 +42,8 @@ import org.springframework.test.web.servlet.MvcResult;
|
||||
"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"
|
||||
"superagent.task-result.max-body-bytes=12000",
|
||||
"superagent.task-result.allow-legacy-internal-source-message-id=true"
|
||||
})
|
||||
@AutoConfigureMockMvc
|
||||
@ActiveProfiles("test")
|
||||
@@ -646,6 +647,17 @@ class SuperAgentTaskResultControllerTest {
|
||||
.andExpect(jsonPath("$.error_code").value("SOURCE_MESSAGE_NOT_FOUND"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectTextResultAsAmbiguousWhenExternalSourceMessageMatchesMultipleChannels() throws Exception {
|
||||
String externalMessageId = "mail-s999-ambiguous-channel-001";
|
||||
captureSourceMessage(externalMessageId, "HOTEL-TEST", "EMAIL");
|
||||
captureSourceMessage(externalMessageId, "HOTEL-TEST", "OUTLOOK");
|
||||
|
||||
mockMvc.perform(signedPlainPost("S999," + externalMessageId, "nonce-s999-ambiguous-channel-001"))
|
||||
.andExpect(status().isConflict())
|
||||
.andExpect(jsonPath("$.error_code").value("SOURCE_MESSAGE_AMBIGUOUS"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldCreateReadOnlySourceMessageOnlyTaskForStructuredS10Result() throws Exception {
|
||||
SourceMessageCaptureResult source = captureSourceMessage("mail-v3-s10-entry-result-001");
|
||||
@@ -710,6 +722,29 @@ class SuperAgentTaskResultControllerTest {
|
||||
assertThat(taskCount).isEqualTo(1L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldCreateStructuredS10ResultWhenSourceMessageStoredAsOutlookChannel() throws Exception {
|
||||
String externalMessageId = "mail-v3-s10-outlook-channel-001";
|
||||
SourceMessageCaptureResult source = captureSourceMessage(externalMessageId, "HOTEL-TEST", "OUTLOOK");
|
||||
String body = structuredS10Body(externalMessageId);
|
||||
|
||||
mockMvc.perform(signedPost(body, "nonce-v3-s10-outlook-channel-001"))
|
||||
.andExpect(status().isCreated())
|
||||
.andExpect(jsonPath("$.source_message_id").value(externalMessageId))
|
||||
.andExpect(jsonPath("$.items[0].route_code").value("S10"))
|
||||
.andExpect(jsonPath("$.items[0].system_process_category").value("SOURCE_MESSAGE_NOTIFICATION"));
|
||||
|
||||
Long transitionCount = jdbcTemplate.queryForObject("""
|
||||
SELECT COUNT(*)
|
||||
FROM workflow_reservation_ai_transition
|
||||
WHERE source_message_id = ?
|
||||
AND result_type = 'source_message_review_notification'
|
||||
AND route_code = 'S10'
|
||||
AND system_process_category = 'SOURCE_MESSAGE_NOTIFICATION'
|
||||
""", Long.class, source.inboxId());
|
||||
assertThat(transitionCount).isEqualTo(1L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldCreateReadOnlySourceMessageOnlyTaskForStructuredS99Result() throws Exception {
|
||||
SourceMessageCaptureResult source = captureSourceMessage("mail-v3-s99-entry-result-001");
|
||||
@@ -931,6 +966,170 @@ class SuperAgentTaskResultControllerTest {
|
||||
assertThat(transitionCount).isEqualTo(1L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldAcceptV2TaskResultWhenSourceMessageStoredAsOutlookChannelWithoutSourceChannel() throws Exception {
|
||||
String externalMessageId = "mail-v2-task-result-outlook-channel-001";
|
||||
SourceMessageCaptureResult source = captureSourceMessage(externalMessageId, "HOTEL-TEST", "OUTLOOK");
|
||||
String body = minimalBodyWithHotel(
|
||||
externalMessageId,
|
||||
"New Booking",
|
||||
"normal_task",
|
||||
"new_fit_reservation",
|
||||
"""
|
||||
"case_keys": {"confirmation_number": "CNF-V2-OUTLOOK-001"},
|
||||
"extracted_fields": {"booking_object_type": "FIT Reservation"}
|
||||
""");
|
||||
|
||||
mockMvc.perform(signedPost(body, "nonce-v2-task-result-outlook-channel-001"))
|
||||
.andExpect(status().isCreated())
|
||||
.andExpect(jsonPath("$.accepted_count").value(1))
|
||||
.andExpect(jsonPath("$.items[0].route_code").value("R01_NEW_FIT_RESERVATION_NORMAL"));
|
||||
|
||||
Long transitionCount = jdbcTemplate.queryForObject("""
|
||||
SELECT COUNT(*)
|
||||
FROM workflow_reservation_ai_transition
|
||||
WHERE source_message_id = ?
|
||||
AND route_code = 'R01_NEW_FIT_RESERVATION_NORMAL'
|
||||
""", Long.class, source.inboxId());
|
||||
assertThat(transitionCount).isEqualTo(1L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldAcceptV2TaskResultWhenLegacySourceChannelDoesNotMatchInboxChannel() throws Exception {
|
||||
String externalMessageId = "mail-v2-task-result-legacy-email-channel-001";
|
||||
SourceMessageCaptureResult source = captureSourceMessage(externalMessageId, "HOTEL-TEST", "OUTLOOK");
|
||||
String body = minimalBodyWithHotelAndSourceMetadata(
|
||||
externalMessageId,
|
||||
"AGENTBUS",
|
||||
"EMAIL",
|
||||
"New Booking",
|
||||
"normal_task",
|
||||
"new_fit_reservation",
|
||||
"""
|
||||
"case_keys": {"confirmation_number": "CNF-V2-LEGACY-EMAIL-001"},
|
||||
"extracted_fields": {"booking_object_type": "FIT Reservation"}
|
||||
""");
|
||||
|
||||
mockMvc.perform(signedPost(body, "nonce-v2-task-result-legacy-email-channel-001"))
|
||||
.andExpect(status().isCreated())
|
||||
.andExpect(jsonPath("$.accepted_count").value(1))
|
||||
.andExpect(jsonPath("$.items[0].route_code").value("R01_NEW_FIT_RESERVATION_NORMAL"));
|
||||
|
||||
Long transitionCount = jdbcTemplate.queryForObject("""
|
||||
SELECT COUNT(*)
|
||||
FROM workflow_reservation_ai_transition
|
||||
WHERE source_message_id = ?
|
||||
AND route_code = 'R01_NEW_FIT_RESERVATION_NORMAL'
|
||||
""", Long.class, source.inboxId());
|
||||
assertThat(transitionCount).isEqualTo(1L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectV2TaskResultAsAmbiguousEvenWhenLegacySourceChannelProvided() throws Exception {
|
||||
String externalMessageId = "mail-v2-task-result-ambiguous-legacy-channel-001";
|
||||
captureSourceMessage(externalMessageId, "HOTEL-TEST", "EMAIL");
|
||||
captureSourceMessage(externalMessageId, "HOTEL-TEST", "OUTLOOK");
|
||||
String body = minimalBodyWithHotelAndSourceMetadata(
|
||||
externalMessageId,
|
||||
"AGENTBUS",
|
||||
"EMAIL",
|
||||
"New Booking",
|
||||
"normal_task",
|
||||
"new_fit_reservation",
|
||||
"""
|
||||
"case_keys": {"confirmation_number": "CNF-V2-AMBIGUOUS-001"},
|
||||
"extracted_fields": {"booking_object_type": "FIT Reservation"}
|
||||
""");
|
||||
|
||||
mockMvc.perform(signedPost(body, "nonce-v2-task-result-ambiguous-legacy-channel-001"))
|
||||
.andExpect(status().isConflict())
|
||||
.andExpect(jsonPath("$.error_code").value("SOURCE_MESSAGE_AMBIGUOUS"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldPreferNumericExternalSourceMessageIdBeforeLegacyInternalId() throws Exception {
|
||||
String externalMessageId = "1234567890123456789";
|
||||
SourceMessageCaptureResult source = captureSourceMessage(externalMessageId, "HOTEL-TEST", "OUTLOOK");
|
||||
String body = minimalBody(
|
||||
externalMessageId,
|
||||
"New Booking",
|
||||
"normal_task",
|
||||
"new_fit_reservation",
|
||||
"""
|
||||
"case_keys": {"confirmation_number": "CNF-V2-NUMERIC-001"},
|
||||
"extracted_fields": {"booking_object_type": "FIT Reservation"}
|
||||
""");
|
||||
|
||||
mockMvc.perform(signedPost(body, "nonce-v2-task-result-numeric-external-001"))
|
||||
.andExpect(status().isCreated())
|
||||
.andExpect(jsonPath("$.accepted_count").value(1))
|
||||
.andExpect(jsonPath("$.items[0].route_code").value("R01_NEW_FIT_RESERVATION_NORMAL"));
|
||||
|
||||
Long transitionCount = jdbcTemplate.queryForObject("""
|
||||
SELECT COUNT(*)
|
||||
FROM workflow_reservation_ai_transition
|
||||
WHERE source_message_id = ?
|
||||
AND route_code = 'R01_NEW_FIT_RESERVATION_NORMAL'
|
||||
""", Long.class, source.inboxId());
|
||||
assertThat(transitionCount).isEqualTo(1L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldAcceptSourceMessageOnlyTextResultWhenSourceMessageStoredAsOutlookChannel() throws Exception {
|
||||
String externalMessageId = "mail-s000-outlook-channel-001";
|
||||
SourceMessageCaptureResult source = captureSourceMessage(externalMessageId, "HOTEL-TEST", "OUTLOOK");
|
||||
|
||||
mockMvc.perform(signedPlainPost("S000," + externalMessageId, "nonce-s000-outlook-channel-001"))
|
||||
.andExpect(status().isCreated())
|
||||
.andExpect(jsonPath("$.accepted_count").value(1))
|
||||
.andExpect(jsonPath("$.items[0].system_process_category").value("SOURCE_MESSAGE_NOTIFICATION"));
|
||||
|
||||
Long transitionCount = jdbcTemplate.queryForObject("""
|
||||
SELECT COUNT(*)
|
||||
FROM workflow_reservation_ai_transition
|
||||
WHERE source_message_id = ?
|
||||
AND route_code = 'S000'
|
||||
AND system_process_category = 'SOURCE_MESSAGE_NOTIFICATION'
|
||||
""", Long.class, source.inboxId());
|
||||
assertThat(transitionCount).isEqualTo(1L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldCreateBusinessTaskFromV3BusinessRootWhenSourceMessageStoredAsOutlookChannel() throws Exception {
|
||||
String externalMessageId = """
|
||||
AAMkAGY5YWM5OTNlLTQ1OTktNDQ1Yi05NTk5LTgwNDIzNTAzY2Q4YgBGAAAAAAApDY1IkXlgT51ii6WOFxDvBwAuw1c78vBZRqpnmlmQMZnLAAAAAAEMAAAuw1c78vBZRqpnmlmQMZnLAAKAkhoCAAA=
|
||||
""".trim();
|
||||
SourceMessageCaptureResult source = captureSourceMessage(externalMessageId, "HOTEL-TEST", "OUTLOOK");
|
||||
String body = v3BusinessRootBody(externalMessageId, "CNF-V3-OUTLOOK-001");
|
||||
|
||||
mockMvc.perform(signedPost(body, "nonce-v3-business-root-outlook-001"))
|
||||
.andExpect(status().isCreated())
|
||||
.andExpect(jsonPath("$.accepted_count").value(1))
|
||||
.andExpect(jsonPath("$.items[0].route_code").value("R01_NEW_FIT_RESERVATION_NORMAL"))
|
||||
.andExpect(jsonPath("$.items[0].system_process_category").value("BUSINESS_TASK"));
|
||||
|
||||
Long transitionCount = jdbcTemplate.queryForObject("""
|
||||
SELECT COUNT(*)
|
||||
FROM workflow_reservation_ai_transition
|
||||
WHERE source_message_id = ?
|
||||
AND route_code = 'R01_NEW_FIT_RESERVATION_NORMAL'
|
||||
AND system_process_category = 'BUSINESS_TASK'
|
||||
""", Long.class, source.inboxId());
|
||||
assertThat(transitionCount).isEqualTo(1L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectV3BusinessRootWhenExternalSourceMessageMatchesMultipleChannels() throws Exception {
|
||||
String externalMessageId = "mail-v3-business-root-ambiguous-channel-001";
|
||||
captureSourceMessage(externalMessageId, "HOTEL-TEST", "EMAIL");
|
||||
captureSourceMessage(externalMessageId, "HOTEL-TEST", "OUTLOOK");
|
||||
String body = v3BusinessRootBody(externalMessageId, "CNF-V3-AMBIGUOUS-001");
|
||||
|
||||
mockMvc.perform(signedPost(body, "nonce-v3-business-root-ambiguous-channel-001"))
|
||||
.andExpect(status().isConflict())
|
||||
.andExpect(jsonPath("$.error_code").value("SOURCE_MESSAGE_AMBIGUOUS"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldConfirmV3BusinessTaskWithoutLegacyVisibleReason() throws Exception {
|
||||
captureSourceMessage("mail-v3-confirm-without-legacy-visible-reason-001");
|
||||
@@ -2560,13 +2759,21 @@ class SuperAgentTaskResultControllerTest {
|
||||
}
|
||||
|
||||
private SourceMessageCaptureResult captureSourceMessage(String externalMessageId, String hotelId) {
|
||||
return captureSourceMessage(externalMessageId, hotelId, "EMAIL");
|
||||
}
|
||||
|
||||
private SourceMessageCaptureResult captureSourceMessage(String externalMessageId, String hotelId, String channel) {
|
||||
String providerFrameId = "frame-" + externalMessageId;
|
||||
if (providerFrameId.length() > 128) {
|
||||
providerFrameId = "frame-" + Integer.toHexString(externalMessageId.hashCode());
|
||||
}
|
||||
return captureService.capture(new CaptureSourceMessageCommand(
|
||||
hotelId,
|
||||
"AGENTBUS",
|
||||
"EMAIL",
|
||||
channel,
|
||||
externalMessageId,
|
||||
"thread-" + externalMessageId,
|
||||
"frame-" + externalMessageId,
|
||||
providerFrameId,
|
||||
"session-m002",
|
||||
Instant.parse("2026-07-07T08:00:00Z"),
|
||||
"guest@example.test",
|
||||
@@ -2686,6 +2893,44 @@ class SuperAgentTaskResultControllerTest {
|
||||
""".formatted(sourceMessageId, resultType, taskType, taskSubtype, itemFields);
|
||||
}
|
||||
|
||||
private String minimalBodyWithHotelAndSourceMetadata(
|
||||
String sourceMessageId,
|
||||
String sourceProvider,
|
||||
String sourceChannel,
|
||||
String taskType,
|
||||
String resultType,
|
||||
String taskSubtype,
|
||||
String itemFields) {
|
||||
return """
|
||||
{
|
||||
"hotel_id": "HOTEL-TEST",
|
||||
"source_provider": "%s",
|
||||
"source_channel": "%s",
|
||||
"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(sourceProvider, sourceChannel, sourceMessageId, resultType, taskType, taskSubtype, itemFields);
|
||||
}
|
||||
|
||||
private String twoTaskBody(String sourceMessageId, String groupCode) {
|
||||
return """
|
||||
{
|
||||
@@ -2820,6 +3065,79 @@ class SuperAgentTaskResultControllerTest {
|
||||
""".formatted(externalSourceMessageId, confirmationNumber, arrivalDate, departureDate);
|
||||
}
|
||||
|
||||
private String v3BusinessRootBody(String externalSourceMessageId, String confirmationNumber) {
|
||||
return """
|
||||
{
|
||||
"source_message": {
|
||||
"source_message_id": "%s",
|
||||
"subject": "Outlook new booking",
|
||||
"from": null,
|
||||
"cc": [],
|
||||
"received_at": null,
|
||||
"source_channel": "Email"
|
||||
},
|
||||
"message_events": [
|
||||
{
|
||||
"event_type": "New Booking",
|
||||
"event_role": "travel_agent_request",
|
||||
"source_event_index": "E1",
|
||||
"current_or_history": "current",
|
||||
"case_keys": {
|
||||
"group_code": null,
|
||||
"confirmation_number": "%s",
|
||||
"reservation_number": null,
|
||||
"block_code": null
|
||||
},
|
||||
"relevant_message_excerpt": "Please create a new FIT reservation.",
|
||||
"attachments": [],
|
||||
"file_references": [],
|
||||
"context_used": {},
|
||||
"extracted_fields": {
|
||||
"booking_object_type": "FIT Reservation",
|
||||
"arrival_date": "2026-09-01"
|
||||
},
|
||||
"manual_review": null
|
||||
}
|
||||
],
|
||||
"case_candidates": [],
|
||||
"extraction_warnings": [],
|
||||
"unhandled_current_intents": []
|
||||
}
|
||||
""".formatted(externalSourceMessageId, confirmationNumber);
|
||||
}
|
||||
|
||||
private String structuredS10Body(String externalSourceMessageId) {
|
||||
return """
|
||||
{
|
||||
"source_message": {
|
||||
"source_message_id": "%s",
|
||||
"subject": null,
|
||||
"from": null,
|
||||
"cc": [],
|
||||
"received_at": null,
|
||||
"source_channel": "Email"
|
||||
},
|
||||
"route_code": "S10",
|
||||
"handler_type": "main_agent_outcome",
|
||||
"result_type": "source_message_review_notification",
|
||||
"current_or_history": "current",
|
||||
"agent_assessment": {
|
||||
"status": "no_booking_action_detected",
|
||||
"reason_code": "no_booking_action_detected",
|
||||
"automation_action": "none"
|
||||
},
|
||||
"notification": {
|
||||
"required": true,
|
||||
"notification_type": "source_message_review",
|
||||
"show_source_message": true,
|
||||
"requires_user_decision": true,
|
||||
"visible_message": "未匹配到当前 Agent 支持的业务事件类型,请查看原邮件并决定是否需要回复或进行其他处理。"
|
||||
},
|
||||
"manual_review": null
|
||||
}
|
||||
""".formatted(externalSourceMessageId);
|
||||
}
|
||||
|
||||
private String[] createReadyTaskWithTwoOperaOperations(
|
||||
String externalMessageId,
|
||||
String nonce,
|
||||
|
||||
@@ -304,6 +304,109 @@ class SuperAgentTaskResultP0FixtureRegressionTest {
|
||||
assertThat(transitionCount).isEqualTo(3L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldNormalizeSingleParentBlockCodeAndCreateParentSplitTask() throws Exception {
|
||||
ObjectNode root = fixture("parent_split_two_children.json").deepCopy();
|
||||
useParentSplitBusinessKeys(root, "PARENT-2608-BLOCK", "CHILD-2608-BLOCK-A", "CHILD-2608-BLOCK-B");
|
||||
convertParentSplitRootToP01(root);
|
||||
ObjectNode parentEvent = (ObjectNode) root.path("message_events").get(2);
|
||||
ObjectNode parentCaseKeys = (ObjectNode) parentEvent.path("case_keys");
|
||||
parentCaseKeys.putNull("group_code");
|
||||
parentCaseKeys.put("block_code", "PARENT-2608-BLOCK");
|
||||
String externalId = "p0-parent-split-single-block-code-001";
|
||||
SourceMessageCaptureResult source = captureSourceMessage(externalId);
|
||||
|
||||
mockMvc.perform(signedPost(withSourceMessageId(root, externalId), "nonce-p0-parent-single-block-001"))
|
||||
.andExpect(status().isCreated())
|
||||
.andExpect(jsonPath("$.accepted_count").value(3))
|
||||
.andExpect(jsonPath("$.items[0].route_code").value("R02_NEW_GROUP_BLOCK_NORMAL"))
|
||||
.andExpect(jsonPath("$.items[1].route_code").value("R02_NEW_GROUP_BLOCK_NORMAL"))
|
||||
.andExpect(jsonPath("$.items[2].route_code").value("R08_CANCEL_ALLOTMENT_CONTROL_BLOCK_NORMAL"));
|
||||
|
||||
String parentCaseKeysJson = jdbcTemplate.queryForObject("""
|
||||
SELECT case_keys_json
|
||||
FROM workflow_reservation_ai_transition
|
||||
WHERE source_message_id = ?
|
||||
AND route_code = 'R08_CANCEL_ALLOTMENT_CONTROL_BLOCK_NORMAL'
|
||||
""", String.class, source.inboxId());
|
||||
JsonNode normalizedCaseKeys = objectMapper.readTree(parentCaseKeysJson);
|
||||
assertThat(normalizedCaseKeys.path("group_code").asText()).isEqualTo("PARENT-2608-BLOCK");
|
||||
assertThat(normalizedCaseKeys.path("block_code").asText()).isEqualTo("PARENT-2608-BLOCK");
|
||||
|
||||
String parentPayloadJson = jdbcTemplate.queryForObject("""
|
||||
SELECT ai_payload_json
|
||||
FROM workflow_reservation_ai_transition
|
||||
WHERE source_message_id = ?
|
||||
AND route_code = 'R08_CANCEL_ALLOTMENT_CONTROL_BLOCK_NORMAL'
|
||||
""", String.class, source.inboxId());
|
||||
JsonNode parentPayload = objectMapper.readTree(parentPayloadJson);
|
||||
assertThat(parentPayload.path("case_keys").path("group_code").asText()).isEqualTo("PARENT-2608-BLOCK");
|
||||
assertThat(parentPayload.path("v3_message_event").path("case_keys").path("group_code").isNull()).isTrue();
|
||||
assertThat(parentPayload.path("v3_message_event").path("case_keys").path("block_code").asText())
|
||||
.isEqualTo("PARENT-2608-BLOCK");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldCreateParentSplitManualReviewWhenParentKeysConflict() throws Exception {
|
||||
ObjectNode root = fixture("parent_split_two_children.json").deepCopy();
|
||||
useParentSplitBusinessKeys(root, "PARENT-2608-CONFLICT", "CHILD-2608-CONFLICT-A", "CHILD-2608-CONFLICT-B");
|
||||
convertParentSplitRootToP01(root);
|
||||
ObjectNode parentEvent = (ObjectNode) root.path("message_events").get(2);
|
||||
ObjectNode parentCaseKeys = (ObjectNode) parentEvent.path("case_keys");
|
||||
parentCaseKeys.putNull("group_code");
|
||||
parentCaseKeys.putNull("block_code");
|
||||
ObjectNode contextUsed = (ObjectNode) parentEvent.path("context_used");
|
||||
ArrayNode candidates = contextUsed.putArray("parent_identity_candidates");
|
||||
candidates.addObject()
|
||||
.put("field", "group_code")
|
||||
.put("value", "PARENT-2608-CONFLICT-A")
|
||||
.put("evidence_source", "subject");
|
||||
candidates.addObject()
|
||||
.put("field", "block_code")
|
||||
.put("value", "PARENT-2608-CONFLICT-B")
|
||||
.put("evidence_source", "attachment");
|
||||
ObjectNode manualReview = parentEvent.putObject("manual_review");
|
||||
manualReview.put("reason_code", "target_object_unclear");
|
||||
manualReview.put("visible_reason", "Parent Group 的 group_code 与 block_code 原始候选冲突,请人工确认目标。");
|
||||
manualReview.put("review_record_type", "business_event_review");
|
||||
manualReview.putArray("missing_fields")
|
||||
.add("/case_keys/group_code")
|
||||
.add("/case_keys/block_code");
|
||||
manualReview.putArray("blocking_points")
|
||||
.add("Parent Group identity cannot be safely normalized.");
|
||||
manualReview.putArray("conflicting_points")
|
||||
.add("PARENT-2608-CONFLICT-A")
|
||||
.add("PARENT-2608-CONFLICT-B");
|
||||
manualReview.putArray("suggested_human_actions")
|
||||
.add("confirm_parent_group_identity");
|
||||
manualReview.putArray("evidence_to_check")
|
||||
.add("parent_identity_candidates");
|
||||
manualReview.putObject("known_fields");
|
||||
String externalId = "p0-parent-split-key-conflict-001";
|
||||
SourceMessageCaptureResult source = captureSourceMessage(externalId);
|
||||
|
||||
mockMvc.perform(signedPost(withSourceMessageId(root, externalId), "nonce-p0-parent-key-conflict-001"))
|
||||
.andExpect(status().isCreated())
|
||||
.andExpect(jsonPath("$.accepted_count").value(3))
|
||||
.andExpect(jsonPath("$.items[0].route_code").value("R02_NEW_GROUP_BLOCK_NORMAL"))
|
||||
.andExpect(jsonPath("$.items[1].route_code").value("R02_NEW_GROUP_BLOCK_NORMAL"))
|
||||
.andExpect(jsonPath("$.items[2].route_code").value("R08_CANCEL_ALLOTMENT_CONTROL_BLOCK_REVIEW"))
|
||||
.andExpect(jsonPath("$.items[2].adapter_error_code").doesNotExist());
|
||||
|
||||
Long parentReviewTaskCount = jdbcTemplate.queryForObject("""
|
||||
SELECT COUNT(*)
|
||||
FROM workflow_reservation_task_card card
|
||||
JOIN workflow_reservation_task task
|
||||
ON task.id = card.task_id
|
||||
JOIN workflow_reservation_ai_transition transition
|
||||
ON transition.id = task.ai_transition_id
|
||||
WHERE task.source_message_id = ?
|
||||
AND transition.route_code = 'R08_CANCEL_ALLOTMENT_CONTROL_BLOCK_REVIEW'
|
||||
AND task.result_type = 'manual_review'
|
||||
""", Long.class, source.inboxId());
|
||||
assertThat(parentReviewTaskCount).isEqualTo(1L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldFailClosedWhenCurrentProducerUsesLegacyParentCancelBooking() throws Exception {
|
||||
ObjectNode root = fixture("parent_split_two_children.json").deepCopy();
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
package cn.nianxx.thhotel.workflows.reservation.control;
|
||||
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
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;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
@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",
|
||||
"superagent.task-result.allow-legacy-internal-source-message-id=false"
|
||||
})
|
||||
@AutoConfigureMockMvc
|
||||
@ActiveProfiles("test")
|
||||
class SuperAgentTaskResultStrictSourceMessageIdControllerTest {
|
||||
|
||||
private static final String ENDPOINT = "/api/integrations/superagent/task-results";
|
||||
private static final String CLIENT_ID = "superagent-test-client";
|
||||
private static final String SECRET = "test-superagent-secret";
|
||||
|
||||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@Autowired
|
||||
private SourceMessageCaptureService captureService;
|
||||
|
||||
@Test
|
||||
void shouldRejectInternalSourceMessageIdWhenLegacyCompatibilityDisabled() throws Exception {
|
||||
SourceMessageCaptureResult source = captureSourceMessage("mail-strict-external-id-only-001");
|
||||
String body = """
|
||||
{
|
||||
"source_message_id": "%s",
|
||||
"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",
|
||||
"visible_reason": "AI extracted a task result.",
|
||||
"relevant_message_excerpt": "Please handle booking message.",
|
||||
"attachments": [],
|
||||
"file_references": [],
|
||||
"context_used": {},
|
||||
"case_keys": {},
|
||||
"extracted_fields": {},
|
||||
"additional_operations": [],
|
||||
"idempotency_key": null
|
||||
}
|
||||
],
|
||||
"extraction_warnings": []
|
||||
}
|
||||
""".formatted(source.inboxId());
|
||||
|
||||
String timestamp = Instant.now().toString();
|
||||
String nonce = "nonce-strict-external-id-only-001";
|
||||
mockMvc.perform(post(ENDPOINT)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(body)
|
||||
.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(body, nonce, timestamp)))
|
||||
.andExpect(status().isNotFound())
|
||||
.andExpect(jsonPath("$.error_code").value("SOURCE_MESSAGE_NOT_FOUND"));
|
||||
}
|
||||
|
||||
private SourceMessageCaptureResult captureSourceMessage(String externalMessageId) {
|
||||
return captureService.capture(new CaptureSourceMessageCommand(
|
||||
"HOTEL-TEST",
|
||||
"AGENTBUS",
|
||||
"OUTLOOK",
|
||||
externalMessageId,
|
||||
"thread-" + externalMessageId,
|
||||
"frame-" + externalMessageId,
|
||||
"session-m002",
|
||||
Instant.parse("2026-07-07T08:00:00Z"),
|
||||
"guest@example.test",
|
||||
"M002 SuperAgent intake",
|
||||
"Please handle booking message.",
|
||||
"<html><body>Please handle booking message.</body></html>",
|
||||
"{\"source\":{\"external_message_id\":\"" + externalMessageId + "\"}}",
|
||||
"agentbus-outlook-v1",
|
||||
List.of()
|
||||
));
|
||||
}
|
||||
|
||||
private String signature(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