修复 M002 V3 P0 回归校验问题

This commit is contained in:
andy
2026-07-11 23:18:32 +08:00
parent 3ae3fea6bc
commit fd027f124c
7 changed files with 740 additions and 70 deletions

View File

@@ -6,7 +6,7 @@
| --- | --- |
| 文档版本 | 0.1 |
| 日期 | 2026-07-11 |
| 状态 | 0711 P0 基线确认版;后端已完成 M002 V3 CP1-CP5 入站、路由持久化、列表 / 详情展示同卡复核解阻第一版 |
| 状态 | 0711 P0 基线确认版;后端已完成 M002 V3 CP1-CP6 入站、路由持久化、列表 / 详情展示同卡复核解阻和 P0 fixtures 回归第一版 |
| 适用范围 | SourceMessage 之后的 SuperAgent 输出适配、任务路由、只读通知卡、人工复核同卡解阻、前后端协作边界 |
| 主要读者 | 产品、后端、前端、测试、SuperAgent 对接方、后续协作 agent |
@@ -288,7 +288,7 @@ Agent payload 不可变。本系统在同一张卡上维护复核状态:
- 订单归属确认可作为复核解阻的一部分保存;当前第一版只允许确认当前任务所属订单,不开放普通任务随意切换订单能力。
- 全部缺失字段、订单归属、目录值和依赖校验通过后,才进入 Preflight / READY。
当前 CP6 兼容说明0711 P0 fixtures 中 `room_items[]` 已经是新结构,但后端第一版字段矩阵仍使用 `extracted_fields.room_type``extracted_fields.room_quantity``extracted_fields.pms_room_type_code` 等扁平字段。入站 payload 保留原始 `room_items[]`;后端在字段读取、展示、校验和确认 payload 组装时,从第一条 `room_items[]` 做扁平字段别名解析。解阻接口接受 `/extracted_fields/room_items/{index}/pms_room_type_code`响应中的矩阵 `field_path` 仍返回当前后端字段 `extracted_fields.pms_room_type_code`。后续字段矩阵整体迁移时再取消这层兼容。
当前 CP6 兼容说明0711 P0 fixtures 中 `room_items[]` 已经是新结构,但后端第一版字段矩阵仍使用 `extracted_fields.room_type``extracted_fields.room_quantity``extracted_fields.pms_room_type_code` 等扁平字段。入站 payload 保留原始 `room_items[]`;后端在字段读取、展示、校验和确认 payload 组装时,从第一条 `room_items[]` 做扁平字段别名解析。解阻接口第一版仅接受 `/extracted_fields/room_items/0/pms_room_type_code` 兼容到当前扁平矩阵字段,其他数组下标不会被自动 alias响应中的矩阵 `field_path` 仍返回当前后端字段 `extracted_fields.pms_room_type_code`。后续字段矩阵整体迁移时再取消这层兼容。
### 9.3 后端第一版接口

View File

@@ -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;
}
}
/**
* 判断文本是否有效。
*/

View File

@@ -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;

View File

@@ -850,12 +850,19 @@ class SuperAgentTaskResultControllerTest {
"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": "CNF-V3-NEW-001"
"confirmation_number": "CNF-V3-NEW-001",
"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"
@@ -942,12 +949,19 @@ class SuperAgentTaskResultControllerTest {
"message_events": [
{
"event_type": "Trace",
"event_role": "travel_agent_request",
"source_event_index": "E2",
"current_or_history": "current",
"case_keys": {
"group_code": "GRP-V3-ERR-001",
"confirmation_number": null
"confirmation_number": null,
"reservation_number": null,
"block_code": null
},
"relevant_message_excerpt": "Please add a trace note.",
"attachments": [],
"file_references": [],
"context_used": {},
"extracted_fields": {},
"manual_review": null
}
@@ -1000,12 +1014,19 @@ class SuperAgentTaskResultControllerTest {
"message_events": [
{
"event_type": "%s",
"event_role": "travel_agent_request",
"source_event_index": "E1",
"current_or_history": "current",
"case_keys": {
"group_code": "%s",
"confirmation_number": null
"confirmation_number": null,
"reservation_number": null,
"block_code": null
},
"relevant_message_excerpt": "Unsupported event with explicit contract error.",
"attachments": [],
"file_references": [],
"context_used": {},
"extracted_fields": {},
"contract_errors": [
{
@@ -1017,12 +1038,19 @@ class SuperAgentTaskResultControllerTest {
},
{
"event_type": "New Booking",
"event_role": "travel_agent_request",
"source_event_index": "E2",
"current_or_history": "current",
"case_keys": {
"group_code": null,
"confirmation_number": "CNF-V3-SIBLING-001"
"confirmation_number": "CNF-V3-SIBLING-001",
"reservation_number": null,
"block_code": null
},
"relevant_message_excerpt": "Please create sibling FIT reservation.",
"attachments": [],
"file_references": [],
"context_used": {},
"extracted_fields": {
"booking_object_type": "FIT Reservation"
},
@@ -1073,12 +1101,19 @@ class SuperAgentTaskResultControllerTest {
"message_events": [
{
"event_type": "Trace",
"event_role": "travel_agent_request",
"source_event_index": "E1",
"current_or_history": "current",
"case_keys": {
"group_code": "GRP-V3-FRONTEND-BLOCK-001",
"confirmation_number": null
"confirmation_number": null,
"reservation_number": null,
"block_code": null
},
"relevant_message_excerpt": "Trace event has explicit contract error.",
"attachments": [],
"file_references": [],
"context_used": {},
"extracted_fields": {},
"contract_errors": [
{
@@ -1090,12 +1125,19 @@ class SuperAgentTaskResultControllerTest {
},
{
"event_type": "New Booking",
"event_role": "travel_agent_request",
"source_event_index": "E2",
"current_or_history": "current",
"case_keys": {
"group_code": null,
"confirmation_number": "CNF-V3-FRONTEND-BLOCK-001"
"confirmation_number": "CNF-V3-FRONTEND-BLOCK-001",
"reservation_number": null,
"block_code": null
},
"relevant_message_excerpt": "Please create frontend-visible FIT reservation.",
"attachments": [],
"file_references": [],
"context_used": {},
"extracted_fields": {
"booking_object_type": "FIT Reservation"
},
@@ -1185,12 +1227,19 @@ class SuperAgentTaskResultControllerTest {
"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": "CNF-V3-MR-001"
"confirmation_number": "CNF-V3-MR-001",
"reservation_number": null,
"block_code": null
},
"relevant_message_excerpt": "Manual review payload is incomplete.",
"attachments": [],
"file_references": [],
"context_used": {},
"extracted_fields": {
"booking_object_type": "FIT Reservation"
},
@@ -2456,18 +2505,26 @@ class SuperAgentTaskResultControllerTest {
"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"
"confirmation_number": "%s",
"reservation_number": null,
"block_code": null
},
"relevant_message_excerpt": "Please create a new FIT reservation with manual room type review.",
"attachments": [],
"file_references": [],
"context_used": {},
"extracted_fields": {
"booking_object_type": "FIT Reservation",
"arrival_date": "%s",
"departure_date": "%s",
"room_quantity": 2,
"room_type": "Deluxe King"
"room_type": "Deluxe King",
"pms_room_type_code": null
},
"manual_review": {
"review_record_type": "business_event_review",

View File

@@ -158,6 +158,24 @@ class SuperAgentTaskResultP0FixtureRegressionTest {
.andExpect(jsonPath("$.result_type").doesNotExist());
}
@Test
void shouldReturnTypedInfrastructureInputErrorWhenV3ShapeMissesSourceMessageObject() throws Exception {
mockMvc.perform(signedPost("""
{
"body_current": "Please cancel group HD260710A",
"message_events": [],
"case_candidates": [],
"extraction_warnings": [],
"unhandled_current_intents": []
}
""", "nonce-p0-missing-source-object-001"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.result_type").value("infrastructure_input_error"))
.andExpect(jsonPath("$.error_code").value("missing_source_message_id"))
.andExpect(jsonPath("$.retryable").value(true))
.andExpect(jsonPath("$.missing_fields[0]").value("source_message.source_message_id"));
}
@Test
void shouldLetNonBlankSourceIdentityPassP0GuardWithoutTypedMissingError() throws Exception {
JsonNode validInput = caseItem(
@@ -197,9 +215,57 @@ class SuperAgentTaskResultP0FixtureRegressionTest {
.isEqualTo("Need Manual Review");
}
@Test
void shouldRejectS99WhenMainAgentReviewHasUnexpectedField() throws Exception {
JsonNode s99 = caseValue(fixture("main_outcomes.json").path("cases"), "legal_s99_material_unavailable");
ObjectNode copy = s99.deepCopy();
((ObjectNode) copy.path("manual_review")).put("unexpected_field", "not-allowed");
String externalId = "p0-s99-unexpected-review-field-001";
SourceMessageCaptureResult source = captureSourceMessage(externalId);
mockMvc.perform(signedPost(withSourceMessageId(copy, externalId), "nonce-p0-s99-extra-field-001"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.error_code").value("ADAPTER_CONTRACT_ERROR"));
Long taskCount = jdbcTemplate.queryForObject("""
SELECT COUNT(*)
FROM workflow_reservation_task
WHERE source_message_id = ?
""", Long.class, source.inboxId());
assertThat(taskCount).isZero();
}
@Test
void shouldRejectV3BusinessRootWhenP0RootArraysAreMissing() throws Exception {
ObjectNode root = objectMapper.createObjectNode();
ObjectNode sourceMessage = root.putObject("source_message");
String externalId = "p0-root-arrays-missing-001";
sourceMessage.put("source_message_id", externalId);
sourceMessage.put("subject", "P0 fixture");
sourceMessage.put("from", "agent@example.test");
sourceMessage.putArray("cc");
sourceMessage.put("received_at", "2026-07-11T10:00:00+08:00");
sourceMessage.put("source_channel", "Email");
root.putArray("message_events")
.add(fixture("row_multiple_derived.json").path("message_events").get(0));
SourceMessageCaptureResult source = captureSourceMessage(externalId);
mockMvc.perform(signedPost(objectMapper.writeValueAsString(root), "nonce-p0-root-arrays-missing-001"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.error_code").value("ADAPTER_CONTRACT_ERROR"));
Long taskCount = jdbcTemplate.queryForObject("""
SELECT COUNT(*)
FROM workflow_reservation_task
WHERE source_message_id = ?
""", Long.class, source.inboxId());
assertThat(taskCount).isZero();
}
@Test
void shouldCreateParentSplitTasksFromP0FixtureInEventOrder() throws Exception {
ObjectNode root = fixture("parent_split_two_children.json").deepCopy();
useParentSplitBusinessKeys(root, "PARENT-2608-OK", "CHILD-2608-OK-A", "CHILD-2608-OK-B");
String externalId = "p0-parent-split-001";
SourceMessageCaptureResult source = captureSourceMessage(externalId);
@@ -234,6 +300,33 @@ class SuperAgentTaskResultP0FixtureRegressionTest {
assertThat(transitionCount).isEqualTo(3L);
}
@Test
void shouldFailClosedWhenParentSplitReferencesDoNotMatchChildEvents() throws Exception {
ObjectNode root = fixture("parent_split_two_children.json").deepCopy();
useParentSplitBusinessKeys(root, "PARENT-2608-MIS", "CHILD-2608-MIS-A", "CHILD-2608-MIS-B");
ObjectNode parentEvent = (ObjectNode) root.path("message_events").get(2);
((ArrayNode) parentEvent.path("related_source_event_indices"))
.set(1, objectMapper.getNodeFactory().textNode("E_UNKNOWN_CHILD"));
String externalId = "p0-parent-split-mismatch-001";
SourceMessageCaptureResult source = captureSourceMessage(externalId);
mockMvc.perform(signedPost(withSourceMessageId(root, externalId), "nonce-p0-parent-split-mismatch-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].adapter_error_code")
.value("LINKED_PARENT_RELEASE_CONTRACT_INCOMPLETE"))
.andExpect(jsonPath("$.items[2].task_id").doesNotExist());
Long taskCount = jdbcTemplate.queryForObject("""
SELECT COUNT(*)
FROM workflow_reservation_task
WHERE source_message_id = ?
""", Long.class, source.inboxId());
assertThat(taskCount).isEqualTo(2L);
}
@Test
void shouldCreateRowDerivedMainParentAndTraceTasksFromP0Fixture() throws Exception {
ObjectNode root = fixture("row_multiple_derived.json").deepCopy();
@@ -255,6 +348,27 @@ class SuperAgentTaskResultP0FixtureRegressionTest {
assertThat(taskCount).isEqualTo(3L);
}
@Test
void shouldFailClosedWhenV3BusinessEventMissesRequiredField() throws Exception {
ObjectNode event = fixture("row_multiple_derived.json").path("message_events").get(0).deepCopy();
event.remove("attachments");
String externalId = "p0-event-required-field-missing-001";
SourceMessageCaptureResult source = captureSourceMessage(externalId);
mockMvc.perform(signedPost(businessRoot(externalId, event), "nonce-p0-event-required-missing-001"))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.accepted_count").value(1))
.andExpect(jsonPath("$.items[0].adapter_error_code").value("EVENT_CONTRACT_INCOMPLETE"))
.andExpect(jsonPath("$.items[0].task_id").doesNotExist());
Long taskCount = jdbcTemplate.queryForObject("""
SELECT COUNT(*)
FROM workflow_reservation_task
WHERE source_message_id = ?
""", Long.class, source.inboxId());
assertThat(taskCount).isZero();
}
@Test
void shouldPreserveMixedAllotmentUnhandledIntentAndRejectPartialAllotmentAsBusinessTask() throws Exception {
JsonNode fixture = fixture("allotment_scope.json");
@@ -286,6 +400,7 @@ class SuperAgentTaskResultP0FixtureRegressionTest {
void shouldCreateTypeKnownManualReviewAndResolveSameCardFromP0Fixture() throws Exception {
JsonNode manualReview = fixture("manual_review_resolution.json");
ObjectNode event = manualReview.path("known_subtype_manual_review").path("event").deepCopy();
useManualReviewGroupCode(event, "CHILD-SUITE-RESOLVE-001");
String externalId = "p0-manual-review-same-card-001";
SourceMessageCaptureResult source = captureSourceMessage(externalId);
String body = businessRoot(externalId, event);
@@ -347,6 +462,69 @@ class SuperAgentTaskResultP0FixtureRegressionTest {
assertThat(aiPayloadJson).doesNotContain("\"pms_room_type_code\":\"SU1\"");
}
@Test
void shouldFailClosedWhenManualReviewMissingFieldPointerDoesNotResolve() throws Exception {
ObjectNode event = fixture("manual_review_resolution.json")
.path("known_subtype_manual_review")
.path("event")
.deepCopy();
((ArrayNode) event.path("manual_review").path("missing_fields"))
.set(0, objectMapper.getNodeFactory()
.textNode("/extracted_fields/room_items/99/pms_room_type_code"));
String externalId = "p0-manual-review-invalid-pointer-001";
SourceMessageCaptureResult source = captureSourceMessage(externalId);
mockMvc.perform(signedPost(businessRoot(externalId, event), "nonce-p0-manual-review-invalid-pointer-001"))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.accepted_count").value(1))
.andExpect(jsonPath("$.items[0].adapter_error_code")
.value("MANUAL_REVIEW_MISSING_FIELD_POINTER_INVALID"))
.andExpect(jsonPath("$.items[0].task_id").doesNotExist());
Long taskCount = jdbcTemplate.queryForObject("""
SELECT COUNT(*)
FROM workflow_reservation_task
WHERE source_message_id = ?
""", Long.class, source.inboxId());
assertThat(taskCount).isZero();
}
@Test
void shouldRejectOutOfRangeRoomItemPointerWhenResolvingManualReview() throws Exception {
JsonNode manualReview = fixture("manual_review_resolution.json");
ObjectNode event = manualReview.path("known_subtype_manual_review").path("event").deepCopy();
useManualReviewGroupCode(event, "CHILD-SUITE-POINTER-001");
String externalId = "p0-manual-review-resolution-pointer-001";
captureSourceMessage(externalId);
MvcResult result = mockMvc.perform(signedPost(
businessRoot(externalId, event),
"nonce-p0-manual-review-resolution-pointer-create-001"))
.andExpect(status().isCreated())
.andReturn();
String taskId = com.jayway.jsonpath.JsonPath.read(
result.getResponse().getContentAsString(),
"$.items[0].task_id");
String orderId = com.jayway.jsonpath.JsonPath.read(
result.getResponse().getContentAsString(),
"$.items[0].order_id");
mockMvc.perform(post("/api/reservation/tasks/{taskId}/manual-review-resolutions", taskId)
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"confirmed_order_id": "%s",
"field_overrides": [
{
"field_pointer": "/extracted_fields/room_items/99/pms_room_type_code",
"value": "SU1"
}
]
}
""".formatted(orderId)))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.error_code").value("TASK_REVIEW_POINTER_INVALID"));
}
private JsonNode fixture(String fileName) throws Exception {
return objectMapper.readTree(FIXTURES_DIR.resolve(fileName).toFile());
}
@@ -397,6 +575,36 @@ class SuperAgentTaskResultP0FixtureRegressionTest {
return objectMapper.writeValueAsString(root);
}
private void useParentSplitBusinessKeys(
ObjectNode root,
String parentGroupCode,
String childGroupCodeOne,
String childGroupCodeTwo) {
ArrayNode events = (ArrayNode) root.path("message_events");
useEventGroupCode((ObjectNode) events.get(0), childGroupCodeOne);
useEventGroupCode((ObjectNode) events.get(1), childGroupCodeTwo);
((ObjectNode) events.get(0).path("extracted_fields")).put("parent_group_code", parentGroupCode);
((ObjectNode) events.get(1).path("extracted_fields")).put("parent_group_code", parentGroupCode);
ObjectNode parentEvent = (ObjectNode) events.get(2);
useEventGroupCode(parentEvent, parentGroupCode);
ObjectNode parentExtractedFields = (ObjectNode) parentEvent.path("extracted_fields");
parentExtractedFields.put("parent_group_code", parentGroupCode);
ArrayNode childGroupCodes = (ArrayNode) parentExtractedFields.path("child_group_codes");
childGroupCodes.removeAll();
childGroupCodes.add(childGroupCodeOne);
childGroupCodes.add(childGroupCodeTwo);
}
private void useManualReviewGroupCode(ObjectNode event, String groupCode) {
useEventGroupCode(event, groupCode);
((ObjectNode) event.path("manual_review").path("known_fields")).put("group_code", groupCode);
}
private void useEventGroupCode(ObjectNode event, String groupCode) {
((ObjectNode) event.path("case_keys")).put("group_code", groupCode);
}
private SourceMessageCaptureResult captureSourceMessage(String externalMessageId) {
return captureService.capture(new CaptureSourceMessageCommand(
"HOTEL-TEST",