收口V4业务卡展示字段白名单

This commit is contained in:
andy
2026-07-19 01:09:31 +07:00
parent e59ac2f3bf
commit 99528b483f
3 changed files with 127 additions and 2 deletions

View File

@@ -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[]` 只读展示诊断信息。

View File

@@ -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 派生用副本,不修改请求原文。
*/

View File

@@ -1081,6 +1081,14 @@ class SuperAgentTaskResultControllerTest {
.contains("att-pay-1")
.contains("payment-slip.jpg")
.doesNotContain("https://oss.example.test");
List<String> 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
}
]