From 3ecbf7eea8c80712b163196be45d152a96567f75 Mon Sep 17 00:00:00 2001 From: andy Date: Sat, 18 Jul 2026 23:05:15 +0700 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8DM002=20V4=E5=85=A5=E7=AB=99?= =?UTF-8?q?=E8=A7=A3=E6=9E=90=E8=BE=B9=E7=95=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- PROJECT_STATE.md | 7 +- .../backend-to-frontend-notes.md | 1 + .../integrations/superagent-api-contract.md | 3 +- .../M002-order-task-workflow-v3.md | 1 + .../M002-v4-agent-callback-field-contract.md | 10 +- .../ReservationAiTaskIntakeServiceImpl.java | 827 +++--------------- .../impl/ReservationAiV4CallbackIssue.java | 10 + .../impl/ReservationAiV4CallbackRouter.java | 239 +++++ .../ReservationAiV4CallbackValidator.java | 654 ++++++++++++++ .../impl/ReservationAiV4PackageContext.java | 12 + .../SuperAgentTaskResultControllerTest.java | 268 +++++- 11 files changed, 1326 insertions(+), 706 deletions(-) create mode 100644 server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationAiV4CallbackIssue.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationAiV4CallbackRouter.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationAiV4CallbackValidator.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationAiV4PackageContext.java diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index 1153eef..05c2cff 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -5,12 +5,12 @@ | 最近更新 | 2026-07-18 | | 当前分支 | `feature/huangting` | | 当前阶段 | M002 V4 入站基线与业务能力迭代并行 | -| 当前重点 | SuperAgent V4 回调入站解析、现有任务链路过渡适配和后续 V4 多卡模型规划 | +| 当前重点 | SuperAgent V4 回调入站解析修正版、现有任务链路过渡适配和后续 V4 多卡模型规划 | ## 1. 当前 Checkpoint - 名称:`M002-V4-CP1-agent-callback-intake-baseline` -- 状态:In Progress,当前后端实现、测试和文档同步中。 +- 状态:Done,已完成后端入站解析基线、review 修复、测试和文档同步。 - 目标:按 2026-07-18 V4 Agent 回调字段契约接收 `source_message + order_contexts[] + message_events[]`,完成入站解析、基础校验、路由适配和 adapter error 最小落库。 - 边界:不做完整 V4 多卡领域模型、不做 OPERA / OHIP、不做前端页面、不做历史数据迁移。 @@ -34,11 +34,10 @@ - `docs/import/` 下按日期导入的资料是输入材料,不等同于当前权威开发契约;当前开发应优先看 `docs/project/README.md` 标记为当前有效或权威契约的文档。 - 后续每完成一个 Feature 或 Checkpoint,需要更新本文件,避免项目状态继续沉淀在聊天记录里。 - M010 Rooming List Excel 生成后端 CP1 和前端 V1 已实现:前端 `/reservation/rooming-lists/new` 上传来源名单和手工字段,后端同步生成 `.xlsx` 直接下载,第一版不落库、不上传 OSS。 -- M002 V4 CP1 当前只完成入站解析和现有任务链路过渡适配;Basic Information 独立卡、V4 订单任务多卡模型和 V4 前端页面仍未完成。 +- M002 V4 CP1 当前已完成入站解析和现有任务链路过渡适配;Basic Information 独立卡、V4 订单任务多卡模型和 V4 前端页面仍未完成。 ## 5. Next Steps -- Review M002 V4 CP1 入站基线改动。 - 后续如继续做 M002 V4,应优先规划订单任务 + 多卡领域模型、Basic Information 独立卡、前端 V4 页面模型和目录校验能力。 - 后续新增重要功能时,优先在 `docs/project/requirements/` 或未来 `docs/specs/` 中形成 Spec,再实现代码。 - M010 后续如需预览、历史记录、OSS 下载、订单 / 任务预填或客户字段目录化,再单独开前后端 checkpoint。 diff --git a/docs/project/frontend-backend/backend-to-frontend-notes.md b/docs/project/frontend-backend/backend-to-frontend-notes.md index ab21592..2ef4fd6 100644 --- a/docs/project/frontend-backend/backend-to-frontend-notes.md +++ b/docs/project/frontend-backend/backend-to-frontend-notes.md @@ -456,6 +456,7 @@ RESERVATION_ROOMING_LIST_GENERATE - M002 V4 入站解析与数据模型基线已完成第一版:后端可接收 `source_message + order_contexts[] + message_events[]`,识别 `NEW_BOOKING`、`UPDATE_BOOKING`、`CANCEL_BOOKING`、`TRACE_RESERVATION_NOTES`、`ROOMING_LIST`、`PAYMENT`,并保存 V4 原始 payload、`route_code`、系统处理分类和 `field_contract_version=20260718-v4`。前端暂不需要直接调用 V4 回调接口。 - V4 可映射 event 现阶段仍复用现有任务详情结构;任务详情中若出现 `field_contract_version=20260718-v4` 或 AI payload 内的 `v4_source_message`、`v4_order_context`、`v4_message_event`,前端第一版只读展示即可,不要据此假定完整 V4 多卡页面已经完成。 - V4 `PAYMENT.attachment_ids[]` 不匹配、`UPDATE_BOOKING` 携带 `rate_code` 等问题会出现在任务详情同批次的 `adapter_contract_errors[]` 只读诊断块中,不展示保存、确认、执行或重试按钮。 +- V4 包级契约错误只会保存在 AI transition 中,不会出现在普通任务列表;V4 event 级契约错误如果同批次存在其它业务任务,前端仍按任务详情里的 `adapter_contract_errors[]` 只读展示诊断信息。 - M002 V3 的结构化 `S10/S99` 入站、40 条 P0.1 路由枚举 / 稳定配置、`UNHANDLED_CURRENT_INTENT`、`adapter_contract_error` transition 最小落库、任务列表 / 订单时间线 / 任务详情 V3 路由字段和只读诊断块透出、type-known manual review 同卡解阻第一版、typed infrastructure error、P0 fixtures 回归基线和 Parent Group / Cancel Allotment 路由修订均已完成。 - 系统管理后台 V1 已完成;后续若要做用户搜索更多筛选、批量操作、密码策略增强、MFA、登录设备管理,应单独开需求。 - 现有 Reservation / SourceMessage 业务接口的强制登录、强制权限和业务审计 actor 全量迁移仍后置。 diff --git a/docs/project/integrations/superagent-api-contract.md b/docs/project/integrations/superagent-api-contract.md index aa3fb84..61fb24d 100644 --- a/docs/project/integrations/superagent-api-contract.md +++ b/docs/project/integrations/superagent-api-contract.md @@ -4,7 +4,7 @@ | 项目 | 内容 | | --- | --- | -| 文档版本 | 0.8 | +| 文档版本 | 0.9 | | 日期 | 2026-07-18 | | 状态 | 当前代码契约已支持 V4 入站解析基线、V2 `ai_task_results[]` 兼容、结构化 S10/S99、V3 业务根基础解析、旧 S000/S999 兼容和单酒店 hotel_id 后端解析 | | 适用范围 | SuperAgent 调用本系统查询上下文、查询邮件会话、提交 AI 任务结果 | @@ -567,6 +567,7 @@ V4 字段说明: - 命中 SourceMessage 后保存 AI batch / transition,并按 `message_events[]` 顺序处理。 - 可映射 event 先复用现有订单 / 任务 / 任务卡链路,`ai_payload_json` 会保留 `v4_source_message`、`v4_order_context`、`v4_message_event`。 - `route_code=S10/S99` 创建只读 `SOURCE_MESSAGE_ONLY` 任务,任务列表可见,订单列表不可见。 +- V4 包级结构错误如果仍能通过 `source_message.source_message_id` 定位 SourceMessage,会返回成功接收并写入 `adapter_contract_error` transition;不创建订单、任务或用户可处理卡。`source_message_id` 缺失或找不到 SourceMessage 时仍返回明确错误。 - `PAYMENT.attachment_ids[]` 引用不存在的附件、`UPDATE_BOOKING` 携带 `rate_code`、以及其他 V4 event 契约错误,只写 `adapter_contract_error` transition,不创建用户可处理业务任务。 - 技术契约错误不会自动转为 S10/S99,也不会创建前端可处理业务任务。 diff --git a/docs/project/requirements/M002-order-task-workflow-v3.md b/docs/project/requirements/M002-order-task-workflow-v3.md index 3e53ca2..91c38ce 100644 --- a/docs/project/requirements/M002-order-task-workflow-v3.md +++ b/docs/project/requirements/M002-order-task-workflow-v3.md @@ -439,6 +439,7 @@ V3 P0.1 不做以下事项: - V4 包级 `route_code=S10/S99` 入站处理,复用 `SOURCE_MESSAGE_ONLY` 只读特殊任务;普通 V4 业务包要求 `route_code=null`。 - V4 业务根 `source_message + order_contexts[] + message_events[]` 基础解析;`source_message.source_message_id` 按 SourceMessage Inbox 的 `external_message_id` 反查邮件。 - V4 第一版识别 `NEW_BOOKING`、`UPDATE_BOOKING`、`CANCEL_BOOKING`、`TRACE_RESERVATION_NOTES`、`ROOMING_LIST`、`PAYMENT`,可映射 event 先复用现有订单 / 任务 / 任务卡链路,并保存 `catalog_code=M002V4`、`skill_id=booking-desk-event-v4`、`field_contract_version=20260718-v4` 和 V4 原始三元组 / 原始 event payload。 +- V4 包级契约错误在 `source_message.source_message_id` 可定位时只写 `adapter_contract_error` transition,不创建订单、任务或用户可处理卡;`source_message_id` 缺失或 SourceMessage 不存在时仍返回明确错误。 - V4 `PAYMENT.attachment_ids[]` 必须匹配 `source_message.attachments[].id`;V4 `UPDATE_BOOKING` 不接受 `rate_code` 或 `after.rate_code`;这类契约错误只落 `adapter_contract_error` transition,不创建用户可处理业务任务。 - 40 条 P0.1 路由枚举 / 稳定配置。 - V3 业务根 `source_message + message_events[]` 基础解析;能派生到稳定路由的 event 创建业务任务,无法派生的 event、显式 `contract_errors`、根 `missing_fields`、不完整 `manual_review`、当前 producer 旧 Parent Cancel Booking 和不完整 parent split 候选只落 `adapter_contract_error` transition。 diff --git a/docs/project/requirements/M002-v4-agent-callback-field-contract.md b/docs/project/requirements/M002-v4-agent-callback-field-contract.md index e0f3ead..2a9855a 100644 --- a/docs/project/requirements/M002-v4-agent-callback-field-contract.md +++ b/docs/project/requirements/M002-v4-agent-callback-field-contract.md @@ -4,7 +4,7 @@ | 项目 | 内容 | | --- | --- | -| 文档版本 | 1.1 | +| 文档版本 | 1.2 | | 日期 | 2026-07-18 | | 状态 | 当前 V4 字段基线;后端已完成 CP1 入站解析与数据模型基线,完整 V4 多卡模型仍需后续 checkpoint | | 适用范围 | 0718 业务基线下,Agent → Adapter / MCP → 信息系统的业务回调字段 | @@ -605,6 +605,12 @@ true 技术失败不属于酒店用户任务。后台 debug、告警和技术重试接口属于技术运维需求,不进入本次业务回调,也不在酒店用户任务卡上增加“重试”。 +当前后端实现口径: + +- 如果 V4 包级结构不符合契约,但 `source_message.source_message_id` 能按 SourceMessage Inbox 的 `external_message_id` 定位到邮件,后端会创建 AI batch,并写入一条 `catalog_code=M002V4`、`system_process_category=ADAPTER_CONTRACT_ERROR` 的 transition;不创建订单、任务或酒店用户可处理卡。 +- 如果 `source_message.source_message_id` 缺失、无法解析或无法定位 SourceMessage,后端仍返回明确请求错误,不创建 AI batch / transition。 +- 单个 `message_events[i]` 的契约错误只影响该 event,同包其它合法 event 继续按数组顺序处理。 + 当前项目可以保留 `platform_superagent_dispatch_run` 或同类技术运行记录作为主链路技术状态载体,后续另行设计查询、告警、超时和重试能力。 ## 20. 受控 code 目录 @@ -692,6 +698,8 @@ AI 回调包 - 普通业务包要求 `route_code=null`,并按 `message_events[]` 数组顺序处理。 - 第一版识别六类 `event_type`:`NEW_BOOKING`、`UPDATE_BOOKING`、`CANCEL_BOOKING`、`TRACE_RESERVATION_NOTES`、`ROOMING_LIST`、`PAYMENT`。 - 能映射到现有稳定任务卡的 event 会创建业务任务,并在 `ai_payload_json` 中保存 `v4_source_message`、`v4_order_context`、`v4_message_event`、`route_code`、系统处理分类和 `field_contract_version=20260718-v4`。 +- V4 入站校验和路由已拆分为独立 Validator / Router,主业务 service 只负责编排、幂等和落库。 +- V4 包级契约错误在 SourceMessage 可定位时只写 `adapter_contract_error` transition,不创建订单、任务或用户可处理卡。 - `PAYMENT.attachment_ids[]` 必须能匹配 `source_message.attachments[].id`;不匹配时只写 `adapter_contract_error` transition,不创建用户可处理业务任务。 - `UPDATE_BOOKING` 中出现 `rate_code` 或 `after.rate_code` 时按 `UPDATE_RATE_CODE_NOT_ALLOWED` 写入 `adapter_contract_error` transition。 - `manual_review` 只接受 `null` 或布尔 `true`;`true` 必须能由当前对象中可识别的未解决字段解释。 diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationAiTaskIntakeServiceImpl.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationAiTaskIntakeServiceImpl.java index 114331e..e03ce75 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationAiTaskIntakeServiceImpl.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationAiTaskIntakeServiceImpl.java @@ -59,7 +59,6 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta private static final Logger LOGGER = LoggerFactory.getLogger(ReservationAiTaskIntakeServiceImpl.class); private static final String FIELD_CONTRACT_VERSION = "20260711-p0"; - private static final String V4_FIELD_CONTRACT_VERSION = "20260718-v4"; private static final String BATCH_KEY_PREFIX = "superagent-task-result-batch:v1"; private static final String ITEM_KEY_PREFIX = "superagent-task-result-item:v1"; private static final String SOURCE_MESSAGE_ONLY_RESULT_TYPE = "source_message_only"; @@ -120,18 +119,6 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta "case_candidates", "extraction_warnings", "unhandled_current_intents"); - private static final Set V4_ALLOWED_EVENT_TYPES = Set.of( - "NEW_BOOKING", - "UPDATE_BOOKING", - "CANCEL_BOOKING", - "TRACE_RESERVATION_NOTES", - "ROOMING_LIST", - "PAYMENT"); - private static final Set V4_ALLOWED_BOOKING_TYPES = Set.of("GROUP", "FIT"); - private static final Set V4_ALLOWED_LOCATOR_TYPES = Set.of( - "GROUP_CODE", - "BOOKING_CODE", - "CONFIRMATION_NUMBER"); private static final List V3_EVENT_REQUIRED_KEYS = List.of( "event_type", "event_role", @@ -160,10 +147,12 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta private final ObjectMapper objectMapper; private final SourceMessageInboxRepository sourceMessageInboxRepository; private final ReservationAiWorkflowRepository workflowRepository; + private final ReservationAiV4CallbackValidator v4CallbackValidator; + private final ReservationAiV4CallbackRouter v4CallbackRouter; private final boolean allowLegacyInternalSourceMessageId; /** - * 注入 JSON 解析器、SourceMessage 查询边界、Reservation 持久化边界和本地旧夹具兼容开关。 + * 注入 JSON 解析器、SourceMessage 查询边界、Reservation 持久化边界、V4 入站适配器和本地旧夹具兼容开关。 */ public ReservationAiTaskIntakeServiceImpl( ObjectMapper objectMapper, @@ -174,6 +163,8 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta this.objectMapper = objectMapper; this.sourceMessageInboxRepository = sourceMessageInboxRepository; this.workflowRepository = workflowRepository; + this.v4CallbackValidator = new ReservationAiV4CallbackValidator(); + this.v4CallbackRouter = new ReservationAiV4CallbackRouter(objectMapper); this.allowLegacyInternalSourceMessageId = allowLegacyInternalSourceMessageId; } @@ -463,12 +454,21 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta String requestId, String defaultHotelId, String rawBody) { - validateV4PackageRoot(root, true); + ResolvedSourceMessage resolvedSourceMessage = resolveV3SourceMessage(root, defaultHotelId); + ReservationAiV4CallbackIssue packageIssue = v4CallbackValidator.inspectPackageRootIssue(root, true); + if (packageIssue != null) { + return acceptV4PackageAdapterContractError( + root, + clientId, + requestId, + rawBody, + resolvedSourceMessage, + packageIssue); + } String routeCode = trimToNull(textAt(root, "route_code")); ReservationAiRouteDefinition route = ReservationAiRouteDefinition.findByRouteCode(routeCode) .filter(ReservationAiRouteDefinition::sourceMessageNotification) .orElseThrow(() -> error(HttpStatus.BAD_REQUEST, "ADAPTER_CONTRACT_ERROR", "V4 通知 route_code 无效。")); - ResolvedSourceMessage resolvedSourceMessage = resolveV3SourceMessage(root, defaultHotelId); SourceMessageOnlyEntryResult entryResult = new SourceMessageOnlyEntryResult( SourceMessageOnlyResultCode.fromCode(route.taskSubtype()) .orElseThrow(() -> error(HttpStatus.BAD_REQUEST, "ADAPTER_CONTRACT_ERROR", "V4 通知结果码无效。")), @@ -494,8 +494,18 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta String requestId, String defaultHotelId, String rawBody) { - V4PackageContext packageContext = validateV4PackageRoot(root, false); ResolvedSourceMessage resolvedSourceMessage = resolveV3SourceMessage(root, defaultHotelId); + ReservationAiV4CallbackIssue packageIssue = v4CallbackValidator.inspectPackageRootIssue(root, false); + if (packageIssue != null) { + return acceptV4PackageAdapterContractError( + root, + clientId, + requestId, + rawBody, + resolvedSourceMessage, + packageIssue); + } + ReservationAiV4PackageContext packageContext = v4CallbackValidator.packageContext(root); SourceMessageInboxSnapshot sourceMessage = resolvedSourceMessage.snapshot(); String hotelId = sourceMessage.hotelId(); Long sourceMessageId = sourceMessage.id(); @@ -545,7 +555,10 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta for (int index = 0; index < messageEvents.size(); index++) { JsonNode event = messageEvents.get(index); int arrayIndex = index + 1; - V3EventContractIssue contractIssue = inspectV4EventContractIssue(event, packageContext, targetOrderByOrderRef); + ReservationAiV4CallbackIssue contractIssue = v4CallbackValidator.inspectEventContractIssue( + event, + packageContext, + targetOrderByOrderRef); if (contractIssue != null) { responseItems.add(createV4AdapterContractErrorTransition( hotelId, @@ -558,7 +571,7 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta now)); continue; } - ReservationAiRouteDefinition route = deriveV4EventRoute(event); + ReservationAiRouteDefinition route = v4CallbackRouter.deriveEventRoute(event); if (route == null) { responseItems.add(createV4AdapterContractErrorTransition( hotelId, @@ -571,7 +584,7 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta now)); continue; } - ObjectNode item = normalizeV4EventToTaskResultItem(root, event, route, arrayIndex); + ObjectNode item = v4CallbackRouter.normalizeEventToTaskResultItem(root, event, route, arrayIndex); responseItems.add(createItem(hotelId, sourceMessageId, batchId, item, arrayIndex, now)); } @@ -587,674 +600,71 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta } /** - * 校验 V4 包级结构,并提取后续 event 校验需要的 order_ref 和附件 ID。 + * 保存 V4 包级契约错误。SourceMessage 可定位时保留入站审计,但不创建用户可处理任务。 */ - private V4PackageContext validateV4PackageRoot(JsonNode root, boolean sourceMessageNotification) { - if (root == null - || !root.isObject() - || !root.has("route_code") - || !root.has("source_message") - || !root.path("order_contexts").isArray() - || !root.path("message_events").isArray()) { - throw error(HttpStatus.BAD_REQUEST, "ADAPTER_CONTRACT_ERROR", "V4 根结构字段不完整。"); - } - V3EventContractIssue sourceMessageIssue = inspectV4SourceMessageIssue(root.path("source_message")); - if (sourceMessageIssue != null) { - throw error(HttpStatus.BAD_REQUEST, "ADAPTER_CONTRACT_ERROR", sourceMessageIssue.message()); - } - Set attachmentIds = collectV4AttachmentIds(root.path("source_message").path("attachments")); - Set orderRefs = collectV4OrderRefs(root.path("order_contexts")); - String routeCode = trimToNull(textAt(root, "route_code")); - if (sourceMessageNotification) { - if (!("S10".equals(routeCode) || "S99".equals(routeCode))) { - throw error(HttpStatus.BAD_REQUEST, "ADAPTER_CONTRACT_ERROR", "V4 通知 route_code 只能为 S10 或 S99。"); - } - if (!root.path("order_contexts").isEmpty() || !root.path("message_events").isEmpty()) { - throw error(HttpStatus.BAD_REQUEST, "ADAPTER_CONTRACT_ERROR", "V4 通知不能携带 order_contexts 或 message_events。"); - } - return new V4PackageContext(orderRefs, attachmentIds); - } - if (routeCode != null) { - throw error(HttpStatus.BAD_REQUEST, "ADAPTER_CONTRACT_ERROR", "V4 普通业务 route_code 必须为 null。"); - } - if (orderRefs.isEmpty()) { - throw error(HttpStatus.BAD_REQUEST, "ADAPTER_CONTRACT_ERROR", "V4 普通业务 order_contexts 不能为空。"); - } - return new V4PackageContext(orderRefs, attachmentIds); - } - - /** - * 校验 V4 source_message 最小业务字段。正文只做结构校验,不在错误中回显原文。 - */ - private V3EventContractIssue inspectV4SourceMessageIssue(JsonNode sourceMessage) { - if (sourceMessage == null - || !sourceMessage.isObject() - || trimToNull(textAt(sourceMessage, "source_message_id")) == null - || !isStringOrNull(sourceMessage.path("conversation_id")) - || !isStringOrNull(sourceMessage.path("subject")) - || !isStringOrNull(sourceMessage.path("sender")) - || !isStringOrNull(sourceMessage.path("sent_at")) - || !isStringOrNull(sourceMessage.path("body")) - || !("text/plain".equals(textAt(sourceMessage, "body_content_type")) - || "text/html".equals(textAt(sourceMessage, "body_content_type"))) - || !sourceMessage.path("attachments").isArray()) { - return new V3EventContractIssue("SOURCE_MESSAGE_CONTRACT_INVALID", "V4 source_message 结构不符合契约。"); - } - for (JsonNode attachment : sourceMessage.path("attachments")) { - V3EventContractIssue attachmentIssue = inspectV4AttachmentIssue(attachment); - if (attachmentIssue != null) { - return attachmentIssue; - } - } - return null; - } - - /** - * 校验 V4 附件引用。URL 仅检查是否存在,不在日志或错误中输出。 - */ - private V3EventContractIssue inspectV4AttachmentIssue(JsonNode attachment) { - if (attachment == null - || !attachment.isObject() - || trimToNull(textAt(attachment, "id")) == null - || trimToNull(textAt(attachment, "name")) == null - || trimToNull(textAt(attachment, "content_type")) == null - || trimToNull(textAt(attachment, "url")) == null - || !(attachment.path("size").isMissingNode() - || attachment.path("size").isNull() - || attachment.path("size").isNumber())) { - return new V3EventContractIssue("SOURCE_MESSAGE_ATTACHMENT_INVALID", "V4 source_message.attachments 结构无效。"); - } - return null; - } - - /** - * 收集 V4 包级附件 ID,并拒绝重复附件 ID,保证 Payment 引用可唯一定位。 - */ - private Set collectV4AttachmentIds(JsonNode attachments) { - Set attachmentIds = new LinkedHashSet<>(); - for (JsonNode attachment : attachments) { - String attachmentId = trimToNull(textAt(attachment, "id")); - if (attachmentId == null || !attachmentIds.add(attachmentId)) { - throw error(HttpStatus.BAD_REQUEST, "ADAPTER_CONTRACT_ERROR", "V4 source_message.attachments.id 重复或缺失。"); - } - validateLength(attachmentId, "source_message.attachments.id", LENGTH_128); - } - return attachmentIds; - } - - /** - * 收集 V4 order_ref,并校验每个订单级 Basic Information 只出现一次。 - */ - private Set collectV4OrderRefs(JsonNode orderContexts) { - Set orderRefs = new LinkedHashSet<>(); - for (JsonNode orderContext : orderContexts) { - String orderRef = trimToNull(textAt(orderContext, "order_ref")); - if (orderRef == null || !orderRefs.add(orderRef)) { - throw error(HttpStatus.BAD_REQUEST, "ADAPTER_CONTRACT_ERROR", "V4 order_contexts.order_ref 重复或缺失。"); - } - validateLength(orderRef, "order_contexts.order_ref", LENGTH_128); - V3EventContractIssue basicIssue = inspectV4BasicInformationIssue(orderContext.path("basic_information")); - if (basicIssue != null) { - throw error(HttpStatus.BAD_REQUEST, "ADAPTER_CONTRACT_ERROR", basicIssue.message()); - } - } - return orderRefs; - } - - /** - * 校验订单级 Basic Information。V4 manual_review 只允许 null 或 true,true 必须由 account_code 缺失解释。 - */ - private V3EventContractIssue inspectV4BasicInformationIssue(JsonNode basicInformation) { - if (basicInformation == null - || !basicInformation.isObject() - || !basicInformation.has("account_code") - || !basicInformation.has("manual_review") - || !isStringOrNull(basicInformation.path("account_code")) - || !isV4ManualReviewNullOrTrue(basicInformation.path("manual_review"))) { - return new V3EventContractIssue("BASIC_INFORMATION_CONTRACT_INVALID", "V4 Basic Information 结构无效。"); - } - boolean manualReview = isBooleanTrue(basicInformation.path("manual_review")); - boolean accountUnresolved = basicInformation.path("account_code").isNull(); - if (manualReview != accountUnresolved) { - return new V3EventContractIssue( - "BASIC_INFORMATION_MANUAL_REVIEW_INVALID", - "V4 Basic Information manual_review 必须能由 account_code 缺失解释。"); - } - return null; - } - - /** - * 校验 V4 单个 event。业务字段错误只影响该 event,不阻断同包其他 sibling event。 - */ - private V3EventContractIssue inspectV4EventContractIssue( - JsonNode event, - V4PackageContext packageContext, - Map targetOrderByOrderRef) { - if (event == null - || !event.isObject() - || trimToNull(textAt(event, "order_ref")) == null - || trimToNull(textAt(event, "event_type")) == null - || !event.path("target_order").isObject() - || !event.has("manual_review") - || !isV4ManualReviewNullOrTrue(event.path("manual_review"))) { - return new V3EventContractIssue("EVENT_CONTRACT_INCOMPLETE", "V4 message_event 公共字段不完整。"); - } - String orderRef = trimToNull(textAt(event, "order_ref")); - if (!packageContext.orderRefs().contains(orderRef)) { - return new V3EventContractIssue("ORDER_REF_NOT_FOUND", "V4 message_event 引用了不存在的 order_ref。"); - } - String eventType = trimToNull(textAt(event, "event_type")); - if (!V4_ALLOWED_EVENT_TYPES.contains(eventType)) { - return new V3EventContractIssue("EVENT_TYPE_UNSUPPORTED", "V4 event_type 不在第一版支持范围。"); - } - V3EventContractIssue targetIssue = inspectV4TargetOrderIssue(event.path("target_order"), event.path("manual_review")); - if (targetIssue != null) { - return targetIssue; - } - V3EventContractIssue targetConsistencyIssue = inspectV4TargetOrderConsistency( - orderRef, - event.path("target_order"), - targetOrderByOrderRef); - if (targetConsistencyIssue != null) { - return targetConsistencyIssue; - } - V3EventContractIssue specificIssue = inspectV4EventSpecificIssue(event, packageContext); - if (specificIssue != null) { - return specificIssue; - } - return inspectV4ManualReviewConsistencyIssue(event); - } - - /** - * 校验 V4 target_order 合法组合。定位字段未解决时必须由 manual_review=true 承接。 - */ - private V3EventContractIssue inspectV4TargetOrderIssue(JsonNode targetOrder, JsonNode manualReview) { - if (!targetOrder.has("booking_type") - || !targetOrder.has("locator_type") - || !targetOrder.has("locator_value") - || !isStringOrNull(targetOrder.path("booking_type")) - || !isStringOrNull(targetOrder.path("locator_type")) - || !isStringOrNull(targetOrder.path("locator_value"))) { - return new V3EventContractIssue("TARGET_ORDER_CONTRACT_INVALID", "V4 target_order 结构无效。"); - } - boolean unresolved = hasV4TargetOrderUnresolvedMarker(targetOrder); - if (unresolved) { - return isBooleanTrue(manualReview) - ? null - : new V3EventContractIssue("TARGET_ORDER_MANUAL_REVIEW_REQUIRED", "V4 target_order 未解决时必须 manual_review=true。"); - } - String bookingType = textAt(targetOrder, "booking_type"); - String locatorType = textAt(targetOrder, "locator_type"); - if (!V4_ALLOWED_BOOKING_TYPES.contains(bookingType) || !V4_ALLOWED_LOCATOR_TYPES.contains(locatorType)) { - return new V3EventContractIssue("TARGET_ORDER_COMBINATION_INVALID", "V4 target_order 枚举值无效。"); - } - if ("GROUP".equals(bookingType) && !"GROUP_CODE".equals(locatorType)) { - return new V3EventContractIssue("TARGET_ORDER_COMBINATION_INVALID", "Group 订单只能使用 GROUP_CODE 定位。"); - } - if ("FIT".equals(bookingType) - && !("BOOKING_CODE".equals(locatorType) || "CONFIRMATION_NUMBER".equals(locatorType))) { - return new V3EventContractIssue("TARGET_ORDER_COMBINATION_INVALID", "Fit 订单只能使用 BOOKING_CODE 或 CONFIRMATION_NUMBER 定位。"); - } - return null; - } - - /** - * 校验同一 order_ref 下非空 target_order 必须一致,避免同一订单任务被拆错或合错。 - */ - private V3EventContractIssue inspectV4TargetOrderConsistency( - String orderRef, - JsonNode targetOrder, - Map targetOrderByOrderRef) { - if (hasV4TargetOrderUnresolvedMarker(targetOrder)) { - return null; - } - String key = textAt(targetOrder, "booking_type") - + "|" - + textAt(targetOrder, "locator_type") - + "|" - + textAt(targetOrder, "locator_value"); - String existing = targetOrderByOrderRef.putIfAbsent(orderRef, key); - if (existing != null && !existing.equals(key)) { - return new V3EventContractIssue("ORDER_REF_TARGET_ORDER_CONFLICT", "同一 order_ref 下 target_order 不一致。"); - } - return null; - } - - /** - * 校验 V4 event 专属字段。这里仅做入站基线和第一版 contract-error 分流,不执行完整业务确认。 - */ - private V3EventContractIssue inspectV4EventSpecificIssue(JsonNode event, V4PackageContext packageContext) { - return switch (textAt(event, "event_type")) { - case "NEW_BOOKING" -> inspectV4NewBookingIssue(event); - case "UPDATE_BOOKING" -> inspectV4UpdateBookingIssue(event); - case "CANCEL_BOOKING" -> null; - case "TRACE_RESERVATION_NOTES" -> inspectV4TraceIssue(event); - case "ROOMING_LIST" -> inspectV4RoomingListIssue(event); - case "PAYMENT" -> inspectV4PaymentIssue(event, packageContext); - default -> new V3EventContractIssue("EVENT_TYPE_UNSUPPORTED", "V4 event_type 不在第一版支持范围。"); - }; - } - - /** - * 校验 V4 New Booking 第一版必需字段和 room_items 结构。 - */ - private V3EventContractIssue inspectV4NewBookingIssue(JsonNode event) { - if (!event.has("arrival_date") - || !event.has("departure_date") - || !event.has("rate_code") - || !event.path("room_items").isArray() - || !isStringOrNull(event.path("arrival_date")) - || !isStringOrNull(event.path("departure_date")) - || !isStringOrNull(event.path("rate_code"))) { - return new V3EventContractIssue("NEW_BOOKING_CONTRACT_INVALID", "V4 NEW_BOOKING 字段不完整。"); - } - if ("GROUP".equals(textAt(event.path("target_order"), "booking_type")) - && !("STANDARD".equals(textAt(event, "booking_scenario")) - || "PROPOSAL".equals(textAt(event, "booking_scenario")))) { - return new V3EventContractIssue("NEW_BOOKING_SCENARIO_INVALID", "Group NEW_BOOKING booking_scenario 无效。"); - } - if ("FIT".equals(textAt(event.path("target_order"), "booking_type")) - && event.has("guest_name") - && !isStringOrNull(event.path("guest_name"))) { - return new V3EventContractIssue("NEW_BOOKING_GUEST_NAME_INVALID", "Fit NEW_BOOKING guest_name 必须为 string 或 null。"); - } - for (JsonNode roomItem : event.path("room_items")) { - if (!roomItem.isObject() - || !roomItem.has("room_type_code") - || !roomItem.has("room_count") - || !isStringOrNull(roomItem.path("room_type_code")) - || (!roomItem.path("room_count").isNull() - && (!roomItem.path("room_count").canConvertToInt() || roomItem.path("room_count").asInt() <= 0))) { - return new V3EventContractIssue("NEW_BOOKING_ROOM_ITEMS_INVALID", "V4 NEW_BOOKING room_items 结构无效。"); - } - } - return null; - } - - /** - * 校验 V4 Update Booking。Rate Code 在 Update 中属于业务契约错误,不能静默忽略。 - */ - private V3EventContractIssue inspectV4UpdateBookingIssue(JsonNode event) { - if (event.has("rate_code") || event.path("after").has("rate_code")) { - return new V3EventContractIssue("UPDATE_RATE_CODE_NOT_ALLOWED", "V4 UPDATE_BOOKING 不允许修改 rate_code。"); - } - JsonNode after = event.path("after"); - if (!after.isObject()) { - return new V3EventContractIssue("UPDATE_AFTER_CONTRACT_INVALID", "V4 UPDATE_BOOKING after 必须是对象。"); - } - if (after.has("room_items") - && !after.path("room_items").isNull() - && !after.path("room_items").isArray()) { - return new V3EventContractIssue("UPDATE_ROOM_ITEMS_CONTRACT_INVALID", "V4 UPDATE_BOOKING after.room_items 必须为 null 或数组。"); - } - if (after.path("room_items").isArray()) { - for (JsonNode roomItem : after.path("room_items")) { - if (!roomItem.isObject() - || !roomItem.has("room_type_code") - || !roomItem.has("room_count") - || !isStringOrNull(roomItem.path("room_type_code")) - || (!roomItem.path("room_count").isNull() - && (!roomItem.path("room_count").canConvertToInt() || roomItem.path("room_count").asInt() <= 0))) { - return new V3EventContractIssue("UPDATE_ROOM_ITEMS_CONTRACT_INVALID", "V4 UPDATE_BOOKING room_items 结构无效。"); - } - } - } - return null; - } - - /** - * 校验 V4 Trace 事项。EXTRA_BED 和 GENERAL 允许放在同一张 Trace 卡内。 - */ - private V3EventContractIssue inspectV4TraceIssue(JsonNode event) { - JsonNode traceItems = event.path("trace_items"); - if (!traceItems.isArray() || traceItems.isEmpty()) { - return new V3EventContractIssue("TRACE_ITEMS_CONTRACT_INVALID", "V4 TRACE_RESERVATION_NOTES trace_items 不能为空。"); - } - for (JsonNode traceItem : traceItems) { - String itemType = textAt(traceItem, "item_type"); - if ("GENERAL".equals(itemType)) { - if (!traceItem.has("text") - || !traceItem.has("department_code") - || !isStringOrNull(traceItem.path("text")) - || !isStringOrNull(traceItem.path("department_code"))) { - return new V3EventContractIssue("TRACE_GENERAL_ITEM_INVALID", "V4 GENERAL trace item 字段无效。"); - } - continue; - } - if ("EXTRA_BED".equals(itemType)) { - if (!traceItem.has("target_room_type_code") - || !traceItem.has("extra_bed_room_count") - || !traceItem.has("department_code") - || !isStringOrNull(traceItem.path("target_room_type_code")) - || !isStringOrNull(traceItem.path("department_code")) - || (!traceItem.path("extra_bed_room_count").isNull() - && (!traceItem.path("extra_bed_room_count").canConvertToInt() - || traceItem.path("extra_bed_room_count").asInt() <= 0))) { - return new V3EventContractIssue("TRACE_EXTRA_BED_ITEM_INVALID", "V4 EXTRA_BED trace item 字段无效。"); - } - continue; - } - return new V3EventContractIssue("TRACE_ITEM_TYPE_UNSUPPORTED", "V4 trace_items.item_type 只能为 GENERAL 或 EXTRA_BED。"); - } - return null; - } - - /** - * 校验 Rooming List 只适用于 Group,第一版不要求 rows 或附件 ID。 - */ - private V3EventContractIssue inspectV4RoomingListIssue(JsonNode event) { - if (!"GROUP".equals(textAt(event.path("target_order"), "booking_type"))) { - return new V3EventContractIssue("ROOMING_LIST_BOOKING_TYPE_INVALID", "V4 ROOMING_LIST 第一版只支持 Group。"); - } - return null; - } - - /** - * 校验 V4 Payment 附件引用。正常 Payment 必须引用同包已有附件 ID。 - */ - private V3EventContractIssue inspectV4PaymentIssue(JsonNode event, V4PackageContext packageContext) { - JsonNode attachmentIds = event.path("attachment_ids"); - if (!attachmentIds.isArray() || attachmentIds.isEmpty()) { - return new V3EventContractIssue("PAYMENT_ATTACHMENT_IDS_EMPTY", "V4 PAYMENT attachment_ids 不能为空。"); - } - Set seen = new LinkedHashSet<>(); - for (JsonNode attachmentIdNode : attachmentIds) { - String attachmentId = attachmentIdNode.isTextual() ? trimToNull(attachmentIdNode.asText()) : null; - if (attachmentId == null) { - return new V3EventContractIssue("PAYMENT_ATTACHMENT_ID_INVALID", "V4 PAYMENT attachment_ids 只能包含非空字符串。"); - } - if (!seen.add(attachmentId)) { - return new V3EventContractIssue("PAYMENT_ATTACHMENT_ID_DUPLICATE", "V4 PAYMENT attachment_ids 存在重复 ID。"); - } - if (!packageContext.attachmentIds().contains(attachmentId)) { - return new V3EventContractIssue("PAYMENT_ATTACHMENT_ID_NOT_FOUND", "V4 PAYMENT attachment_ids 引用了不存在的包级附件。"); - } - } - return null; - } - - /** - * 校验 manual_review=true 是否能由当前对象中的未解决字段解释,避免完整合法字段被误标复核。 - */ - private V3EventContractIssue inspectV4ManualReviewConsistencyIssue(JsonNode event) { - boolean manualReview = isBooleanTrue(event.path("manual_review")); - boolean unresolved = hasV4EventUnresolvedMarker(event); - if (manualReview && !unresolved) { - return new V3EventContractIssue("MANUAL_REVIEW_WITHOUT_UNRESOLVED_FIELD", "V4 manual_review=true 缺少可识别未解决字段。"); - } - if (!manualReview && unresolved) { - return new V3EventContractIssue("MANUAL_REVIEW_REQUIRED_FOR_UNRESOLVED_FIELD", "V4 未解决字段必须同时 manual_review=true。"); - } - return null; - } - - /** - * 判断 V4 event 是否存在被 Schema 允许的未解决标记。 - */ - private boolean hasV4EventUnresolvedMarker(JsonNode event) { - if (hasV4TargetOrderUnresolvedMarker(event.path("target_order"))) { - return true; - } - String eventType = textAt(event, "event_type"); - if ("NEW_BOOKING".equals(eventType)) { - return event.path("arrival_date").isNull() - || event.path("departure_date").isNull() - || event.path("rate_code").isNull() - || event.path("room_items").isEmpty() - || hasV4RoomItemsUnresolved(event.path("room_items")); - } - if ("UPDATE_BOOKING".equals(eventType)) { - JsonNode after = event.path("after"); - return hasNullChild(after) || after.path("room_items").isNull() || hasV4RoomItemsUnresolved(after.path("room_items")); - } - if ("TRACE_RESERVATION_NOTES".equals(eventType)) { - return hasV4TraceUnresolved(event.path("trace_items")); - } - return false; - } - - /** - * 判断 target_order 是否包含未解决字段。 - */ - private boolean hasV4TargetOrderUnresolvedMarker(JsonNode targetOrder) { - return targetOrder == null - || targetOrder.path("booking_type").isNull() - || targetOrder.path("locator_type").isNull() - || targetOrder.path("locator_value").isNull(); - } - - /** - * 判断 room_items 内是否有待人工补正字段。 - */ - private boolean hasV4RoomItemsUnresolved(JsonNode roomItems) { - if (roomItems == null || !roomItems.isArray()) { - return false; - } - for (JsonNode roomItem : roomItems) { - if (roomItem.path("room_type_code").isNull() || roomItem.path("room_count").isNull()) { - return true; - } - } - return false; - } - - /** - * 判断对象直接子字段是否包含 null,用于 V4 Update 稀疏 after 的未解决值识别。 - */ - private boolean hasNullChild(JsonNode node) { - if (node == null || !node.isObject()) { - return false; - } - var fields = node.fields(); - while (fields.hasNext()) { - if (fields.next().getValue().isNull()) { - return true; - } - } - return false; - } - - /** - * 判断 Trace item 是否存在允许人工复核补正的 null 字段。 - */ - private boolean hasV4TraceUnresolved(JsonNode traceItems) { - if (traceItems == null || !traceItems.isArray()) { - return false; - } - for (JsonNode traceItem : traceItems) { - if (traceItem.path("text").isNull() - || traceItem.path("department_code").isNull() - || traceItem.path("target_room_type_code").isNull() - || traceItem.path("extra_bed_room_count").isNull()) { - return true; - } - } - return false; - } - - /** - * V4 manual_review 仅允许 null 或布尔 true,不接受 false 或对象。 - */ - private boolean isV4ManualReviewNullOrTrue(JsonNode manualReview) { - return manualReview != null && (manualReview.isNull() || isBooleanTrue(manualReview)); - } - - /** - * 按 V4 event_type 和 target_order 派生现有稳定路由。 - */ - private ReservationAiRouteDefinition deriveV4EventRoute(JsonNode event) { - String resultType = isBooleanTrue(event.path("manual_review")) - ? AiResultType.MANUAL_REVIEW.code() - : AiResultType.NORMAL_TASK.code(); - String eventType = textAt(event, "event_type"); - String taskType = deriveV4TaskType(eventType); - String taskSubtype = deriveV4TaskSubtype(event); - if (taskType == null || taskSubtype == null) { - return null; - } - return ReservationAiRouteDefinition.findByTriplet(resultType, taskType, taskSubtype).orElse(null); - } - - /** - * V4 event_type 到当前系统 AI task_type 的最小映射。 - */ - private String deriveV4TaskType(String eventType) { - return switch (nullToEmpty(eventType)) { - case "NEW_BOOKING" -> "New Booking"; - case "UPDATE_BOOKING" -> "Update Booking"; - case "CANCEL_BOOKING" -> "Cancel Booking"; - case "TRACE_RESERVATION_NOTES" -> "Trace / Reservation Notes"; - case "ROOMING_LIST" -> "Rooming List"; - case "PAYMENT" -> "Payment Evidence"; - default -> null; - }; - } - - /** - * V4 event_type 到当前系统 task_subtype 的最小映射。后续 V4 多卡模型可替换这一层。 - */ - private String deriveV4TaskSubtype(JsonNode event) { - String eventType = textAt(event, "event_type"); - String bookingType = textAt(event.path("target_order"), "booking_type"); - return switch (nullToEmpty(eventType)) { - case "NEW_BOOKING" -> switch (nullToEmpty(bookingType)) { - case "GROUP" -> "new_group_block"; - case "FIT" -> "new_fit_reservation"; - default -> null; - }; - case "UPDATE_BOOKING" -> "update_booking_amendment"; - case "CANCEL_BOOKING" -> switch (nullToEmpty(bookingType)) { - case "GROUP" -> "cancel_group_block"; - case "FIT" -> "cancel_fit_reservation"; - default -> null; - }; - case "TRACE_RESERVATION_NOTES" -> v4TraceSubtype(event); - case "ROOMING_LIST" -> "rooming_list"; - case "PAYMENT" -> "payment_evidence"; - default -> null; - }; - } - - /** - * 同一 V4 Trace 卡可同时包含 GENERAL 和 EXTRA_BED;若包含加床,路由到 extra_bed 卡型。 - */ - private String v4TraceSubtype(JsonNode event) { - for (JsonNode traceItem : event.path("trace_items")) { - if ("EXTRA_BED".equals(textAt(traceItem, "item_type"))) { - return "extra_bed"; - } - } - return "general_request"; - } - - /** - * 将 V4 event 归一为当前任务创建链路可消费的 item,并把 V4 原始结构完整保存在 ai_payload_json 中。 - */ - private ObjectNode normalizeV4EventToTaskResultItem( + private SuperAgentTaskResultResponse acceptV4PackageAdapterContractError( JsonNode root, - JsonNode event, - ReservationAiRouteDefinition route, - int arrayIndex) { - ObjectNode item = objectMapper.createObjectNode(); - item.put("source_event_index", arrayIndex); - item.put("catalog_code", V4_CATALOG_CODE); - item.put("skill_id", V4_SKILL_ID); - item.put("result_type", route.resultType()); - item.put("task_type", route.taskType()); - item.put("task_subtype", route.taskSubtype()); - item.put("current_or_history", "current"); - item.put("route_code", route.routeCode()); - item.put("system_process_category", route.systemProcessCategory().name()); - item.put("field_contract_version", V4_FIELD_CONTRACT_VERSION); - item.set("case_keys", buildV4CaseKeys(event.path("target_order"))); - item.set("extracted_fields", buildV4ExtractedFields(event)); - item.set("manual_review", event.path("manual_review")); - item.set("attachments", root.path("source_message").path("attachments")); - item.set("v4_source_message", root.path("source_message")); - item.set("v4_order_context", findV4OrderContext(root.path("order_contexts"), textAt(event, "order_ref"))); - item.set("v4_message_event", event); - return item; - } + String clientId, + String requestId, + String rawBody, + ResolvedSourceMessage resolvedSourceMessage, + ReservationAiV4CallbackIssue packageIssue) { + SourceMessageInboxSnapshot sourceMessage = resolvedSourceMessage.snapshot(); + String hotelId = sourceMessage.hotelId(); + Long sourceMessageId = sourceMessage.id(); + String requestPayloadSha256 = sha256(rawBody == null ? "" : rawBody); + String batchIdempotencyKey = sha256(BATCH_KEY_PREFIX + "|" + sourceMessageId + "|" + requestPayloadSha256); - /** - * 将 V4 target_order 映射为旧任务链路可识别的 case_keys,同时保留 Booking Code 供后续 V4 投影使用。 - */ - private ObjectNode buildV4CaseKeys(JsonNode targetOrder) { - ObjectNode caseKeys = objectMapper.createObjectNode(); - caseKeys.putNull("group_code"); - caseKeys.putNull("confirmation_number"); - caseKeys.putNull("reservation_number"); - caseKeys.putNull("block_code"); - caseKeys.putNull("booking_code"); - String locatorType = textAt(targetOrder, "locator_type"); - String locatorValue = textAt(targetOrder, "locator_value"); - if ("GROUP_CODE".equals(locatorType) && trimToNull(locatorValue) != null) { - caseKeys.put("group_code", locatorValue); - caseKeys.put("block_code", locatorValue); - } else if ("CONFIRMATION_NUMBER".equals(locatorType) && trimToNull(locatorValue) != null) { - caseKeys.put("confirmation_number", locatorValue); - } else if ("BOOKING_CODE".equals(locatorType) && trimToNull(locatorValue) != null) { - caseKeys.put("booking_code", locatorValue); + ReservationAiBatchSnapshot existingBatch = workflowRepository + .findBatchBySourceMessageId(hotelId, sourceMessageId) + .orElse(null); + if (existingBatch != null) { + return handleExistingBatch( + requestId, + resolvedSourceMessage.responseSourceMessageId(), + requestPayloadSha256, + existingBatch); } - caseKeys.set("target_order", targetOrder); - return caseKeys; - } - /** - * 将 V4 业务字段搬入 extracted_fields,供当前任务详情和后续 V4 多卡模型读取。 - */ - private ObjectNode buildV4ExtractedFields(JsonNode event) { - ObjectNode fields = objectMapper.createObjectNode(); - fields.put("order_ref", textAt(event, "order_ref")); - fields.put("v4_event_type", textAt(event, "event_type")); - fields.set("target_order", event.path("target_order")); - String eventType = textAt(event, "event_type"); - if ("NEW_BOOKING".equals(eventType)) { - fields.put("booking_object_type", "GROUP".equals(textAt(event.path("target_order"), "booking_type")) - ? "Group Block" - : "FIT Reservation"); - copyFieldEvenIfNull(event, fields, "arrival_date"); - copyFieldEvenIfNull(event, fields, "departure_date"); - copyFieldEvenIfNull(event, fields, "rate_code"); - copyFieldEvenIfNull(event, fields, "booking_scenario"); - copyFieldEvenIfNull(event, fields, "guest_name"); - copyFieldEvenIfNull(event, fields, "room_items"); - } else if ("UPDATE_BOOKING".equals(eventType)) { - fields.set("after", event.path("after")); - } else if ("CANCEL_BOOKING".equals(eventType)) { - fields.put("cancel_object_type", "GROUP".equals(textAt(event.path("target_order"), "booking_type")) - ? "group_block" - : "fit_reservation"); - } else if ("TRACE_RESERVATION_NOTES".equals(eventType)) { - fields.set("trace_items", event.path("trace_items")); - fields.put("trace_subtype", v4TraceSubtype(event)); - } else if ("ROOMING_LIST".equals(eventType)) { - fields.put("rooming_list_detected", true); - } else if ("PAYMENT".equals(eventType)) { - fields.set("attachment_ids", event.path("attachment_ids")); - } - return fields; - } - - /** - * 查找 V4 event 对应的订单级上下文,未找到时返回空对象;正常情况下前置校验已保证存在。 - */ - private JsonNode findV4OrderContext(JsonNode orderContexts, String orderRef) { - for (JsonNode orderContext : orderContexts) { - if (orderRef != null && orderRef.equals(textAt(orderContext, "order_ref"))) { - return orderContext; - } - } - return objectMapper.createObjectNode(); - } - - /** - * V4 归一化需要保留显式 null,便于后续字段校验知道 Agent 确认了字段未解决。 - */ - private void copyFieldEvenIfNull(JsonNode source, ObjectNode target, String fieldName) { - if (source != null && source.has(fieldName)) { - target.set(fieldName, source.get(fieldName)); + LocalDateTime now = nowUtc(); + String safeRequestId = optionalText(requestId, "request_id", LENGTH_128); + ReservationAiBatchDraft batchDraft = new ReservationAiBatchDraft( + hotelId, + sourceMessageId, + requestPayloadSha256, + batchIdempotencyKey, + requireText(clientId, "clientId", LENGTH_128), + safeRequestId, + now, + 1, + null + ); + Long batchId = insertBatchOrReplay(batchDraft); + if (batchId == null) { + return handleExistingBatch( + requestId, + resolvedSourceMessage.responseSourceMessageId(), + requestPayloadSha256, + workflowRepository.findBatchBySourceMessageId(hotelId, sourceMessageId) + .orElseThrow(() -> error(HttpStatus.CONFLICT, "IDEMPOTENCY_CONFLICT", "AI 批次并发写入状态不确定。"))); } + SuperAgentTaskResultItemResponse responseItem = createV4PackageAdapterContractErrorTransition( + hotelId, + sourceMessageId, + batchId, + root, + packageIssue.code(), + packageIssue.message(), + now); + return new SuperAgentTaskResultResponse( + safeRequestId, + resolvedSourceMessage.responseSourceMessageId(), + batchId.toString(), + false, + 1, + List.of(responseItem), + List.of() + ); } /** @@ -2758,10 +2168,13 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta * 判断是否为 V4 候选包。完整结构仍交给 V4 validator 报出清晰契约错误。 */ private boolean isV4PackageCandidate(JsonNode root) { - return root != null - && root.has("source_message") - && ((root.has("order_contexts") && root.has("message_events")) - || (root.has("route_code") && (root.has("order_contexts") || root.has("message_events")))); + if (root == null || !root.has("source_message")) { + return false; + } + if (AiResultType.SOURCE_MESSAGE_REVIEW_NOTIFICATION.code().equals(textAt(root, "result_type"))) { + return false; + } + return root.has("order_contexts") || root.has("route_code"); } /** @@ -2834,7 +2247,7 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta ? objectMapper.createObjectNode() : event.deepCopy(); if (event != null && event.path("target_order").isObject()) { - eventForTransition.set("case_keys", buildV4CaseKeys(event.path("target_order"))); + eventForTransition.set("case_keys", v4CallbackRouter.buildCaseKeys(event.path("target_order"))); } if (event != null) { eventForTransition.set("v4_message_event", event); @@ -2854,6 +2267,37 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta V4_SKILL_ID); } + /** + * 保存 V4 包级 Adapter 契约错误 transition。该记录不关联具体任务,只用于排查回调包整体不合规。 + */ + private SuperAgentTaskResultItemResponse createV4PackageAdapterContractErrorTransition( + String hotelId, + Long sourceMessageId, + Long batchId, + JsonNode root, + String adapterErrorCode, + String adapterErrorMessage, + LocalDateTime now) { + ObjectNode packageForTransition = objectMapper.createObjectNode(); + packageForTransition.set("v4_package", root == null ? objectMapper.createObjectNode() : root); + packageForTransition.set("v4_source_message", root == null ? objectMapper.createObjectNode() : root.path("source_message")); + packageForTransition.set("v4_order_contexts", root == null ? objectMapper.createArrayNode() : root.path("order_contexts")); + packageForTransition.set("v4_message_events", root == null ? objectMapper.createArrayNode() : root.path("message_events")); + return createAdapterContractErrorTransition( + hotelId, + sourceMessageId, + batchId, + packageForTransition, + 1, + 1, + null, + adapterErrorCode, + adapterErrorMessage, + now, + V4_CATALOG_CODE, + V4_SKILL_ID); + } + /** * 保存 Adapter 契约错误 transition 的通用实现。catalog / skill 用于区分 V3 与 V4 入站来源。 */ @@ -3255,15 +2699,6 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta ) { } - /** - * V4 包级上下文。orderRefs 用于校验 event 归属,attachmentIds 用于校验 Payment 附件引用。 - */ - private record V4PackageContext( - Set orderRefs, - Set attachmentIds - ) { - } - /** * 将空白字符串转换为 null。 */ diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationAiV4CallbackIssue.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationAiV4CallbackIssue.java new file mode 100644 index 0000000..8f74f5a --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationAiV4CallbackIssue.java @@ -0,0 +1,10 @@ +package cn.nianxx.thhotel.workflows.reservation.service.impl; + +/** + * V4 回调契约问题摘要。只保存稳定错误码和安全中文说明,不保存邮件正文或附件 URL。 + */ +record ReservationAiV4CallbackIssue( + String code, + String message +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationAiV4CallbackRouter.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationAiV4CallbackRouter.java new file mode 100644 index 0000000..50829a3 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationAiV4CallbackRouter.java @@ -0,0 +1,239 @@ +package cn.nianxx.thhotel.workflows.reservation.service.impl; + +import cn.nianxx.thhotel.workflows.reservation.common.enums.AiResultType; +import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationAiRouteDefinition; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; + +/** + * V4 SuperAgent 回调路由器。负责把 V4 event 映射到当前系统稳定 route 和旧任务创建链路 item。 + */ +final class ReservationAiV4CallbackRouter { + + private static final String FIELD_CONTRACT_VERSION = "20260718-v4"; + private static final String CATALOG_CODE = "M002V4"; + private static final String SKILL_ID = "booking-desk-event-v4"; + + private final ObjectMapper objectMapper; + + /** + * 注入 JSON 构造器,用于构建 case_keys、extracted_fields 和标准 item。 + */ + ReservationAiV4CallbackRouter(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + } + + /** + * 按 V4 event_type 和 target_order 派生当前系统稳定路由。 + */ + ReservationAiRouteDefinition deriveEventRoute(JsonNode event) { + String resultType = isBooleanTrue(event.path("manual_review")) + ? AiResultType.MANUAL_REVIEW.code() + : AiResultType.NORMAL_TASK.code(); + String eventType = textAt(event, "event_type"); + String taskType = deriveTaskType(eventType); + String taskSubtype = deriveTaskSubtype(event); + if (taskType == null || taskSubtype == null) { + return null; + } + return ReservationAiRouteDefinition.findByTriplet(resultType, taskType, taskSubtype).orElse(null); + } + + /** + * 将 V4 event 归一为当前任务创建链路可消费的 item,并保留 V4 原始结构。 + */ + ObjectNode normalizeEventToTaskResultItem( + JsonNode root, + JsonNode event, + ReservationAiRouteDefinition route, + int arrayIndex) { + ObjectNode item = objectMapper.createObjectNode(); + item.put("source_event_index", arrayIndex); + item.put("catalog_code", CATALOG_CODE); + item.put("skill_id", SKILL_ID); + item.put("result_type", route.resultType()); + item.put("task_type", route.taskType()); + item.put("task_subtype", route.taskSubtype()); + item.put("current_or_history", "current"); + item.put("route_code", route.routeCode()); + item.put("system_process_category", route.systemProcessCategory().name()); + item.put("field_contract_version", FIELD_CONTRACT_VERSION); + item.set("case_keys", buildCaseKeys(event.path("target_order"))); + item.set("extracted_fields", buildExtractedFields(event)); + item.set("manual_review", event.path("manual_review")); + item.set("attachments", root.path("source_message").path("attachments")); + item.set("v4_source_message", root.path("source_message")); + item.set("v4_order_context", findOrderContext(root.path("order_contexts"), textAt(event, "order_ref"))); + item.set("v4_message_event", event); + return item; + } + + /** + * 将 V4 target_order 映射为旧任务链路可识别的 case_keys,同时保留 Booking Code。 + */ + ObjectNode buildCaseKeys(JsonNode targetOrder) { + ObjectNode caseKeys = objectMapper.createObjectNode(); + caseKeys.putNull("group_code"); + caseKeys.putNull("confirmation_number"); + caseKeys.putNull("reservation_number"); + caseKeys.putNull("block_code"); + caseKeys.putNull("booking_code"); + String locatorType = textAt(targetOrder, "locator_type"); + String locatorValue = textAt(targetOrder, "locator_value"); + if ("GROUP_CODE".equals(locatorType) && trimToNull(locatorValue) != null) { + caseKeys.put("group_code", locatorValue); + caseKeys.put("block_code", locatorValue); + } else if ("CONFIRMATION_NUMBER".equals(locatorType) && trimToNull(locatorValue) != null) { + caseKeys.put("confirmation_number", locatorValue); + } else if ("BOOKING_CODE".equals(locatorType) && trimToNull(locatorValue) != null) { + caseKeys.put("booking_code", locatorValue); + } + caseKeys.set("target_order", targetOrder); + return caseKeys; + } + + /** + * V4 event_type 到当前系统 AI task_type 的最小映射。 + */ + private String deriveTaskType(String eventType) { + return switch (nullToEmpty(eventType)) { + case "NEW_BOOKING" -> "New Booking"; + case "UPDATE_BOOKING" -> "Update Booking"; + case "CANCEL_BOOKING" -> "Cancel Booking"; + case "TRACE_RESERVATION_NOTES" -> "Trace / Reservation Notes"; + case "ROOMING_LIST" -> "Rooming List"; + case "PAYMENT" -> "Payment Evidence"; + default -> null; + }; + } + + /** + * V4 event_type 到当前系统 task_subtype 的最小映射。 + */ + private String deriveTaskSubtype(JsonNode event) { + String eventType = textAt(event, "event_type"); + String bookingType = textAt(event.path("target_order"), "booking_type"); + return switch (nullToEmpty(eventType)) { + case "NEW_BOOKING" -> switch (nullToEmpty(bookingType)) { + case "GROUP" -> "new_group_block"; + case "FIT" -> "new_fit_reservation"; + default -> null; + }; + case "UPDATE_BOOKING" -> "update_booking_amendment"; + case "CANCEL_BOOKING" -> switch (nullToEmpty(bookingType)) { + case "GROUP" -> "cancel_group_block"; + case "FIT" -> "cancel_fit_reservation"; + default -> null; + }; + case "TRACE_RESERVATION_NOTES" -> traceSubtype(event); + case "ROOMING_LIST" -> "rooming_list"; + case "PAYMENT" -> "payment_evidence"; + default -> null; + }; + } + + /** + * 同一 V4 Trace 卡可同时包含 GENERAL 和 EXTRA_BED;若包含加床,路由到 extra_bed 卡型。 + */ + private String traceSubtype(JsonNode event) { + for (JsonNode traceItem : event.path("trace_items")) { + if ("EXTRA_BED".equals(textAt(traceItem, "item_type"))) { + return "extra_bed"; + } + } + return "general_request"; + } + + /** + * 将 V4 业务字段搬入 extracted_fields,供当前任务详情和后续 V4 多卡模型读取。 + */ + private ObjectNode buildExtractedFields(JsonNode event) { + ObjectNode fields = objectMapper.createObjectNode(); + fields.put("order_ref", textAt(event, "order_ref")); + fields.put("v4_event_type", textAt(event, "event_type")); + fields.set("target_order", event.path("target_order")); + String eventType = textAt(event, "event_type"); + if ("NEW_BOOKING".equals(eventType)) { + fields.put("booking_object_type", "GROUP".equals(textAt(event.path("target_order"), "booking_type")) + ? "Group Block" + : "FIT Reservation"); + copyFieldEvenIfNull(event, fields, "arrival_date"); + copyFieldEvenIfNull(event, fields, "departure_date"); + copyFieldEvenIfNull(event, fields, "rate_code"); + copyFieldEvenIfNull(event, fields, "booking_scenario"); + copyFieldEvenIfNull(event, fields, "guest_name"); + copyFieldEvenIfNull(event, fields, "room_items"); + } else if ("UPDATE_BOOKING".equals(eventType)) { + fields.set("after", event.path("after")); + } else if ("CANCEL_BOOKING".equals(eventType)) { + fields.put("cancel_object_type", "GROUP".equals(textAt(event.path("target_order"), "booking_type")) + ? "group_block" + : "fit_reservation"); + } else if ("TRACE_RESERVATION_NOTES".equals(eventType)) { + fields.set("trace_items", event.path("trace_items")); + fields.put("trace_subtype", traceSubtype(event)); + } else if ("ROOMING_LIST".equals(eventType)) { + fields.put("rooming_list_detected", true); + } else if ("PAYMENT".equals(eventType)) { + fields.set("attachment_ids", event.path("attachment_ids")); + } + return fields; + } + + /** + * 查找 V4 event 对应的订单级上下文,未找到时返回空对象;正常情况下前置校验已保证存在。 + */ + private JsonNode findOrderContext(JsonNode orderContexts, String orderRef) { + for (JsonNode orderContext : orderContexts) { + if (orderRef != null && orderRef.equals(textAt(orderContext, "order_ref"))) { + return orderContext; + } + } + return objectMapper.createObjectNode(); + } + + /** + * V4 归一化需要保留显式 null,便于后续字段校验知道 Agent 确认了字段未解决。 + */ + private void copyFieldEvenIfNull(JsonNode source, ObjectNode target, String fieldName) { + if (source != null && source.has(fieldName)) { + target.set(fieldName, source.get(fieldName)); + } + } + + /** + * 判断 JSON 节点是否为布尔 true。 + */ + private boolean isBooleanTrue(JsonNode node) { + return node != null && node.isBoolean() && node.booleanValue(); + } + + /** + * 从对象节点中读取文本字段,缺失或 null 时返回 null。 + */ + private String textAt(JsonNode node, String fieldName) { + if (node == null || node.isMissingNode() || node.get(fieldName) == null || node.get(fieldName).isNull()) { + return null; + } + return node.get(fieldName).asText(); + } + + /** + * 将空白字符串转换为 null。 + */ + private String trimToNull(String value) { + if (value == null) { + return null; + } + String trimmed = value.trim(); + return trimmed.isEmpty() ? null : trimmed; + } + + /** + * null 转为空字符串,保证 switch 分支稳定。 + */ + private String nullToEmpty(String value) { + return value == null ? "" : value; + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationAiV4CallbackValidator.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationAiV4CallbackValidator.java new file mode 100644 index 0000000..8c1f8d0 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationAiV4CallbackValidator.java @@ -0,0 +1,654 @@ +package cn.nianxx.thhotel.workflows.reservation.service.impl; + +import com.fasterxml.jackson.databind.JsonNode; +import java.math.BigInteger; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Set; + +/** + * V4 SuperAgent 回调契约校验器。只做入站结构和稳定枚举校验,不创建订单、任务或审计记录。 + */ +final class ReservationAiV4CallbackValidator { + + private static final int LENGTH_128 = 128; + private static final Set ALLOWED_EVENT_TYPES = Set.of( + "NEW_BOOKING", + "UPDATE_BOOKING", + "CANCEL_BOOKING", + "TRACE_RESERVATION_NOTES", + "ROOMING_LIST", + "PAYMENT"); + private static final Set ALLOWED_BOOKING_TYPES = Set.of("GROUP", "FIT"); + private static final Set ALLOWED_LOCATOR_TYPES = Set.of( + "GROUP_CODE", + "BOOKING_CODE", + "CONFIRMATION_NUMBER"); + + /** + * 校验 V4 包级结构。返回 null 表示包级契约通过,返回 issue 时由调用方决定是否落 adapter transition。 + */ + ReservationAiV4CallbackIssue inspectPackageRootIssue(JsonNode root, boolean sourceMessageNotification) { + if (root == null + || !root.isObject() + || !root.has("route_code") + || !root.has("source_message") + || !root.path("order_contexts").isArray() + || !root.path("message_events").isArray()) { + return new ReservationAiV4CallbackIssue("V4_ROOT_CONTRACT_INVALID", "V4 根结构字段不完整。"); + } + ReservationAiV4CallbackIssue sourceMessageIssue = inspectSourceMessageIssue(root.path("source_message")); + if (sourceMessageIssue != null) { + return sourceMessageIssue; + } + ReservationAiV4CallbackIssue attachmentIdIssue = inspectAttachmentIdsIssue(root.path("source_message").path("attachments")); + if (attachmentIdIssue != null) { + return attachmentIdIssue; + } + ReservationAiV4CallbackIssue orderContextIssue = inspectOrderContextsIssue(root.path("order_contexts")); + if (orderContextIssue != null) { + return orderContextIssue; + } + String routeCode = trimToNull(textAt(root, "route_code")); + if (sourceMessageNotification) { + if (!("S10".equals(routeCode) || "S99".equals(routeCode))) { + return new ReservationAiV4CallbackIssue("V4_NOTIFICATION_ROUTE_INVALID", "V4 通知 route_code 只能为 S10 或 S99。"); + } + if (root.path("order_contexts").size() > 0 || root.path("message_events").size() > 0) { + return new ReservationAiV4CallbackIssue( + "V4_NOTIFICATION_CONTENT_INVALID", + "V4 通知不能携带 order_contexts 或 message_events。"); + } + return null; + } + if (routeCode != null) { + return new ReservationAiV4CallbackIssue("V4_BUSINESS_ROUTE_INVALID", "V4 普通业务 route_code 必须为 null。"); + } + if (root.path("order_contexts").size() == 0) { + return new ReservationAiV4CallbackIssue("ORDER_CONTEXTS_EMPTY", "V4 普通业务 order_contexts 不能为空。"); + } + return null; + } + + /** + * 提取 event 校验需要的包级上下文。调用前应先通过 inspectPackageRootIssue。 + */ + ReservationAiV4PackageContext packageContext(JsonNode root) { + return new ReservationAiV4PackageContext( + collectOrderRefs(root.path("order_contexts")), + collectAttachmentIds(root.path("source_message").path("attachments"))); + } + + /** + * 校验单个 V4 message_event。业务字段错误只影响当前 event,不阻断同包其它 event。 + */ + ReservationAiV4CallbackIssue inspectEventContractIssue( + JsonNode event, + ReservationAiV4PackageContext packageContext, + Map targetOrderByOrderRef) { + if (event == null + || !event.isObject() + || trimToNull(textAt(event, "order_ref")) == null + || trimToNull(textAt(event, "event_type")) == null + || !event.path("target_order").isObject() + || !event.has("manual_review") + || !isManualReviewNullOrTrue(event.path("manual_review"))) { + return new ReservationAiV4CallbackIssue("EVENT_CONTRACT_INCOMPLETE", "V4 message_event 公共字段不完整。"); + } + String orderRef = trimToNull(textAt(event, "order_ref")); + if (!packageContext.orderRefs().contains(orderRef)) { + return new ReservationAiV4CallbackIssue("ORDER_REF_NOT_FOUND", "V4 message_event 引用了不存在的 order_ref。"); + } + String eventType = trimToNull(textAt(event, "event_type")); + if (!ALLOWED_EVENT_TYPES.contains(eventType)) { + return new ReservationAiV4CallbackIssue("EVENT_TYPE_UNSUPPORTED", "V4 event_type 不在第一版支持范围。"); + } + ReservationAiV4CallbackIssue targetIssue = inspectTargetOrderIssue( + event.path("target_order"), + event.path("manual_review")); + if (targetIssue != null) { + return targetIssue; + } + ReservationAiV4CallbackIssue targetConsistencyIssue = inspectTargetOrderConsistencyIssue( + orderRef, + event.path("target_order"), + targetOrderByOrderRef); + if (targetConsistencyIssue != null) { + return targetConsistencyIssue; + } + ReservationAiV4CallbackIssue specificIssue = inspectEventSpecificIssue(event, packageContext); + if (specificIssue != null) { + return specificIssue; + } + ReservationAiV4CallbackIssue manualReviewIssue = inspectManualReviewConsistencyIssue(event); + if (manualReviewIssue != null) { + return manualReviewIssue; + } + rememberTargetOrder(orderRef, event.path("target_order"), targetOrderByOrderRef); + return null; + } + + /** + * 校验 V4 source_message 最小字段。正文只做结构校验,不在错误中回显原文。 + */ + private ReservationAiV4CallbackIssue inspectSourceMessageIssue(JsonNode sourceMessage) { + if (sourceMessage == null + || !sourceMessage.isObject() + || trimToNull(textAt(sourceMessage, "source_message_id")) == null + || !isStringOrNull(sourceMessage.path("conversation_id")) + || !isStringOrNull(sourceMessage.path("subject")) + || !isStringOrNull(sourceMessage.path("sender")) + || !isStringOrNull(sourceMessage.path("sent_at")) + || !isStringOrNull(sourceMessage.path("body")) + || !("text/plain".equals(textAt(sourceMessage, "body_content_type")) + || "text/html".equals(textAt(sourceMessage, "body_content_type"))) + || !sourceMessage.path("attachments").isArray()) { + return new ReservationAiV4CallbackIssue("SOURCE_MESSAGE_CONTRACT_INVALID", "V4 source_message 结构不符合契约。"); + } + for (JsonNode attachment : sourceMessage.path("attachments")) { + ReservationAiV4CallbackIssue attachmentIssue = inspectAttachmentIssue(attachment); + if (attachmentIssue != null) { + return attachmentIssue; + } + } + return null; + } + + /** + * 校验 V4 附件引用。URL 仅检查是否存在,不在错误中输出。 + */ + private ReservationAiV4CallbackIssue inspectAttachmentIssue(JsonNode attachment) { + if (attachment == null + || !attachment.isObject() + || trimToNull(textAt(attachment, "id")) == null + || trimToNull(textAt(attachment, "name")) == null + || trimToNull(textAt(attachment, "content_type")) == null + || trimToNull(textAt(attachment, "url")) == null + || !(attachment.path("size").isMissingNode() + || attachment.path("size").isNull() + || attachment.path("size").isNumber())) { + return new ReservationAiV4CallbackIssue("SOURCE_MESSAGE_ATTACHMENT_INVALID", "V4 source_message.attachments 结构无效。"); + } + return null; + } + + /** + * 校验附件 ID 唯一性,保证 Payment attachment_ids 可唯一定位。 + */ + private ReservationAiV4CallbackIssue inspectAttachmentIdsIssue(JsonNode attachments) { + Set attachmentIds = new LinkedHashSet<>(); + for (JsonNode attachment : attachments) { + String attachmentId = trimToNull(textAt(attachment, "id")); + if (attachmentId == null || !attachmentIds.add(attachmentId)) { + return new ReservationAiV4CallbackIssue( + "SOURCE_MESSAGE_ATTACHMENT_ID_DUPLICATE", + "V4 source_message.attachments.id 重复或缺失。"); + } + if (attachmentId.length() > LENGTH_128) { + return new ReservationAiV4CallbackIssue("V4_FIELD_TOO_LONG", "source_message.attachments.id 超过长度限制。"); + } + } + return null; + } + + /** + * 校验 order_contexts 的 order_ref 和订单级 Basic Information。 + */ + private ReservationAiV4CallbackIssue inspectOrderContextsIssue(JsonNode orderContexts) { + Set orderRefs = new LinkedHashSet<>(); + for (JsonNode orderContext : orderContexts) { + String orderRef = trimToNull(textAt(orderContext, "order_ref")); + if (orderRef == null || !orderRefs.add(orderRef)) { + return new ReservationAiV4CallbackIssue("ORDER_CONTEXT_REF_DUPLICATE", "V4 order_contexts.order_ref 重复或缺失。"); + } + if (orderRef.length() > LENGTH_128) { + return new ReservationAiV4CallbackIssue("V4_FIELD_TOO_LONG", "order_contexts.order_ref 超过长度限制。"); + } + ReservationAiV4CallbackIssue basicIssue = inspectBasicInformationIssue(orderContext.path("basic_information")); + if (basicIssue != null) { + return basicIssue; + } + } + return null; + } + + /** + * 校验订单级 Basic Information。manual_review=true 必须由 account_code 缺失解释。 + */ + private ReservationAiV4CallbackIssue inspectBasicInformationIssue(JsonNode basicInformation) { + if (basicInformation == null + || !basicInformation.isObject() + || !basicInformation.has("account_code") + || !basicInformation.has("manual_review") + || !isStringOrNull(basicInformation.path("account_code")) + || !isManualReviewNullOrTrue(basicInformation.path("manual_review"))) { + return new ReservationAiV4CallbackIssue("BASIC_INFORMATION_CONTRACT_INVALID", "V4 Basic Information 结构无效。"); + } + boolean manualReview = isBooleanTrue(basicInformation.path("manual_review")); + boolean accountUnresolved = basicInformation.path("account_code").isNull(); + if (manualReview != accountUnresolved) { + return new ReservationAiV4CallbackIssue( + "BASIC_INFORMATION_MANUAL_REVIEW_INVALID", + "V4 Basic Information manual_review 必须能由 account_code 缺失解释。"); + } + return null; + } + + /** + * 校验 V4 target_order 合法组合。定位字段未解决时必须由 manual_review=true 承接。 + */ + private ReservationAiV4CallbackIssue inspectTargetOrderIssue(JsonNode targetOrder, JsonNode manualReview) { + if (!targetOrder.has("booking_type") + || !targetOrder.has("locator_type") + || !targetOrder.has("locator_value") + || !isStringOrNull(targetOrder.path("booking_type")) + || !isStringOrNull(targetOrder.path("locator_type")) + || !isStringOrNull(targetOrder.path("locator_value"))) { + return new ReservationAiV4CallbackIssue("TARGET_ORDER_CONTRACT_INVALID", "V4 target_order 结构无效。"); + } + boolean unresolved = hasTargetOrderUnresolvedMarker(targetOrder); + if (unresolved) { + return isBooleanTrue(manualReview) + ? null + : new ReservationAiV4CallbackIssue( + "TARGET_ORDER_MANUAL_REVIEW_REQUIRED", + "V4 target_order 未解决时必须 manual_review=true。"); + } + String bookingType = textAt(targetOrder, "booking_type"); + String locatorType = textAt(targetOrder, "locator_type"); + if (!ALLOWED_BOOKING_TYPES.contains(bookingType) || !ALLOWED_LOCATOR_TYPES.contains(locatorType)) { + return new ReservationAiV4CallbackIssue("TARGET_ORDER_COMBINATION_INVALID", "V4 target_order 枚举值无效。"); + } + if ("GROUP".equals(bookingType) && !"GROUP_CODE".equals(locatorType)) { + return new ReservationAiV4CallbackIssue("TARGET_ORDER_COMBINATION_INVALID", "Group 订单只能使用 GROUP_CODE 定位。"); + } + if ("FIT".equals(bookingType) + && !("BOOKING_CODE".equals(locatorType) || "CONFIRMATION_NUMBER".equals(locatorType))) { + return new ReservationAiV4CallbackIssue( + "TARGET_ORDER_COMBINATION_INVALID", + "Fit 订单只能使用 BOOKING_CODE 或 CONFIRMATION_NUMBER 定位。"); + } + return null; + } + + /** + * 校验同一 order_ref 下非空 target_order 必须一致,避免同一订单任务被拆错或合错。 + */ + private ReservationAiV4CallbackIssue inspectTargetOrderConsistencyIssue( + String orderRef, + JsonNode targetOrder, + Map targetOrderByOrderRef) { + if (hasTargetOrderUnresolvedMarker(targetOrder)) { + return null; + } + String key = targetOrderKey(targetOrder); + String existing = targetOrderByOrderRef.get(orderRef); + if (existing != null && !existing.equals(key)) { + return new ReservationAiV4CallbackIssue("ORDER_REF_TARGET_ORDER_CONFLICT", "同一 order_ref 下 target_order 不一致。"); + } + return null; + } + + /** + * 记录已通过全部契约校验的 target_order。坏 event 不应污染同包后续 sibling event。 + */ + private void rememberTargetOrder( + String orderRef, + JsonNode targetOrder, + Map targetOrderByOrderRef) { + if (!hasTargetOrderUnresolvedMarker(targetOrder)) { + targetOrderByOrderRef.putIfAbsent(orderRef, targetOrderKey(targetOrder)); + } + } + + /** + * 生成 target_order 一致性校验使用的稳定 key。 + */ + private String targetOrderKey(JsonNode targetOrder) { + return textAt(targetOrder, "booking_type") + + "|" + + textAt(targetOrder, "locator_type") + + "|" + + textAt(targetOrder, "locator_value"); + } + + /** + * 校验不同 event_type 的专属字段。第一版只做入站基线,不执行完整业务确认。 + */ + private ReservationAiV4CallbackIssue inspectEventSpecificIssue( + JsonNode event, + ReservationAiV4PackageContext packageContext) { + return switch (textAt(event, "event_type")) { + case "NEW_BOOKING" -> inspectNewBookingIssue(event); + case "UPDATE_BOOKING" -> inspectUpdateBookingIssue(event); + case "CANCEL_BOOKING" -> null; + case "TRACE_RESERVATION_NOTES" -> inspectTraceIssue(event); + case "ROOMING_LIST" -> inspectRoomingListIssue(event); + case "PAYMENT" -> inspectPaymentIssue(event, packageContext); + default -> new ReservationAiV4CallbackIssue("EVENT_TYPE_UNSUPPORTED", "V4 event_type 不在第一版支持范围。"); + }; + } + + /** + * 校验 V4 New Booking 第一版必需字段和 room_items 结构。 + */ + private ReservationAiV4CallbackIssue inspectNewBookingIssue(JsonNode event) { + if (!event.has("arrival_date") + || !event.has("departure_date") + || !event.has("rate_code") + || !event.path("room_items").isArray() + || !isStringOrNull(event.path("arrival_date")) + || !isStringOrNull(event.path("departure_date")) + || !isStringOrNull(event.path("rate_code"))) { + return new ReservationAiV4CallbackIssue("NEW_BOOKING_CONTRACT_INVALID", "V4 NEW_BOOKING 字段不完整。"); + } + if ("GROUP".equals(textAt(event.path("target_order"), "booking_type")) + && !("STANDARD".equals(textAt(event, "booking_scenario")) + || "PROPOSAL".equals(textAt(event, "booking_scenario")))) { + return new ReservationAiV4CallbackIssue("NEW_BOOKING_SCENARIO_INVALID", "Group NEW_BOOKING booking_scenario 无效。"); + } + if ("FIT".equals(textAt(event.path("target_order"), "booking_type")) + && event.has("guest_name") + && !isStringOrNull(event.path("guest_name"))) { + return new ReservationAiV4CallbackIssue("NEW_BOOKING_GUEST_NAME_INVALID", "Fit NEW_BOOKING guest_name 必须为 string 或 null。"); + } + for (JsonNode roomItem : event.path("room_items")) { + if (!roomItem.isObject() + || !roomItem.has("room_type_code") + || !roomItem.has("room_count") + || !isStringOrNull(roomItem.path("room_type_code")) + || !isPositiveIntOrNull(roomItem.path("room_count"))) { + return new ReservationAiV4CallbackIssue("NEW_BOOKING_ROOM_ITEMS_INVALID", "V4 NEW_BOOKING room_items 结构无效。"); + } + } + return null; + } + + /** + * 校验 V4 Update Booking。Rate Code 在 Update 中属于契约错误,不能静默忽略。 + */ + private ReservationAiV4CallbackIssue inspectUpdateBookingIssue(JsonNode event) { + if (event.has("rate_code") || event.path("after").has("rate_code")) { + return new ReservationAiV4CallbackIssue("UPDATE_RATE_CODE_NOT_ALLOWED", "V4 UPDATE_BOOKING 不允许修改 rate_code。"); + } + JsonNode after = event.path("after"); + if (!after.isObject()) { + return new ReservationAiV4CallbackIssue("UPDATE_AFTER_CONTRACT_INVALID", "V4 UPDATE_BOOKING after 必须是对象。"); + } + if (after.has("room_items") + && !after.path("room_items").isNull() + && !after.path("room_items").isArray()) { + return new ReservationAiV4CallbackIssue( + "UPDATE_ROOM_ITEMS_CONTRACT_INVALID", + "V4 UPDATE_BOOKING after.room_items 必须为 null 或数组。"); + } + if (after.path("room_items").isArray()) { + for (JsonNode roomItem : after.path("room_items")) { + if (!roomItem.isObject() + || !roomItem.has("room_type_code") + || !roomItem.has("room_count") + || !isStringOrNull(roomItem.path("room_type_code")) + || !isPositiveIntOrNull(roomItem.path("room_count"))) { + return new ReservationAiV4CallbackIssue( + "UPDATE_ROOM_ITEMS_CONTRACT_INVALID", + "V4 UPDATE_BOOKING room_items 结构无效。"); + } + } + } + return null; + } + + /** + * 校验 V4 Trace 事项。EXTRA_BED 和 GENERAL 允许放在同一张 Trace 卡内。 + */ + private ReservationAiV4CallbackIssue inspectTraceIssue(JsonNode event) { + JsonNode traceItems = event.path("trace_items"); + if (!traceItems.isArray() || traceItems.size() == 0) { + return new ReservationAiV4CallbackIssue("TRACE_ITEMS_CONTRACT_INVALID", "V4 TRACE_RESERVATION_NOTES trace_items 不能为空。"); + } + for (JsonNode traceItem : traceItems) { + String itemType = textAt(traceItem, "item_type"); + if ("GENERAL".equals(itemType)) { + if (!traceItem.has("text") + || !traceItem.has("department_code") + || !isStringOrNull(traceItem.path("text")) + || !isStringOrNull(traceItem.path("department_code"))) { + return new ReservationAiV4CallbackIssue("TRACE_GENERAL_ITEM_INVALID", "V4 GENERAL trace item 字段无效。"); + } + continue; + } + if ("EXTRA_BED".equals(itemType)) { + if (!traceItem.has("target_room_type_code") + || !traceItem.has("extra_bed_room_count") + || !traceItem.has("department_code") + || !isStringOrNull(traceItem.path("target_room_type_code")) + || !isStringOrNull(traceItem.path("department_code")) + || !isPositiveIntOrNull(traceItem.path("extra_bed_room_count"))) { + return new ReservationAiV4CallbackIssue("TRACE_EXTRA_BED_ITEM_INVALID", "V4 EXTRA_BED trace item 字段无效。"); + } + continue; + } + return new ReservationAiV4CallbackIssue("TRACE_ITEM_TYPE_UNSUPPORTED", "V4 trace_items.item_type 只能为 GENERAL 或 EXTRA_BED。"); + } + return null; + } + + /** + * 校验 Rooming List 只适用于 Group,第一版不要求 rows 或附件 ID。 + */ + private ReservationAiV4CallbackIssue inspectRoomingListIssue(JsonNode event) { + if (!"GROUP".equals(textAt(event.path("target_order"), "booking_type"))) { + return new ReservationAiV4CallbackIssue("ROOMING_LIST_BOOKING_TYPE_INVALID", "V4 ROOMING_LIST 第一版只支持 Group。"); + } + return null; + } + + /** + * 校验 V4 Payment 附件引用。正常 Payment 必须引用同包已有附件 ID。 + */ + private ReservationAiV4CallbackIssue inspectPaymentIssue( + JsonNode event, + ReservationAiV4PackageContext packageContext) { + JsonNode attachmentIds = event.path("attachment_ids"); + if (!attachmentIds.isArray() || attachmentIds.size() == 0) { + return new ReservationAiV4CallbackIssue("PAYMENT_ATTACHMENT_IDS_EMPTY", "V4 PAYMENT attachment_ids 不能为空。"); + } + Set seen = new LinkedHashSet<>(); + for (JsonNode attachmentIdNode : attachmentIds) { + String attachmentId = attachmentIdNode.isTextual() ? trimToNull(attachmentIdNode.asText()) : null; + if (attachmentId == null) { + return new ReservationAiV4CallbackIssue("PAYMENT_ATTACHMENT_ID_INVALID", "V4 PAYMENT attachment_ids 只能包含非空字符串。"); + } + if (!seen.add(attachmentId)) { + return new ReservationAiV4CallbackIssue("PAYMENT_ATTACHMENT_ID_DUPLICATE", "V4 PAYMENT attachment_ids 存在重复 ID。"); + } + if (!packageContext.attachmentIds().contains(attachmentId)) { + return new ReservationAiV4CallbackIssue( + "PAYMENT_ATTACHMENT_ID_NOT_FOUND", + "V4 PAYMENT attachment_ids 引用了不存在的包级附件。"); + } + } + return null; + } + + /** + * 校验 manual_review=true 是否能由当前对象中的未解决字段解释。 + */ + private ReservationAiV4CallbackIssue inspectManualReviewConsistencyIssue(JsonNode event) { + boolean manualReview = isBooleanTrue(event.path("manual_review")); + boolean unresolved = hasEventUnresolvedMarker(event); + if (manualReview && !unresolved) { + return new ReservationAiV4CallbackIssue( + "MANUAL_REVIEW_WITHOUT_UNRESOLVED_FIELD", + "V4 manual_review=true 缺少可识别未解决字段。"); + } + if (!manualReview && unresolved) { + return new ReservationAiV4CallbackIssue( + "MANUAL_REVIEW_REQUIRED_FOR_UNRESOLVED_FIELD", + "V4 未解决字段必须同时 manual_review=true。"); + } + return null; + } + + /** + * 判断 V4 event 是否存在被 Schema 允许的未解决标记。 + */ + private boolean hasEventUnresolvedMarker(JsonNode event) { + if (hasTargetOrderUnresolvedMarker(event.path("target_order"))) { + return true; + } + String eventType = textAt(event, "event_type"); + if ("NEW_BOOKING".equals(eventType)) { + return event.path("arrival_date").isNull() + || event.path("departure_date").isNull() + || event.path("rate_code").isNull() + || event.path("room_items").size() == 0 + || hasRoomItemsUnresolved(event.path("room_items")); + } + if ("UPDATE_BOOKING".equals(eventType)) { + JsonNode after = event.path("after"); + return hasNullChild(after) || after.path("room_items").isNull() || hasRoomItemsUnresolved(after.path("room_items")); + } + if ("TRACE_RESERVATION_NOTES".equals(eventType)) { + return hasTraceUnresolved(event.path("trace_items")); + } + return false; + } + + /** + * 判断 target_order 是否包含未解决字段。 + */ + private boolean hasTargetOrderUnresolvedMarker(JsonNode targetOrder) { + return targetOrder == null + || targetOrder.path("booking_type").isNull() + || targetOrder.path("locator_type").isNull() + || targetOrder.path("locator_value").isNull(); + } + + /** + * 判断 room_items 内是否有待人工补正字段。 + */ + private boolean hasRoomItemsUnresolved(JsonNode roomItems) { + if (roomItems == null || !roomItems.isArray()) { + return false; + } + for (JsonNode roomItem : roomItems) { + if (roomItem.path("room_type_code").isNull() || roomItem.path("room_count").isNull()) { + return true; + } + } + return false; + } + + /** + * 判断对象直接子字段是否包含 null,用于 V4 Update 稀疏 after 的未解决值识别。 + */ + private boolean hasNullChild(JsonNode node) { + if (node == null || !node.isObject()) { + return false; + } + for (Map.Entry field : node.properties()) { + if (field.getValue().isNull()) { + return true; + } + } + return false; + } + + /** + * 判断 Trace item 是否存在允许人工复核补正的 null 字段。 + */ + private boolean hasTraceUnresolved(JsonNode traceItems) { + if (traceItems == null || !traceItems.isArray()) { + return false; + } + for (JsonNode traceItem : traceItems) { + if (traceItem.path("text").isNull() + || traceItem.path("department_code").isNull() + || traceItem.path("target_room_type_code").isNull() + || traceItem.path("extra_bed_room_count").isNull()) { + return true; + } + } + return false; + } + + /** + * V4 manual_review 仅允许 null 或布尔 true,不接受 false 或对象。 + */ + private boolean isManualReviewNullOrTrue(JsonNode manualReview) { + return manualReview != null && (manualReview.isNull() || isBooleanTrue(manualReview)); + } + + /** + * 判断字段为 null 或正整数,避免房量、加床数量接受小数、负数或超大整数。 + */ + private boolean isPositiveIntOrNull(JsonNode node) { + if (node == null || node.isNull()) { + return true; + } + if (!node.isIntegralNumber()) { + return false; + } + BigInteger value = node.bigIntegerValue(); + return value.compareTo(BigInteger.ZERO) > 0 + && value.compareTo(BigInteger.valueOf(Integer.MAX_VALUE)) <= 0; + } + + /** + * 收集 V4 order_ref。 + */ + private Set collectOrderRefs(JsonNode orderContexts) { + Set orderRefs = new LinkedHashSet<>(); + for (JsonNode orderContext : orderContexts) { + orderRefs.add(trimToNull(textAt(orderContext, "order_ref"))); + } + return orderRefs; + } + + /** + * 收集 V4 包级附件 ID。 + */ + private Set collectAttachmentIds(JsonNode attachments) { + Set attachmentIds = new LinkedHashSet<>(); + for (JsonNode attachment : attachments) { + attachmentIds.add(trimToNull(textAt(attachment, "id"))); + } + return attachmentIds; + } + + /** + * 判断字段值是否为字符串或 null。 + */ + private boolean isStringOrNull(JsonNode node) { + return node != null && (node.isNull() || node.isTextual()); + } + + /** + * 判断 JSON 节点是否为布尔 true。 + */ + private boolean isBooleanTrue(JsonNode node) { + return node != null && node.isBoolean() && node.booleanValue(); + } + + /** + * 从对象节点中读取文本字段,缺失或 null 时返回 null。 + */ + private String textAt(JsonNode node, String fieldName) { + if (node == null || node.isMissingNode() || node.get(fieldName) == null || node.get(fieldName).isNull()) { + return null; + } + return node.get(fieldName).asText(); + } + + /** + * 将空白字符串转换为 null。 + */ + private String trimToNull(String value) { + if (value == null) { + return null; + } + String trimmed = value.trim(); + return trimmed.isEmpty() ? null : trimmed; + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationAiV4PackageContext.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationAiV4PackageContext.java new file mode 100644 index 0000000..2fa9e34 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationAiV4PackageContext.java @@ -0,0 +1,12 @@ +package cn.nianxx.thhotel.workflows.reservation.service.impl; + +import java.util.Set; + +/** + * V4 包级上下文。orderRefs 用于校验 event 归属,attachmentIds 用于校验 Payment 附件引用。 + */ +record ReservationAiV4PackageContext( + Set orderRefs, + Set attachmentIds +) { +} diff --git a/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/SuperAgentTaskResultControllerTest.java b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/SuperAgentTaskResultControllerTest.java index 882ddd1..2c64f7e 100644 --- a/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/SuperAgentTaskResultControllerTest.java +++ b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/SuperAgentTaskResultControllerTest.java @@ -1032,6 +1032,56 @@ class SuperAgentTaskResultControllerTest { assertThat(transitionCount).isEqualTo(2L); } + @Test + void shouldCreateV4CancelTraceAndRoomingListTasksInEventOrder() throws Exception { + SourceMessageCaptureResult source = captureSourceMessage("mail-v4-cancel-trace-rooming-001"); + String body = v4CancelTraceRoomingListBody("mail-v4-cancel-trace-rooming-001"); + + MvcResult result = mockMvc.perform(signedPost(body, "nonce-v4-cancel-trace-rooming-001")) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.source_message_id").value("mail-v4-cancel-trace-rooming-001")) + .andExpect(jsonPath("$.accepted_count").value(3)) + .andExpect(jsonPath("$.items[0].route_code").value("R06_CANCEL_GROUP_BLOCK_NORMAL")) + .andExpect(jsonPath("$.items[0].system_task_type").value("CANCEL_BOOKING")) + .andExpect(jsonPath("$.items[0].task_card_type").value("CANCEL_BOOKING")) + .andExpect(jsonPath("$.items[0].execution_order").value(1)) + .andExpect(jsonPath("$.items[1].route_code").value("R14_GENERAL_REQUEST_NORMAL")) + .andExpect(jsonPath("$.items[1].system_task_type").value("UPDATE_BOOKING")) + .andExpect(jsonPath("$.items[1].task_card_type").value("TRACE_RESERVATION_NOTES")) + .andExpect(jsonPath("$.items[1].execution_order").value(2)) + .andExpect(jsonPath("$.items[2].route_code").value("R11_ROOMING_LIST_NORMAL")) + .andExpect(jsonPath("$.items[2].system_task_type").value("UPDATE_BOOKING")) + .andExpect(jsonPath("$.items[2].task_card_type").value("ROOMING_LIST")) + .andExpect(jsonPath("$.items[2].execution_order").value(3)) + .andReturn(); + + String firstOrderId = com.jayway.jsonpath.JsonPath.read( + result.getResponse().getContentAsString(), + "$.items[0].order_id"); + String secondOrderId = com.jayway.jsonpath.JsonPath.read( + result.getResponse().getContentAsString(), + "$.items[1].order_id"); + String thirdOrderId = com.jayway.jsonpath.JsonPath.read( + result.getResponse().getContentAsString(), + "$.items[2].order_id"); + assertThat(secondOrderId).isEqualTo(firstOrderId); + assertThat(thirdOrderId).isEqualTo(firstOrderId); + + Long transitionCount = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) + FROM workflow_reservation_ai_transition + WHERE source_message_id = ? + AND catalog_code = 'M002V4' + AND skill_id = 'booking-desk-event-v4' + AND route_code IN ( + 'R06_CANCEL_GROUP_BLOCK_NORMAL', + 'R14_GENERAL_REQUEST_NORMAL', + 'R11_ROOMING_LIST_NORMAL' + ) + """, Long.class, source.inboxId()); + assertThat(transitionCount).isEqualTo(3L); + } + @Test void shouldCreateReadOnlyTaskForV4S10WithoutLegacyResultType() throws Exception { SourceMessageCaptureResult source = captureSourceMessage("mail-v4-s10-entry-result-001"); @@ -1058,13 +1108,59 @@ class SuperAgentTaskResultControllerTest { } @Test - void shouldRejectMalformedV4PackageWithClearAdapterContractError() throws Exception { + void shouldPersistPackageAdapterContractErrorWhenV4RootIsMalformed() throws Exception { + SourceMessageCaptureResult source = captureSourceMessage("mail-v4-malformed-missing-order-contexts-001"); String body = v4MalformedMissingOrderContextsBody("mail-v4-malformed-missing-order-contexts-001"); mockMvc.perform(signedPost(body, "nonce-v4-malformed-missing-order-contexts-001")) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.error_code").value("ADAPTER_CONTRACT_ERROR")) - .andExpect(jsonPath("$.message").value("V4 根结构字段不完整。")); + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.accepted_count").value(1)) + .andExpect(jsonPath("$.items[0].adapter_error_code").value("V4_ROOT_CONTRACT_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()); + Long adapterErrorCount = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) + FROM workflow_reservation_ai_transition + WHERE source_message_id = ? + AND catalog_code = 'M002V4' + AND system_process_category = 'ADAPTER_CONTRACT_ERROR' + AND adapter_error_code = 'V4_ROOT_CONTRACT_INVALID' + """, Long.class, source.inboxId()); + assertThat(taskCount).isZero(); + assertThat(adapterErrorCount).isEqualTo(1L); + } + + @Test + void shouldPersistPackageAdapterContractErrorWhenV4S10OmitsEmptyArrays() throws Exception { + SourceMessageCaptureResult source = captureSourceMessage("mail-v4-s10-missing-empty-arrays-001"); + String body = v4S10MissingArraysBody("mail-v4-s10-missing-empty-arrays-001"); + + mockMvc.perform(signedPost(body, "nonce-v4-s10-missing-empty-arrays-001")) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.accepted_count").value(1)) + .andExpect(jsonPath("$.items[0].adapter_error_code").value("V4_ROOT_CONTRACT_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()); + Long adapterErrorCount = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) + FROM workflow_reservation_ai_transition + WHERE source_message_id = ? + AND catalog_code = 'M002V4' + AND system_process_category = 'ADAPTER_CONTRACT_ERROR' + AND adapter_error_code = 'V4_ROOT_CONTRACT_INVALID' + """, Long.class, source.inboxId()); + assertThat(taskCount).isZero(); + assertThat(adapterErrorCount).isEqualTo(1L); } @Test @@ -1094,6 +1190,36 @@ class SuperAgentTaskResultControllerTest { assertThat(adapterErrorCount).isEqualTo(1L); } + @Test + void shouldNotLetInvalidV4SiblingEventBlockLaterValidEvent() throws Exception { + SourceMessageCaptureResult source = captureSourceMessage("mail-v4-invalid-payment-then-cancel-001"); + String body = v4InvalidPaymentThenCancelBody("mail-v4-invalid-payment-then-cancel-001"); + + mockMvc.perform(signedPost(body, "nonce-v4-invalid-payment-then-cancel-001")) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.accepted_count").value(2)) + .andExpect(jsonPath("$.items[0].adapter_error_code").value("PAYMENT_ATTACHMENT_ID_NOT_FOUND")) + .andExpect(jsonPath("$.items[0].task_id").doesNotExist()) + .andExpect(jsonPath("$.items[1].route_code").value("R06_CANCEL_GROUP_BLOCK_NORMAL")) + .andExpect(jsonPath("$.items[1].task_id").exists()) + .andExpect(jsonPath("$.items[1].execution_order").value(1)); + + Long taskCount = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) + FROM workflow_reservation_task + WHERE source_message_id = ? + """, Long.class, source.inboxId()); + Long adapterErrorCount = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) + FROM workflow_reservation_ai_transition + WHERE source_message_id = ? + AND system_process_category = 'ADAPTER_CONTRACT_ERROR' + AND adapter_error_code = 'PAYMENT_ATTACHMENT_ID_NOT_FOUND' + """, Long.class, source.inboxId()); + assertThat(taskCount).isEqualTo(1L); + assertThat(adapterErrorCount).isEqualTo(1L); + } + @Test void shouldPersistAdapterContractErrorWhenV4UpdateContainsRateCode() throws Exception { SourceMessageCaptureResult source = captureSourceMessage("mail-v4-update-rate-code-001"); @@ -3383,6 +3509,72 @@ class SuperAgentTaskResultControllerTest { """.formatted(externalSourceMessageId); } + private String v4CancelTraceRoomingListBody(String externalSourceMessageId) { + return """ + { + "route_code": null, + "source_message": { + "source_message_id": "%s", + "conversation_id": "thread-v4-cancel-trace-rooming-001", + "subject": "Cancel, trace and rooming list", + "sender": "agent@example.test", + "sent_at": "2026-07-18T02:20:00Z", + "body": "Please cancel group, add trace and note rooming list.", + "body_content_type": "text/plain", + "attachments": [] + }, + "order_contexts": [ + { + "order_ref": "order-1", + "basic_information": { + "account_code": "QBD_TRAVEL", + "manual_review": null + } + } + ], + "message_events": [ + { + "order_ref": "order-1", + "event_type": "CANCEL_BOOKING", + "target_order": { + "booking_type": "GROUP", + "locator_type": "GROUP_CODE", + "locator_value": "GRP-V4-CTR-001" + }, + "manual_review": null + }, + { + "order_ref": "order-1", + "event_type": "TRACE_RESERVATION_NOTES", + "target_order": { + "booking_type": "GROUP", + "locator_type": "GROUP_CODE", + "locator_value": "GRP-V4-CTR-001" + }, + "trace_items": [ + { + "item_type": "GENERAL", + "text": "Guest requests quiet rooms.", + "department_code": "FO" + } + ], + "manual_review": null + }, + { + "order_ref": "order-1", + "event_type": "ROOMING_LIST", + "target_order": { + "booking_type": "GROUP", + "locator_type": "GROUP_CODE", + "locator_value": "GRP-V4-CTR-001" + }, + "manual_review": null + } + ] + } + """.formatted(externalSourceMessageId); + } + private String v4S10Body(String externalSourceMessageId) { return """ { @@ -3422,6 +3614,24 @@ class SuperAgentTaskResultControllerTest { """.formatted(externalSourceMessageId); } + private String v4S10MissingArraysBody(String externalSourceMessageId) { + return """ + { + "route_code": "S10", + "source_message": { + "source_message_id": "%s", + "conversation_id": "thread-v4-s10-missing-empty-arrays-001", + "subject": "V4 S10 without empty arrays", + "sender": "agent@example.test", + "sent_at": "2026-07-18T02:15:00Z", + "body": "S10 package omitted empty arrays.", + "body_content_type": "text/plain", + "attachments": [] + } + } + """.formatted(externalSourceMessageId); + } + private String v4PaymentBody(String externalSourceMessageId, String attachmentIdsJson) { return """ { @@ -3470,6 +3680,56 @@ class SuperAgentTaskResultControllerTest { """.formatted(externalSourceMessageId, attachmentIdsJson); } + private String v4InvalidPaymentThenCancelBody(String externalSourceMessageId) { + return """ + { + "route_code": null, + "source_message": { + "source_message_id": "%s", + "conversation_id": "thread-v4-invalid-payment-then-cancel-001", + "subject": "Bad payment followed by cancel", + "sender": "agent@example.test", + "sent_at": "2026-07-18T02:25:00Z", + "body": "Payment reference failed but cancel is valid.", + "body_content_type": "text/plain", + "attachments": [] + }, + "order_contexts": [ + { + "order_ref": "order-1", + "basic_information": { + "account_code": "QBD_TRAVEL", + "manual_review": null + } + } + ], + "message_events": [ + { + "order_ref": "order-1", + "event_type": "PAYMENT", + "target_order": { + "booking_type": "GROUP", + "locator_type": "GROUP_CODE", + "locator_value": "GRP-V4-BAD-PAY-001" + }, + "attachment_ids": ["att-missing"], + "manual_review": null + }, + { + "order_ref": "order-1", + "event_type": "CANCEL_BOOKING", + "target_order": { + "booking_type": "GROUP", + "locator_type": "GROUP_CODE", + "locator_value": "GRP-V4-CANCEL-AFTER-BAD-001" + }, + "manual_review": null + } + ] + } + """.formatted(externalSourceMessageId); + } + private String v4UpdateWithRateCodeBody(String externalSourceMessageId) { return """ {