From cf3531a7482a3baa044ada9dd3657096f34aa889 Mon Sep 17 00:00:00 2001 From: andy Date: Mon, 20 Jul 2026 13:06:19 +0700 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8DV4=E5=AE=A1=E8=AE=A1=E6=9F=A5?= =?UTF-8?q?=E8=AF=A2=E8=AF=AF=E5=8C=B9=E9=85=8D=E5=92=8C=E8=84=B1=E6=95=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...ybatisReservationAiWorkflowRepository.java | 50 ++++++-- .../impl/ReservationV4QueryServiceImpl.java | 34 +++++ .../ReservationV4QueryControllerTest.java | 116 +++++++++++++++--- 3 files changed, 176 insertions(+), 24 deletions(-) diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/repository/MybatisReservationAiWorkflowRepository.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/repository/MybatisReservationAiWorkflowRepository.java index e49d21f..39efce3 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/repository/MybatisReservationAiWorkflowRepository.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/repository/MybatisReservationAiWorkflowRepository.java @@ -43,6 +43,8 @@ import cn.nianxx.thhotel.workflows.reservation.mapper.ReservationTaskCardMapper; import cn.nianxx.thhotel.workflows.reservation.mapper.ReservationTaskMapper; import com.baomidou.mybatisplus.core.toolkit.Wrappers; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; import java.time.LocalDateTime; import java.util.Collection; import java.util.LinkedHashMap; @@ -70,6 +72,7 @@ public class MybatisReservationAiWorkflowRepository implements ReservationAiWork private final ReservationOperaOperationMapper operaOperationMapper; private final ReservationOperaOperationAttemptMapper operaOperationAttemptMapper; private final ReservationAuditLogMapper auditLogMapper; + private final ObjectMapper objectMapper; /** * 注入本工作流 Mapper,Repository 负责实体转换和查询细节。 @@ -82,7 +85,8 @@ public class MybatisReservationAiWorkflowRepository implements ReservationAiWork ReservationTaskCardMapper taskCardMapper, ReservationOperaOperationMapper operaOperationMapper, ReservationOperaOperationAttemptMapper operaOperationAttemptMapper, - ReservationAuditLogMapper auditLogMapper) { + ReservationAuditLogMapper auditLogMapper, + ObjectMapper objectMapper) { this.batchMapper = batchMapper; this.transitionMapper = transitionMapper; this.orderMapper = orderMapper; @@ -91,6 +95,7 @@ public class MybatisReservationAiWorkflowRepository implements ReservationAiWork this.operaOperationMapper = operaOperationMapper; this.operaOperationAttemptMapper = operaOperationAttemptMapper; this.auditLogMapper = auditLogMapper; + this.objectMapper = objectMapper; } /** @@ -941,24 +946,53 @@ public class MybatisReservationAiWorkflowRepository implements ReservationAiWork return List.of(); } String targetFieldToken = "\"" + targetFieldName + "\""; - String targetIdToken = "\"" + targetId + "\""; return auditLogMapper.selectList(Wrappers.lambdaQuery() .eq(ReservationAuditLogEntity::getHotelId, hotelId) .in(ReservationAuditLogEntity::getAction, actions) .and(wrapper -> wrapper - .and(beforeSnapshot -> beforeSnapshot - .like(ReservationAuditLogEntity::getBeforeSnapshotJson, targetFieldToken) - .like(ReservationAuditLogEntity::getBeforeSnapshotJson, targetIdToken)) - .or(afterSnapshot -> afterSnapshot - .like(ReservationAuditLogEntity::getAfterSnapshotJson, targetFieldToken) - .like(ReservationAuditLogEntity::getAfterSnapshotJson, targetIdToken))) + .like(ReservationAuditLogEntity::getBeforeSnapshotJson, targetFieldToken) + .or() + .like(ReservationAuditLogEntity::getAfterSnapshotJson, targetFieldToken)) .orderByAsc(ReservationAuditLogEntity::getOccurredAt) .orderByAsc(ReservationAuditLogEntity::getId)) .stream() .map(this::toAuditLogSnapshot) + .filter(snapshot -> auditSnapshotContainsTarget(snapshot, targetFieldName, targetId.toString())) .toList(); } + /** + * 判断审计前后快照中是否精确指向目标对象,避免同一 JSON 中其他字段包含目标 ID 时误命中。 + */ + private boolean auditSnapshotContainsTarget( + ReservationAuditLogSnapshot snapshot, + String targetFieldName, + String targetId) { + return auditPayloadContainsTarget(snapshot.beforeSnapshotJson(), targetFieldName, targetId) + || auditPayloadContainsTarget(snapshot.afterSnapshotJson(), targetFieldName, targetId); + } + + /** + * 解析审计 JSON 顶层字段并比较目标 ID。解析失败的历史异常快照不参与 V4 审计关联。 + */ + private boolean auditPayloadContainsTarget(String payloadJson, String targetFieldName, String targetId) { + if (!hasText(payloadJson)) { + return false; + } + try { + JsonNode payload = objectMapper.readTree(payloadJson); + JsonNode value = payload == null ? null : payload.get(targetFieldName); + if (value == null || value.isNull() || value.isMissingNode()) { + return false; + } + return value.isTextual() || value.isNumber() + ? targetId.equals(value.asText()) + : false; + } catch (Exception ex) { + return false; + } + } + /** * 按业务 key 查询 AI transition。该查询只用于补充待处理任务上下文,不替代订单查询。 */ diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationV4QueryServiceImpl.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationV4QueryServiceImpl.java index 4b65bb1..834ab14 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationV4QueryServiceImpl.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationV4QueryServiceImpl.java @@ -502,6 +502,9 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService } return sanitized; } + if (payload.isTextual() && isSensitiveAuditValue(payload.asText())) { + return NullNode.getInstance(); + } return payload; } @@ -513,13 +516,44 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService return "raw".equals(normalized) || normalized.endsWith("_raw") || normalized.contains("payload_json") + || normalized.contains("ai_payload") + || normalized.contains("raw_payload") || normalized.contains("private_url") + || normalized.contains("attachment_url") + || normalized.contains("download_url") + || normalized.contains("signed_url") + || "url".equals(normalized) + || normalized.endsWith("_url") || normalized.contains("secret") || normalized.contains("token") + || normalized.contains("api_key") + || normalized.contains("apikey") + || normalized.contains("cookie") + || normalized.contains("authorization") + || normalized.contains("password") + || normalized.contains("plain_text") + || normalized.contains("email_text") + || normalized.contains("text_body") || normalized.contains("body") || normalized.contains("html"); } + /** + * 判断审计字段值是否像敏感链接或认证头,避免未命中字段名规则时泄漏 OSS / 签名 URL。 + */ + private boolean isSensitiveAuditValue(String value) { + if (!hasText(value)) { + return false; + } + String normalized = value.trim().toLowerCase(Locale.ROOT); + return normalized.startsWith("http://") + || normalized.startsWith("https://") + || normalized.startsWith("oss://") + || normalized.startsWith("s3://") + || normalized.startsWith("bearer ") + || normalized.startsWith("basic "); + } + private ReservationV4CardCountsResult cardCounts(List cards) { List safeCards = cards == null ? List.of() : cards; return new ReservationV4CardCountsResult( diff --git a/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationV4QueryControllerTest.java b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationV4QueryControllerTest.java index b77a885..e2e9338 100644 --- a/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationV4QueryControllerTest.java +++ b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationV4QueryControllerTest.java @@ -382,6 +382,18 @@ class ReservationV4QueryControllerTest { Instant.parse("2026-07-18T04:13:00Z")); insertV4OrderTaskAudit(otherOrderTask, "V4_CARD_CONFIRM", "v4-query-admin", Instant.parse("2026-07-18T04:14:00Z")); + insertV4OrderTaskAuditRaw( + otherOrderTask.orderId(), + "V4_CARD_CONFIRM", + "v4-query-admin", + "容易误命中的 V4 订单任务审计。", + """ + {"v4_order_task_id":"%s","related_card_id":"%s","marker":"false-positive-order-task"} + """.formatted(otherOrderTask.id(), orderTask.id()), + """ + {"v4_order_task_id":"%s","related_order_task_id":"%s","card_status":"CONFIRMED"} + """.formatted(otherOrderTask.id(), orderTask.id()), + Instant.parse("2026-07-18T04:15:00Z")); performAuthorized(mockMvc, adminToken(), get("/api/reservation/order-tasks/{orderTaskId}/audits", orderTask.id()).param("hotel_id", HOTEL_ID)) @@ -395,7 +407,14 @@ class ReservationV4QueryControllerTest { .andExpect(jsonPath("$.items[1].after_snapshot.v4_order_task_id").value(orderTask.id().toString())) .andExpect(jsonPath("$.items[0].occurred_at").value(matchesPattern(UTC_INSTANT_PATTERN))) .andExpect(content().string(not(containsString(otherOrderTask.id().toString())))) - .andExpect(content().string(not(containsString("Sensitive raw audit body")))); + .andExpect(content().string(not(containsString("false-positive-order-task")))) + .andExpect(content().string(not(containsString("Sensitive raw audit body")))) + .andExpect(content().string(not(containsString("https://oss.example.test")))) + .andExpect(content().string(not(containsString("guest email text")))) + .andExpect(content().string(not(containsString("sk-test-secret")))) + .andExpect(content().string(not(containsString("Bearer sensitive-token")))) + .andExpect(content().string(not(containsString("raw-ai-payload")))) + .andExpect(content().string(containsString("visible audit note"))); } @Test @@ -406,6 +425,21 @@ class ReservationV4QueryControllerTest { Instant.parse("2026-07-18T04:20:00Z")); insertV4SourceNotificationAudit(notification, "v4-query-admin", Instant.parse("2026-07-18T04:21:00Z")); + ReservationV4SourceNotificationSnapshot otherNotification = seedSourceNotification( + "mail-v4-query-notification-audits-other-001", + "S99", + Instant.parse("2026-07-18T04:22:00Z")); + insertV4SourceNotificationAuditRaw( + otherNotification, + "v4-query-admin", + "容易误命中的 V4 来源通知审计。", + """ + {"v4_source_notification_id":"%s","related_notification_id":"%s","marker":"false-positive-notification"} + """.formatted(otherNotification.id(), notification.id()), + """ + {"v4_source_notification_id":"%s","related_notification_id":"%s","notification_status":"ACKED"} + """.formatted(otherNotification.id(), notification.id()), + Instant.parse("2026-07-18T04:23:00Z")); performAuthorized(mockMvc, adminToken(), get("/api/reservation/source-notifications/{notificationId}/audits", notification.id()).param("hotel_id", HOTEL_ID)) @@ -417,6 +451,7 @@ class ReservationV4QueryControllerTest { .andExpect(jsonPath("$.items[0].after_snapshot.v4_source_notification_id") .value(notification.id().toString())) .andExpect(jsonPath("$.items[0].occurred_at").value(matchesPattern(UTC_INSTANT_PATTERN))) + .andExpect(content().string(not(containsString("false-positive-notification")))) .andExpect(content().string(not(containsString("Sensitive raw audit body")))); } @@ -745,21 +780,54 @@ class ReservationV4QueryControllerTest { String action, String actorId, Instant occurredAt) { + insertV4OrderTaskAuditRaw( + orderTask.orderId(), + action, + actorId, + "前端查询 V4 审计测试。", + """ + { + "v4_order_task_id":"%s", + "card_status":"PENDING_CONFIRM", + "raw":"Sensitive raw audit body", + "attachment_url":"https://oss.example.test/private.pdf", + "download_url":"https://oss.example.test/download/private.pdf", + "signed_url":"https://oss.example.test/signed/private.pdf", + "attachments":[{"name":"invoice.pdf","url":"https://oss.example.test/attachment/private.pdf"}], + "plain_text":"guest email text", + "email_text":"guest email text", + "api_key":"sk-test-secret", + "cookie":"SESSION=secret-cookie", + "authorization":"Bearer sensitive-token", + "ai_payload":{"payload":"raw-ai-payload"}, + "safe_note":"visible audit note" + } + """.formatted(orderTask.id()), + """ + {"v4_order_task_id":"%s","card_status":"CONFIRMED","field_pointers":["/basic_information/account_code"]} + """.formatted(orderTask.id()), + occurredAt); + } + + private void insertV4OrderTaskAuditRaw( + Long orderId, + String action, + String actorId, + String summary, + String beforeSnapshotJson, + String afterSnapshotJson, + Instant occurredAt) { aiWorkflowRepository.insertAuditLog(new ReservationAuditLogDraft( HOTEL_ID, - orderTask.orderId(), + orderId, null, null, "USER", actorId, action, - "前端查询 V4 审计测试。", - """ - {"v4_order_task_id":"%s","card_status":"PENDING_CONFIRM","raw":"Sensitive raw audit body"} - """.formatted(orderTask.id()), - """ - {"v4_order_task_id":"%s","card_status":"CONFIRMED","field_pointers":["/basic_information/account_code"]} - """.formatted(orderTask.id()), + summary, + beforeSnapshotJson, + afterSnapshotJson, LocalDateTime.ofInstant(occurredAt, ZoneOffset.UTC))); } @@ -767,6 +835,26 @@ class ReservationV4QueryControllerTest { ReservationV4SourceNotificationSnapshot notification, String actorId, Instant occurredAt) { + insertV4SourceNotificationAuditRaw( + notification, + actorId, + "确认已处理。", + """ + {"v4_source_notification_id":"%s","notification_status":"ACK_REQUIRED","raw":"Sensitive raw audit body"} + """.formatted(notification.id()), + """ + {"v4_source_notification_id":"%s","notification_status":"ACKED"} + """.formatted(notification.id()), + occurredAt); + } + + private void insertV4SourceNotificationAuditRaw( + ReservationV4SourceNotificationSnapshot notification, + String actorId, + String summary, + String beforeSnapshotJson, + String afterSnapshotJson, + Instant occurredAt) { aiWorkflowRepository.insertAuditLog(new ReservationAuditLogDraft( HOTEL_ID, null, @@ -775,13 +863,9 @@ class ReservationV4QueryControllerTest { "USER", actorId, "V4_SOURCE_NOTIFICATION_ACK", - "确认已处理。", - """ - {"v4_source_notification_id":"%s","notification_status":"ACK_REQUIRED","raw":"Sensitive raw audit body"} - """.formatted(notification.id()), - """ - {"v4_source_notification_id":"%s","notification_status":"ACKED"} - """.formatted(notification.id()), + summary, + beforeSnapshotJson, + afterSnapshotJson, LocalDateTime.ofInstant(occurredAt, ZoneOffset.UTC))); } }