修复 M002 V3 P0 回归校验问题
This commit is contained in:
@@ -36,7 +36,9 @@ import java.time.LocalDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HexFormat;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
@@ -65,6 +67,86 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta
|
||||
private static final int LENGTH_256 = 256;
|
||||
private static final int LENGTH_512 = 512;
|
||||
private static final int MAX_QUEUE_ORDER_RETRY = 5;
|
||||
private static final List<String> V3_SOURCE_MESSAGE_KEYS = List.of(
|
||||
"source_message_id",
|
||||
"subject",
|
||||
"from",
|
||||
"cc",
|
||||
"received_at",
|
||||
"source_channel");
|
||||
private static final List<String> V3_MAIN_OUTCOME_KEYS = List.of(
|
||||
"source_message",
|
||||
"route_code",
|
||||
"handler_type",
|
||||
"result_type",
|
||||
"current_or_history",
|
||||
"agent_assessment",
|
||||
"notification",
|
||||
"manual_review");
|
||||
private static final List<String> V3_AGENT_ASSESSMENT_KEYS = List.of(
|
||||
"status",
|
||||
"reason_code",
|
||||
"automation_action");
|
||||
private static final List<String> V3_NOTIFICATION_KEYS = List.of(
|
||||
"required",
|
||||
"notification_type",
|
||||
"show_source_message",
|
||||
"requires_user_decision",
|
||||
"visible_message");
|
||||
private static final List<String> V3_REVIEW_KEYS = List.of(
|
||||
"reason_code",
|
||||
"visible_reason",
|
||||
"review_record_type",
|
||||
"missing_fields",
|
||||
"blocking_points",
|
||||
"conflicting_points",
|
||||
"suggested_human_actions",
|
||||
"evidence_to_check",
|
||||
"known_fields");
|
||||
private static final List<String> V3_BUSINESS_ROOT_KEYS = List.of(
|
||||
"source_message",
|
||||
"message_events",
|
||||
"case_candidates",
|
||||
"extraction_warnings",
|
||||
"unhandled_current_intents");
|
||||
private static final List<String> V3_EVENT_REQUIRED_KEYS = List.of(
|
||||
"event_type",
|
||||
"event_role",
|
||||
"current_or_history",
|
||||
"source_event_index",
|
||||
"case_keys",
|
||||
"relevant_message_excerpt",
|
||||
"attachments",
|
||||
"file_references",
|
||||
"context_used",
|
||||
"extracted_fields",
|
||||
"manual_review");
|
||||
private static final List<String> V3_CASE_KEY_KEYS = List.of(
|
||||
"group_code",
|
||||
"confirmation_number",
|
||||
"reservation_number",
|
||||
"block_code");
|
||||
private static final List<String> V3_REVIEW_ARRAY_KEYS = List.of(
|
||||
"missing_fields",
|
||||
"blocking_points",
|
||||
"conflicting_points",
|
||||
"suggested_human_actions",
|
||||
"evidence_to_check");
|
||||
private static final Set<String> V3_ACTIVE_EVENT_TYPES = Set.of(
|
||||
"New Booking",
|
||||
"Update Booking / Amendment",
|
||||
"Cancel Booking",
|
||||
"Cancel Allotment",
|
||||
"Voucher Received",
|
||||
"Payment Evidence",
|
||||
"Rooming List",
|
||||
"AMEND GROUP CODE",
|
||||
"Invoice Generation",
|
||||
"Invoice Received",
|
||||
"Payment Notice",
|
||||
"Trace",
|
||||
"Manual RateCode",
|
||||
"TA RECORDER");
|
||||
|
||||
private final ObjectMapper objectMapper;
|
||||
private final SourceMessageInboxRepository sourceMessageInboxRepository;
|
||||
@@ -192,7 +274,11 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta
|
||||
String requestId,
|
||||
String defaultHotelId,
|
||||
String rawBody) {
|
||||
String routeCode = requireText(textAt(root, "route_code"), "route_code", LENGTH_64);
|
||||
String routeCode = trimToNull(textAt(root, "route_code"));
|
||||
if (routeCode == null) {
|
||||
throw error(HttpStatus.BAD_REQUEST, "ADAPTER_CONTRACT_ERROR", "S10/S99 route_code 缺失。");
|
||||
}
|
||||
validateLength(routeCode, "route_code", LENGTH_64);
|
||||
ReservationAiRouteDefinition route = ReservationAiRouteDefinition.findByRouteCode(routeCode)
|
||||
.filter(ReservationAiRouteDefinition::sourceMessageNotification)
|
||||
.orElseThrow(() -> error(HttpStatus.BAD_REQUEST, "ADAPTER_CONTRACT_ERROR", "S10/S99 route_code 无效。"));
|
||||
@@ -226,6 +312,10 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta
|
||||
String requestId,
|
||||
String defaultHotelId,
|
||||
String rawBody) {
|
||||
V3EventContractIssue rootIssue = inspectV3BusinessRootIssue(root);
|
||||
if (rootIssue != null) {
|
||||
throw error(HttpStatus.BAD_REQUEST, "ADAPTER_CONTRACT_ERROR", rootIssue.message());
|
||||
}
|
||||
ResolvedSourceMessage resolvedSourceMessage = resolveV3SourceMessage(root, defaultHotelId);
|
||||
SourceMessageInboxSnapshot sourceMessage = resolvedSourceMessage.snapshot();
|
||||
String hotelId = sourceMessage.hotelId();
|
||||
@@ -277,7 +367,7 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta
|
||||
for (int index = 0; index < messageEvents.size(); index++) {
|
||||
JsonNode event = messageEvents.get(index);
|
||||
int arrayIndex = index + 1;
|
||||
V3EventContractIssue contractIssue = inspectV3EventContractIssue(event);
|
||||
V3EventContractIssue contractIssue = inspectV3EventContractIssue(event, messageEvents);
|
||||
if (contractIssue != null) {
|
||||
responseItems.add(createAdapterContractErrorTransition(
|
||||
hotelId,
|
||||
@@ -336,11 +426,34 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验 V3 业务根的批次级 P0 结构;单个事件内容错误由 event 级 transition 承接。
|
||||
*/
|
||||
private V3EventContractIssue inspectV3BusinessRootIssue(JsonNode root) {
|
||||
if (!hasExactFields(root, V3_BUSINESS_ROOT_KEYS)
|
||||
|| !root.path("message_events").isArray()
|
||||
|| !root.path("case_candidates").isArray()
|
||||
|| !root.path("extraction_warnings").isArray()
|
||||
|| !root.path("unhandled_current_intents").isArray()) {
|
||||
return new V3EventContractIssue("BUSINESS_ROOT_CONTRACT_INVALID", "V3 业务根结构不符合 0711 P0 契约。");
|
||||
}
|
||||
V3EventContractIssue sourceMessageIssue = inspectV3SourceMessageIssue(root.path("source_message"));
|
||||
if (sourceMessageIssue != null) {
|
||||
return sourceMessageIssue;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验结构化 S10/S99 的最小形态,避免把联合值或空复核误收为正常通知。
|
||||
*/
|
||||
private void validateV3SourceMessageNotificationShape(JsonNode root, ReservationAiRouteDefinition route) {
|
||||
if (!"main_agent_outcome".equals(textAt(root, "handler_type"))
|
||||
V3EventContractIssue sourceMessageIssue = inspectV3SourceMessageIssue(root.path("source_message"));
|
||||
if (!hasExactFields(root, V3_MAIN_OUTCOME_KEYS)
|
||||
|| sourceMessageIssue != null
|
||||
|| !hasExactFields(root.path("agent_assessment"), V3_AGENT_ASSESSMENT_KEYS)
|
||||
|| !hasExactFields(root.path("notification"), V3_NOTIFICATION_KEYS)
|
||||
|| !"main_agent_outcome".equals(textAt(root, "handler_type"))
|
||||
|| !AiResultType.SOURCE_MESSAGE_REVIEW_NOTIFICATION.code().equals(textAt(root, "result_type"))
|
||||
|| !"current".equals(textAt(root, "current_or_history"))) {
|
||||
throw error(HttpStatus.BAD_REQUEST, "ADAPTER_CONTRACT_ERROR", "S10/S99 根结构字段无效。");
|
||||
@@ -379,23 +492,115 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta
|
||||
* 校验 S99 main_agent_entry_review 的 P0 九字段结构。
|
||||
*/
|
||||
private V3EventContractIssue inspectV3MainAgentReviewIssue(JsonNode manualReview) {
|
||||
if (!manualReview.isObject()) {
|
||||
return new V3EventContractIssue("MAIN_AGENT_REVIEW_INVALID", "manual_review 必须是对象。");
|
||||
}
|
||||
if (!"main_agent_entry_review".equals(textAt(manualReview, "review_record_type"))
|
||||
|| trimToNull(textAt(manualReview, "reason_code")) == null
|
||||
|| trimToNull(textAt(manualReview, "visible_reason")) == null
|
||||
|| !manualReview.path("known_fields").isObject()
|
||||
|| !manualReview.path("missing_fields").isArray()
|
||||
|| !manualReview.path("blocking_points").isArray()
|
||||
|| !manualReview.path("conflicting_points").isArray()
|
||||
|| !manualReview.path("suggested_human_actions").isArray()
|
||||
|| !manualReview.path("evidence_to_check").isArray()) {
|
||||
return new V3EventContractIssue("MAIN_AGENT_REVIEW_INCOMPLETE", "manual_review 九字段不完整。");
|
||||
return inspectV3ReviewIssue(
|
||||
manualReview,
|
||||
"main_agent_entry_review",
|
||||
null,
|
||||
"MAIN_AGENT_REVIEW_INVALID",
|
||||
"MAIN_AGENT_REVIEW_INCOMPLETE",
|
||||
"MAIN_AGENT_REVIEW_MISSING_FIELD_POINTER_INVALID");
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验 V3 SourceMessage 元数据;source_message_id 缺失由入口预检查返回 typed infrastructure error。
|
||||
*/
|
||||
private V3EventContractIssue inspectV3SourceMessageIssue(JsonNode sourceMessage) {
|
||||
if (!hasExactFields(sourceMessage, V3_SOURCE_MESSAGE_KEYS)
|
||||
|| trimToNull(textAt(sourceMessage, "source_message_id")) == null
|
||||
|| !isStringOrNull(sourceMessage.path("subject"))
|
||||
|| !isStringOrNull(sourceMessage.path("from"))
|
||||
|| !isStringArray(sourceMessage.path("cc"), false)
|
||||
|| !isStringOrNull(sourceMessage.path("received_at"))
|
||||
|| !"Email".equals(textAt(sourceMessage, "source_channel"))) {
|
||||
return new V3EventContractIssue("SOURCE_MESSAGE_CONTRACT_INVALID", "source_message 结构不符合 0711 P0 契约。");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验 V3 单个 message_event 的基础形态;缺字段时 fail closed,不猜测任务含义。
|
||||
*/
|
||||
private V3EventContractIssue inspectV3BusinessEventShapeIssue(JsonNode event) {
|
||||
if (event == null || !event.isObject() || !hasRequiredFields(event, V3_EVENT_REQUIRED_KEYS)) {
|
||||
return new V3EventContractIssue("EVENT_CONTRACT_INCOMPLETE", "message_event 必填字段不完整。");
|
||||
}
|
||||
if ((!V3_ACTIVE_EVENT_TYPES.contains(textAt(event, "event_type"))
|
||||
&& !"Need Manual Review".equals(textAt(event, "event_type")))
|
||||
|| trimToNull(textAt(event, "event_role")) == null
|
||||
|| !"current".equals(textAt(event, "current_or_history"))
|
||||
|| trimToNull(textAt(event, "source_event_index")) == null
|
||||
|| inspectV3CaseKeysIssue(event.path("case_keys")) != null
|
||||
|| !event.path("relevant_message_excerpt").isTextual()
|
||||
|| !event.path("attachments").isArray()
|
||||
|| !event.path("file_references").isArray()
|
||||
|| !event.path("context_used").isObject()
|
||||
|| !event.path("extracted_fields").isObject()) {
|
||||
return new V3EventContractIssue("EVENT_CONTRACT_INCOMPLETE", "message_event 字段类型或基础值无效。");
|
||||
}
|
||||
JsonNode manualReview = event.get("manual_review");
|
||||
if (!manualReview.isNull() && !manualReview.isObject()) {
|
||||
return new V3EventContractIssue("EVENT_CONTRACT_INCOMPLETE", "message_event.manual_review 必须为 null 或对象。");
|
||||
}
|
||||
if ("Need Manual Review".equals(textAt(event, "event_type")) && manualReview.isNull()) {
|
||||
return new V3EventContractIssue("EVENT_CONTRACT_INCOMPLETE", "Need Manual Review 必须携带 manual_review。");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验 case_keys 的 P0 四字段结构。
|
||||
*/
|
||||
private V3EventContractIssue inspectV3CaseKeysIssue(JsonNode caseKeys) {
|
||||
if (!hasExactFields(caseKeys, V3_CASE_KEY_KEYS)) {
|
||||
return new V3EventContractIssue("CASE_KEYS_CONTRACT_INVALID", "case_keys 结构不符合 0711 P0 契约。");
|
||||
}
|
||||
for (String key : V3_CASE_KEY_KEYS) {
|
||||
if (!isStringOrNull(caseKeys.path(key))) {
|
||||
return new V3EventContractIssue("CASE_KEYS_CONTRACT_INVALID", "case_keys 字段必须为 string 或 null。");
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 复用 0711 P0 manual_review 九字段结构校验,业务事件复核还要确认缺失字段 pointer 能定位到当前事件。
|
||||
*/
|
||||
private V3EventContractIssue inspectV3ReviewIssue(
|
||||
JsonNode manualReview,
|
||||
String expectedRecordType,
|
||||
JsonNode businessEvent,
|
||||
String invalidCode,
|
||||
String incompleteCode,
|
||||
String pointerInvalidCode) {
|
||||
if (manualReview == null || !manualReview.isObject()) {
|
||||
return new V3EventContractIssue(invalidCode, "manual_review 必须是对象。");
|
||||
}
|
||||
if (!hasExactFields(manualReview, V3_REVIEW_KEYS)
|
||||
|| trimToNull(textAt(manualReview, "reason_code")) == null
|
||||
|| trimToNull(textAt(manualReview, "visible_reason")) == null
|
||||
|| !expectedRecordType.equals(textAt(manualReview, "review_record_type"))
|
||||
|| !manualReview.path("known_fields").isObject()) {
|
||||
return new V3EventContractIssue(incompleteCode, "manual_review 九字段不完整。");
|
||||
}
|
||||
for (String arrayKey : V3_REVIEW_ARRAY_KEYS) {
|
||||
if (!isStringArray(manualReview.path(arrayKey), false)) {
|
||||
return new V3EventContractIssue(incompleteCode, "manual_review 数组字段必须只包含字符串。");
|
||||
}
|
||||
}
|
||||
if (businessEvent != null) {
|
||||
for (JsonNode pointer : manualReview.path("missing_fields")) {
|
||||
String pointerValue = pointer.isTextual() ? pointer.asText() : null;
|
||||
if (!jsonPointerTargetExists(businessEvent, pointerValue)) {
|
||||
return new V3EventContractIssue(
|
||||
pointerInvalidCode,
|
||||
"manual_review.missing_fields 必须使用能定位当前事件字段的 RFC 6901 JSON Pointer。");
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 将 V3 业务 event 转为旧任务结果 item 形态,复用现有订单、任务和任务卡创建逻辑。
|
||||
*/
|
||||
@@ -516,21 +721,25 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta
|
||||
/**
|
||||
* 识别 V3 event 中已经明确暴露的契约问题。命中后该 event 不建业务卡,只保存 transition。
|
||||
*/
|
||||
private V3EventContractIssue inspectV3EventContractIssue(JsonNode event) {
|
||||
private V3EventContractIssue inspectV3EventContractIssue(JsonNode event, JsonNode messageEvents) {
|
||||
if (nonEmptyArray(event, "contract_errors")) {
|
||||
return new V3EventContractIssue("EVENT_CONTRACT_ERROR", "message_event 携带 contract_errors,第一版不建业务任务。");
|
||||
}
|
||||
if (nonEmptyArray(event, "missing_fields")) {
|
||||
return new V3EventContractIssue("EVENT_CONTRACT_INCOMPLETE", "message_event 根节点携带 missing_fields,第一版不建业务任务。");
|
||||
}
|
||||
V3EventContractIssue shapeIssue = inspectV3BusinessEventShapeIssue(event);
|
||||
if (shapeIssue != null) {
|
||||
return shapeIssue;
|
||||
}
|
||||
JsonNode manualReview = event == null ? null : event.get("manual_review");
|
||||
if (!isNullOrMissing(manualReview)) {
|
||||
V3EventContractIssue manualReviewIssue = inspectV3ManualReviewContractIssue(manualReview);
|
||||
V3EventContractIssue manualReviewIssue = inspectV3ManualReviewContractIssue(manualReview, event);
|
||||
if (manualReviewIssue != null) {
|
||||
return manualReviewIssue;
|
||||
}
|
||||
}
|
||||
if (isLinkedParentReleaseCandidate(event) && !validLinkedParentReleaseCandidate(event)) {
|
||||
if (isLinkedParentReleaseCandidate(event) && !validLinkedParentReleaseCandidate(event, messageEvents)) {
|
||||
return new V3EventContractIssue(
|
||||
"LINKED_PARENT_RELEASE_CONTRACT_INCOMPLETE",
|
||||
"linked_parent_release_after_child_split 关系字段不完整,第一版不建业务任务。");
|
||||
@@ -541,33 +750,14 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta
|
||||
/**
|
||||
* 校验 V3 type-known manual_review 的九字段基础结构,后续同卡复核解阻会继续校验缺失字段。
|
||||
*/
|
||||
private V3EventContractIssue inspectV3ManualReviewContractIssue(JsonNode manualReview) {
|
||||
if (!manualReview.isObject()) {
|
||||
return new V3EventContractIssue("MANUAL_REVIEW_CONTRACT_INVALID", "manual_review 必须是对象。");
|
||||
}
|
||||
String reviewRecordType = trimToNull(textAt(manualReview, "review_record_type"));
|
||||
String reasonCode = trimToNull(textAt(manualReview, "reason_code"));
|
||||
String visibleReason = trimToNull(textAt(manualReview, "visible_reason"));
|
||||
if (!"business_event_review".equals(reviewRecordType) || reasonCode == null || visibleReason == null) {
|
||||
return new V3EventContractIssue("MANUAL_REVIEW_CONTRACT_INCOMPLETE", "manual_review 九字段不完整。");
|
||||
}
|
||||
if (!manualReview.path("known_fields").isObject()
|
||||
|| !manualReview.path("missing_fields").isArray()
|
||||
|| !manualReview.path("blocking_points").isArray()
|
||||
|| !manualReview.path("conflicting_points").isArray()
|
||||
|| !manualReview.path("suggested_human_actions").isArray()
|
||||
|| !manualReview.path("evidence_to_check").isArray()) {
|
||||
return new V3EventContractIssue("MANUAL_REVIEW_CONTRACT_INCOMPLETE", "manual_review 九字段不完整。");
|
||||
}
|
||||
for (JsonNode pointer : manualReview.path("missing_fields")) {
|
||||
String pointerValue = pointer.isTextual() ? trimToNull(pointer.asText()) : null;
|
||||
if (pointerValue == null || !pointerValue.startsWith("/")) {
|
||||
return new V3EventContractIssue(
|
||||
"MANUAL_REVIEW_MISSING_FIELD_POINTER_INVALID",
|
||||
"manual_review.missing_fields 必须使用 RFC 6901 JSON Pointer。");
|
||||
}
|
||||
}
|
||||
return null;
|
||||
private V3EventContractIssue inspectV3ManualReviewContractIssue(JsonNode manualReview, JsonNode event) {
|
||||
return inspectV3ReviewIssue(
|
||||
manualReview,
|
||||
"business_event_review",
|
||||
event,
|
||||
"MANUAL_REVIEW_CONTRACT_INVALID",
|
||||
"MANUAL_REVIEW_CONTRACT_INCOMPLETE",
|
||||
"MANUAL_REVIEW_MISSING_FIELD_POINTER_INVALID");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -581,14 +771,42 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta
|
||||
/**
|
||||
* 校验 parent split 候选的最小可追溯关系字段,避免只凭 relationship_type 建业务任务。
|
||||
*/
|
||||
private boolean validLinkedParentReleaseCandidate(JsonNode event) {
|
||||
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");
|
||||
return "Cancel Booking".equals(trimToNull(textAt(event, "event_type")))
|
||||
if (!("Cancel Booking".equals(trimToNull(textAt(event, "event_type")))
|
||||
&& "group_block".equals(firstText(extractedFields, event, "cancel_object_type"))
|
||||
&& trimToNull(textAt(caseKeys, "group_code")) != null
|
||||
&& nonEmptyArray(extractedFields, "child_group_codes")
|
||||
&& nonEmptyArray(event, "related_source_event_indices");
|
||||
&& nonEmptyArray(event, "related_source_event_indices"))) {
|
||||
return false;
|
||||
}
|
||||
List<String> childGroupCodes = stringArrayValues(extractedFields.path("child_group_codes"));
|
||||
List<String> relatedSourceEventIndices = stringArrayValues(event.path("related_source_event_indices"));
|
||||
if (childGroupCodes.isEmpty()
|
||||
|| relatedSourceEventIndices.isEmpty()
|
||||
|| childGroupCodes.size() != relatedSourceEventIndices.size()) {
|
||||
return false;
|
||||
}
|
||||
Set<String> expectedChildGroups = new LinkedHashSet<>(childGroupCodes);
|
||||
Set<String> relatedEventIds = new LinkedHashSet<>(relatedSourceEventIndices);
|
||||
if (expectedChildGroups.size() != childGroupCodes.size()
|
||||
|| relatedEventIds.size() != relatedSourceEventIndices.size()) {
|
||||
return false;
|
||||
}
|
||||
Set<String> actualChildGroups = new LinkedHashSet<>();
|
||||
for (String sourceEventIndex : relatedSourceEventIndices) {
|
||||
JsonNode childEvent = findV3MessageEventBySourceEventIndex(messageEvents, sourceEventIndex);
|
||||
if (childEvent == null || !"New Booking".equals(trimToNull(textAt(childEvent, "event_type")))) {
|
||||
return false;
|
||||
}
|
||||
String childGroupCode = trimToNull(textAt(childEvent.path("case_keys"), "group_code"));
|
||||
if (childGroupCode == null || !expectedChildGroups.contains(childGroupCode)) {
|
||||
return false;
|
||||
}
|
||||
actualChildGroups.add(childGroupCode);
|
||||
}
|
||||
return actualChildGroups.equals(expectedChildGroups);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1469,10 +1687,13 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta
|
||||
* V3 或入口网关形态输入必须先有非空 source_message_id;纯 V2 兼容结构不受该预检查影响。
|
||||
*/
|
||||
private boolean isV3SourceMessageIdentityMissing(JsonNode root) {
|
||||
if (root == null || !root.has("source_message") || !isV3OrGatewayInputShape(root)) {
|
||||
if (root == null || !isV3OrGatewayInputShape(root)) {
|
||||
return false;
|
||||
}
|
||||
return trimToNull(textAt(root.path("source_message"), "source_message_id")) == null;
|
||||
JsonNode sourceMessage = root.get("source_message");
|
||||
return sourceMessage == null
|
||||
|| !sourceMessage.isObject()
|
||||
|| trimToNull(textAt(sourceMessage, "source_message_id")) == null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1713,6 +1934,189 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta
|
||||
return value != null && value.isArray() && value.size() > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验对象字段集合必须与 P0 契约完全一致。
|
||||
*/
|
||||
private boolean hasExactFields(JsonNode node, List<String> expectedFields) {
|
||||
if (node == null || !node.isObject() || node.size() != expectedFields.size()) {
|
||||
return false;
|
||||
}
|
||||
for (String fieldName : expectedFields) {
|
||||
if (!node.has(fieldName)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验对象必须包含指定字段;允许 P0 event 的关系扩展字段继续保留。
|
||||
*/
|
||||
private boolean hasRequiredFields(JsonNode node, List<String> requiredFields) {
|
||||
if (node == null || !node.isObject()) {
|
||||
return false;
|
||||
}
|
||||
for (String fieldName : requiredFields) {
|
||||
if (!node.has(fieldName)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断字段值是否为字符串或 null。
|
||||
*/
|
||||
private boolean isStringOrNull(JsonNode node) {
|
||||
return node != null && (node.isNull() || node.isTextual());
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断字段值是否为字符串数组。
|
||||
*/
|
||||
private boolean isStringArray(JsonNode node, boolean nonempty) {
|
||||
if (node == null || !node.isArray() || (nonempty && node.isEmpty())) {
|
||||
return false;
|
||||
}
|
||||
for (JsonNode item : node) {
|
||||
if (!item.isTextual()) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取非空字符串数组;数组不存在或包含非字符串时返回空集合,交由调用方 fail closed。
|
||||
*/
|
||||
private List<String> stringArrayValues(JsonNode node) {
|
||||
if (node == null || !node.isArray()) {
|
||||
return List.of();
|
||||
}
|
||||
List<String> values = new ArrayList<>(node.size());
|
||||
for (JsonNode item : node) {
|
||||
String value = item.isTextual() ? trimToNull(item.asText()) : null;
|
||||
if (value == null) {
|
||||
return List.of();
|
||||
}
|
||||
values.add(value);
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 source_event_index 在同一 V3 根的 message_events 中查找事件。
|
||||
*/
|
||||
private JsonNode findV3MessageEventBySourceEventIndex(JsonNode messageEvents, String sourceEventIndex) {
|
||||
if (messageEvents == null || !messageEvents.isArray() || trimToNull(sourceEventIndex) == null) {
|
||||
return null;
|
||||
}
|
||||
for (JsonNode candidate : messageEvents) {
|
||||
if (sourceEventIndex.equals(trimToNull(textAt(candidate, "source_event_index")))) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断 RFC 6901 JSON Pointer 是否能定位到指定文档节点,null 值也视为有效目标。
|
||||
*/
|
||||
private boolean jsonPointerTargetExists(JsonNode document, String pointer) {
|
||||
List<String> tokens = jsonPointerTokens(pointer);
|
||||
if (tokens == null) {
|
||||
return false;
|
||||
}
|
||||
JsonNode current = document;
|
||||
for (String token : tokens) {
|
||||
if (current == null || current.isMissingNode()) {
|
||||
return false;
|
||||
}
|
||||
if (current.isObject()) {
|
||||
if (!current.has(token)) {
|
||||
return false;
|
||||
}
|
||||
current = current.get(token);
|
||||
continue;
|
||||
}
|
||||
if (current.isArray()) {
|
||||
Integer arrayIndex = jsonPointerArrayIndex(token);
|
||||
if (arrayIndex == null || arrayIndex >= current.size()) {
|
||||
return false;
|
||||
}
|
||||
current = current.get(arrayIndex);
|
||||
continue;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return current != null && !current.isMissingNode();
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 RFC 6901 JSON Pointer token,拒绝非法 ~ 转义。
|
||||
*/
|
||||
private List<String> jsonPointerTokens(String pointer) {
|
||||
String safePointer = trimToNull(pointer);
|
||||
if (safePointer == null || !safePointer.startsWith("/")) {
|
||||
return null;
|
||||
}
|
||||
String[] rawTokens = safePointer.substring(1).split("/", -1);
|
||||
List<String> tokens = new ArrayList<>(rawTokens.length);
|
||||
for (String rawToken : rawTokens) {
|
||||
String token = jsonPointerToken(rawToken);
|
||||
if (token == null) {
|
||||
return null;
|
||||
}
|
||||
tokens.add(token);
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析单个 RFC 6901 token。
|
||||
*/
|
||||
private String jsonPointerToken(String rawToken) {
|
||||
StringBuilder builder = new StringBuilder();
|
||||
for (int i = 0; i < rawToken.length(); i++) {
|
||||
char current = rawToken.charAt(i);
|
||||
if (current != '~') {
|
||||
builder.append(current);
|
||||
continue;
|
||||
}
|
||||
if (i + 1 >= rawToken.length()) {
|
||||
return null;
|
||||
}
|
||||
char escaped = rawToken.charAt(++i);
|
||||
if (escaped == '0') {
|
||||
builder.append('~');
|
||||
} else if (escaped == '1') {
|
||||
builder.append('/');
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return builder.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 JSON Pointer 数组下标,负数和非数字不接受。
|
||||
*/
|
||||
private Integer jsonPointerArrayIndex(String token) {
|
||||
if (token == null || token.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
for (int i = 0; i < token.length(); i++) {
|
||||
if (!Character.isDigit(token.charAt(i))) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
try {
|
||||
return Integer.valueOf(token);
|
||||
} catch (NumberFormatException exception) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断文本是否有效。
|
||||
*/
|
||||
|
||||
@@ -898,7 +898,7 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl
|
||||
if (!collapsed.equals(candidates.get(0))) {
|
||||
candidates.add(collapsed);
|
||||
}
|
||||
String legacyAlias = p0ReviewPointerLegacyAlias(collapsed);
|
||||
String legacyAlias = p0ReviewPointerLegacyAlias(candidates.get(0), collapsed);
|
||||
if (legacyAlias != null && !candidates.contains(legacyAlias)) {
|
||||
candidates.add(legacyAlias);
|
||||
}
|
||||
@@ -908,8 +908,9 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl
|
||||
/**
|
||||
* 兼容 0711 P0 fixtures 中已迁移为数组结构、但当前后端矩阵仍是扁平字段的复核 pointer。
|
||||
*/
|
||||
private String p0ReviewPointerLegacyAlias(String collapsedFieldPath) {
|
||||
if ("extracted_fields.room_items[].pms_room_type_code".equals(collapsedFieldPath)) {
|
||||
private String p0ReviewPointerLegacyAlias(String plainFieldPath, String collapsedFieldPath) {
|
||||
if ("extracted_fields.room_items.0.pms_room_type_code".equals(plainFieldPath)
|
||||
&& "extracted_fields.room_items[].pms_room_type_code".equals(collapsedFieldPath)) {
|
||||
return "extracted_fields.pms_room_type_code";
|
||||
}
|
||||
return null;
|
||||
|
||||
Reference in New Issue
Block a user