diff --git a/docs/project/frontend-backend/backend-to-frontend-notes.md b/docs/project/frontend-backend/backend-to-frontend-notes.md index 6d2e851..a5ba1dc 100644 --- a/docs/project/frontend-backend/backend-to-frontend-notes.md +++ b/docs/project/frontend-backend/backend-to-frontend-notes.md @@ -455,6 +455,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 回调接口。 - M002 V4 CP4 已完成入站写入新模型:普通 V4 业务包会写入 V4 订单任务、来源邮件展示卡、Basic Information 卡和业务卡;但 V4 工作台、订单任务详情、来源通知详情和卡片确认接口还没开放,前端当前不要调用 CP2 草案路径。 +- V4 任务卡的 `display_payload_json` 只保留后端白名单展示字段;`ai_payload_json` 才包含完整 SuperAgent 原始 event。后续 V4 查询接口不得把 `ai_payload_json`、附件 URL 或 raw evidence 直接给普通页面渲染。 - 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[]` 只读展示诊断信息。 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 fced303..6f22750 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 @@ -31,6 +31,7 @@ import cn.nianxx.thhotel.workflows.reservation.service.ReservationAiTaskIntakeSe import cn.nianxx.thhotel.workflows.reservation.service.ReservationV4TaskIntakeService; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.ObjectNode; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; @@ -1144,11 +1145,119 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta payload.put("result_type", route.resultType()); payload.put("task_type", route.taskType()); payload.put("task_subtype", route.taskSubtype()); - payload.set("target_order", event.path("target_order")); - payload.set("business_fields", event); + payload.set("target_order", v4TargetOrderDisplayPayload(event.path("target_order"))); + payload.set("business_fields", v4BusinessFieldsDisplayPayload(event)); return payload; } + /** + * 生成 V4 target_order 展示快照,只保留契约内定位三元组,避免未知字段进入前端展示 payload。 + */ + private ObjectNode v4TargetOrderDisplayPayload(JsonNode targetOrder) { + ObjectNode display = objectMapper.createObjectNode(); + copyJsonFieldIfPresent(display, targetOrder, "booking_type"); + copyJsonFieldIfPresent(display, targetOrder, "locator_type"); + copyJsonFieldIfPresent(display, targetOrder, "locator_value"); + return display; + } + + /** + * 生成 V4 业务字段展示快照。展示 payload 只复制白名单业务字段;完整 event 仅保留在 ai_payload_json。 + */ + private ObjectNode v4BusinessFieldsDisplayPayload(JsonNode event) { + ObjectNode fields = objectMapper.createObjectNode(); + copyJsonFieldIfPresent(fields, event, "order_ref"); + copyJsonFieldIfPresent(fields, event, "event_type"); + copyJsonFieldIfPresent(fields, event, "manual_review"); + switch (nullToEmpty(textAt(event, "event_type"))) { + case "NEW_BOOKING" -> copyV4NewBookingDisplayFields(fields, event); + case "UPDATE_BOOKING" -> copyV4UpdateBookingDisplayFields(fields, event); + case "TRACE_RESERVATION_NOTES" -> fields.set("trace_items", v4TraceItemsDisplayPayload(event.path("trace_items"))); + case "PAYMENT" -> copyJsonFieldIfPresent(fields, event, "attachment_ids"); + case "CANCEL_BOOKING", "ROOMING_LIST" -> { + // 当前 V4 契约下这两类 event 没有额外可编辑展示字段。 + } + default -> { + // 已由 V4 validator 拦截;这里保守返回公共字段。 + } + } + return fields; + } + + /** + * 复制 NEW_BOOKING 展示字段,room_items 逐项白名单化。 + */ + private void copyV4NewBookingDisplayFields(ObjectNode fields, JsonNode event) { + copyJsonFieldIfPresent(fields, event, "arrival_date"); + copyJsonFieldIfPresent(fields, event, "departure_date"); + copyJsonFieldIfPresent(fields, event, "rate_code"); + copyJsonFieldIfPresent(fields, event, "booking_scenario"); + copyJsonFieldIfPresent(fields, event, "guest_name"); + fields.set("room_items", v4RoomItemsDisplayPayload(event.path("room_items"))); + } + + /** + * 复制 UPDATE_BOOKING 展示字段,after 只保留第一版契约允许的稀疏更新字段。 + */ + private void copyV4UpdateBookingDisplayFields(ObjectNode fields, JsonNode event) { + JsonNode after = event.path("after"); + ObjectNode afterDisplay = objectMapper.createObjectNode(); + copyJsonFieldIfPresent(afterDisplay, after, "guest_name"); + copyJsonFieldIfPresent(afterDisplay, after, "arrival_date"); + copyJsonFieldIfPresent(afterDisplay, after, "departure_date"); + if (after.has("room_items")) { + JsonNode roomItems = after.path("room_items"); + afterDisplay.set("room_items", roomItems.isNull() ? objectMapper.nullNode() : v4RoomItemsDisplayPayload(roomItems)); + } + fields.set("after", afterDisplay); + } + + /** + * 生成 room_items[] 展示快照,只保留房型代码和房量。 + */ + private ArrayNode v4RoomItemsDisplayPayload(JsonNode roomItems) { + ArrayNode display = objectMapper.createArrayNode(); + if (roomItems == null || !roomItems.isArray()) { + return display; + } + for (JsonNode roomItem : roomItems) { + ObjectNode item = objectMapper.createObjectNode(); + copyJsonFieldIfPresent(item, roomItem, "room_type_code"); + copyJsonFieldIfPresent(item, roomItem, "room_count"); + display.add(item); + } + return display; + } + + /** + * 生成 trace_items[] 展示快照,只保留 Trace 契约字段。 + */ + private ArrayNode v4TraceItemsDisplayPayload(JsonNode traceItems) { + ArrayNode display = objectMapper.createArrayNode(); + if (traceItems == null || !traceItems.isArray()) { + return display; + } + for (JsonNode traceItem : traceItems) { + ObjectNode item = objectMapper.createObjectNode(); + copyJsonFieldIfPresent(item, traceItem, "item_type"); + copyJsonFieldIfPresent(item, traceItem, "text"); + copyJsonFieldIfPresent(item, traceItem, "department_code"); + copyJsonFieldIfPresent(item, traceItem, "target_room_type_code"); + copyJsonFieldIfPresent(item, traceItem, "extra_bed_room_count"); + display.add(item); + } + return display; + } + + /** + * 按字段名复制 JSON 值,保留 null 语义,但不复制缺失字段。 + */ + private void copyJsonFieldIfPresent(ObjectNode target, JsonNode source, String fieldName) { + if (source != null && source.has(fieldName)) { + target.set(fieldName, source.get(fieldName)); + } + } + /** * 归一 Parent split 的 Parent Group key;只补齐 adapter 派生用副本,不修改请求原文。 */ 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 e01f64e..aa7696e 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 @@ -1081,6 +1081,14 @@ class SuperAgentTaskResultControllerTest { .contains("att-pay-1") .contains("payment-slip.jpg") .doesNotContain("https://oss.example.test"); + List displayPayloads = jdbcTemplate.queryForList(""" + SELECT display_payload_json + FROM workflow_reservation_v4_task_card + WHERE source_message_id = ? + """, String.class, source.inboxId()); + assertThat(displayPayloads).allSatisfy(displayPayload -> assertThat(displayPayload) + .doesNotContain("https://oss.example.test") + .doesNotContain("raw-event-attachment.jpg")); mockMvc.perform(signedPost(body, "nonce-v4-business-root-replay-001")) .andExpect(status().isOk()) @@ -3756,6 +3764,13 @@ class SuperAgentTaskResultControllerTest { "locator_value": "GRP-V4-001" }, "attachment_ids": ["att-pay-1"], + "evidence_url": "https://oss.example.test/raw-event-attachment.jpg", + "attachments": [ + { + "name": "raw-event-attachment.jpg", + "url": "https://oss.example.test/raw-event-attachment.jpg" + } + ], "manual_review": null } ]