修复V4审计查询误匹配和脱敏
This commit is contained in:
@@ -43,6 +43,8 @@ import cn.nianxx.thhotel.workflows.reservation.mapper.ReservationTaskCardMapper;
|
|||||||
import cn.nianxx.thhotel.workflows.reservation.mapper.ReservationTaskMapper;
|
import cn.nianxx.thhotel.workflows.reservation.mapper.ReservationTaskMapper;
|
||||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
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.time.LocalDateTime;
|
||||||
import java.util.Collection;
|
import java.util.Collection;
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
@@ -70,6 +72,7 @@ public class MybatisReservationAiWorkflowRepository implements ReservationAiWork
|
|||||||
private final ReservationOperaOperationMapper operaOperationMapper;
|
private final ReservationOperaOperationMapper operaOperationMapper;
|
||||||
private final ReservationOperaOperationAttemptMapper operaOperationAttemptMapper;
|
private final ReservationOperaOperationAttemptMapper operaOperationAttemptMapper;
|
||||||
private final ReservationAuditLogMapper auditLogMapper;
|
private final ReservationAuditLogMapper auditLogMapper;
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 注入本工作流 Mapper,Repository 负责实体转换和查询细节。
|
* 注入本工作流 Mapper,Repository 负责实体转换和查询细节。
|
||||||
@@ -82,7 +85,8 @@ public class MybatisReservationAiWorkflowRepository implements ReservationAiWork
|
|||||||
ReservationTaskCardMapper taskCardMapper,
|
ReservationTaskCardMapper taskCardMapper,
|
||||||
ReservationOperaOperationMapper operaOperationMapper,
|
ReservationOperaOperationMapper operaOperationMapper,
|
||||||
ReservationOperaOperationAttemptMapper operaOperationAttemptMapper,
|
ReservationOperaOperationAttemptMapper operaOperationAttemptMapper,
|
||||||
ReservationAuditLogMapper auditLogMapper) {
|
ReservationAuditLogMapper auditLogMapper,
|
||||||
|
ObjectMapper objectMapper) {
|
||||||
this.batchMapper = batchMapper;
|
this.batchMapper = batchMapper;
|
||||||
this.transitionMapper = transitionMapper;
|
this.transitionMapper = transitionMapper;
|
||||||
this.orderMapper = orderMapper;
|
this.orderMapper = orderMapper;
|
||||||
@@ -91,6 +95,7 @@ public class MybatisReservationAiWorkflowRepository implements ReservationAiWork
|
|||||||
this.operaOperationMapper = operaOperationMapper;
|
this.operaOperationMapper = operaOperationMapper;
|
||||||
this.operaOperationAttemptMapper = operaOperationAttemptMapper;
|
this.operaOperationAttemptMapper = operaOperationAttemptMapper;
|
||||||
this.auditLogMapper = auditLogMapper;
|
this.auditLogMapper = auditLogMapper;
|
||||||
|
this.objectMapper = objectMapper;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -941,24 +946,53 @@ public class MybatisReservationAiWorkflowRepository implements ReservationAiWork
|
|||||||
return List.of();
|
return List.of();
|
||||||
}
|
}
|
||||||
String targetFieldToken = "\"" + targetFieldName + "\"";
|
String targetFieldToken = "\"" + targetFieldName + "\"";
|
||||||
String targetIdToken = "\"" + targetId + "\"";
|
|
||||||
return auditLogMapper.selectList(Wrappers.<ReservationAuditLogEntity>lambdaQuery()
|
return auditLogMapper.selectList(Wrappers.<ReservationAuditLogEntity>lambdaQuery()
|
||||||
.eq(ReservationAuditLogEntity::getHotelId, hotelId)
|
.eq(ReservationAuditLogEntity::getHotelId, hotelId)
|
||||||
.in(ReservationAuditLogEntity::getAction, actions)
|
.in(ReservationAuditLogEntity::getAction, actions)
|
||||||
.and(wrapper -> wrapper
|
.and(wrapper -> wrapper
|
||||||
.and(beforeSnapshot -> beforeSnapshot
|
.like(ReservationAuditLogEntity::getBeforeSnapshotJson, targetFieldToken)
|
||||||
.like(ReservationAuditLogEntity::getBeforeSnapshotJson, targetFieldToken)
|
.or()
|
||||||
.like(ReservationAuditLogEntity::getBeforeSnapshotJson, targetIdToken))
|
.like(ReservationAuditLogEntity::getAfterSnapshotJson, targetFieldToken))
|
||||||
.or(afterSnapshot -> afterSnapshot
|
|
||||||
.like(ReservationAuditLogEntity::getAfterSnapshotJson, targetFieldToken)
|
|
||||||
.like(ReservationAuditLogEntity::getAfterSnapshotJson, targetIdToken)))
|
|
||||||
.orderByAsc(ReservationAuditLogEntity::getOccurredAt)
|
.orderByAsc(ReservationAuditLogEntity::getOccurredAt)
|
||||||
.orderByAsc(ReservationAuditLogEntity::getId))
|
.orderByAsc(ReservationAuditLogEntity::getId))
|
||||||
.stream()
|
.stream()
|
||||||
.map(this::toAuditLogSnapshot)
|
.map(this::toAuditLogSnapshot)
|
||||||
|
.filter(snapshot -> auditSnapshotContainsTarget(snapshot, targetFieldName, targetId.toString()))
|
||||||
.toList();
|
.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。该查询只用于补充待处理任务上下文,不替代订单查询。
|
* 按业务 key 查询 AI transition。该查询只用于补充待处理任务上下文,不替代订单查询。
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -502,6 +502,9 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService
|
|||||||
}
|
}
|
||||||
return sanitized;
|
return sanitized;
|
||||||
}
|
}
|
||||||
|
if (payload.isTextual() && isSensitiveAuditValue(payload.asText())) {
|
||||||
|
return NullNode.getInstance();
|
||||||
|
}
|
||||||
return payload;
|
return payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -513,13 +516,44 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService
|
|||||||
return "raw".equals(normalized)
|
return "raw".equals(normalized)
|
||||||
|| normalized.endsWith("_raw")
|
|| normalized.endsWith("_raw")
|
||||||
|| normalized.contains("payload_json")
|
|| normalized.contains("payload_json")
|
||||||
|
|| normalized.contains("ai_payload")
|
||||||
|
|| normalized.contains("raw_payload")
|
||||||
|| normalized.contains("private_url")
|
|| 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("secret")
|
||||||
|| normalized.contains("token")
|
|| 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("body")
|
||||||
|| normalized.contains("html");
|
|| 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<ReservationV4TaskCardSnapshot> cards) {
|
private ReservationV4CardCountsResult cardCounts(List<ReservationV4TaskCardSnapshot> cards) {
|
||||||
List<ReservationV4TaskCardSnapshot> safeCards = cards == null ? List.of() : cards;
|
List<ReservationV4TaskCardSnapshot> safeCards = cards == null ? List.of() : cards;
|
||||||
return new ReservationV4CardCountsResult(
|
return new ReservationV4CardCountsResult(
|
||||||
|
|||||||
@@ -382,6 +382,18 @@ class ReservationV4QueryControllerTest {
|
|||||||
Instant.parse("2026-07-18T04:13:00Z"));
|
Instant.parse("2026-07-18T04:13:00Z"));
|
||||||
insertV4OrderTaskAudit(otherOrderTask, "V4_CARD_CONFIRM", "v4-query-admin",
|
insertV4OrderTaskAudit(otherOrderTask, "V4_CARD_CONFIRM", "v4-query-admin",
|
||||||
Instant.parse("2026-07-18T04:14:00Z"));
|
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",
|
performAuthorized(mockMvc, adminToken(), get("/api/reservation/order-tasks/{orderTaskId}/audits",
|
||||||
orderTask.id()).param("hotel_id", HOTEL_ID))
|
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[1].after_snapshot.v4_order_task_id").value(orderTask.id().toString()))
|
||||||
.andExpect(jsonPath("$.items[0].occurred_at").value(matchesPattern(UTC_INSTANT_PATTERN)))
|
.andExpect(jsonPath("$.items[0].occurred_at").value(matchesPattern(UTC_INSTANT_PATTERN)))
|
||||||
.andExpect(content().string(not(containsString(otherOrderTask.id().toString()))))
|
.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
|
@Test
|
||||||
@@ -406,6 +425,21 @@ class ReservationV4QueryControllerTest {
|
|||||||
Instant.parse("2026-07-18T04:20:00Z"));
|
Instant.parse("2026-07-18T04:20:00Z"));
|
||||||
insertV4SourceNotificationAudit(notification, "v4-query-admin",
|
insertV4SourceNotificationAudit(notification, "v4-query-admin",
|
||||||
Instant.parse("2026-07-18T04:21:00Z"));
|
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",
|
performAuthorized(mockMvc, adminToken(), get("/api/reservation/source-notifications/{notificationId}/audits",
|
||||||
notification.id()).param("hotel_id", HOTEL_ID))
|
notification.id()).param("hotel_id", HOTEL_ID))
|
||||||
@@ -417,6 +451,7 @@ class ReservationV4QueryControllerTest {
|
|||||||
.andExpect(jsonPath("$.items[0].after_snapshot.v4_source_notification_id")
|
.andExpect(jsonPath("$.items[0].after_snapshot.v4_source_notification_id")
|
||||||
.value(notification.id().toString()))
|
.value(notification.id().toString()))
|
||||||
.andExpect(jsonPath("$.items[0].occurred_at").value(matchesPattern(UTC_INSTANT_PATTERN)))
|
.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"))));
|
.andExpect(content().string(not(containsString("Sensitive raw audit body"))));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -745,21 +780,54 @@ class ReservationV4QueryControllerTest {
|
|||||||
String action,
|
String action,
|
||||||
String actorId,
|
String actorId,
|
||||||
Instant occurredAt) {
|
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(
|
aiWorkflowRepository.insertAuditLog(new ReservationAuditLogDraft(
|
||||||
HOTEL_ID,
|
HOTEL_ID,
|
||||||
orderTask.orderId(),
|
orderId,
|
||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
"USER",
|
"USER",
|
||||||
actorId,
|
actorId,
|
||||||
action,
|
action,
|
||||||
"前端查询 V4 审计测试。",
|
summary,
|
||||||
"""
|
beforeSnapshotJson,
|
||||||
{"v4_order_task_id":"%s","card_status":"PENDING_CONFIRM","raw":"Sensitive raw audit body"}
|
afterSnapshotJson,
|
||||||
""".formatted(orderTask.id()),
|
|
||||||
"""
|
|
||||||
{"v4_order_task_id":"%s","card_status":"CONFIRMED","field_pointers":["/basic_information/account_code"]}
|
|
||||||
""".formatted(orderTask.id()),
|
|
||||||
LocalDateTime.ofInstant(occurredAt, ZoneOffset.UTC)));
|
LocalDateTime.ofInstant(occurredAt, ZoneOffset.UTC)));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -767,6 +835,26 @@ class ReservationV4QueryControllerTest {
|
|||||||
ReservationV4SourceNotificationSnapshot notification,
|
ReservationV4SourceNotificationSnapshot notification,
|
||||||
String actorId,
|
String actorId,
|
||||||
Instant occurredAt) {
|
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(
|
aiWorkflowRepository.insertAuditLog(new ReservationAuditLogDraft(
|
||||||
HOTEL_ID,
|
HOTEL_ID,
|
||||||
null,
|
null,
|
||||||
@@ -775,13 +863,9 @@ class ReservationV4QueryControllerTest {
|
|||||||
"USER",
|
"USER",
|
||||||
actorId,
|
actorId,
|
||||||
"V4_SOURCE_NOTIFICATION_ACK",
|
"V4_SOURCE_NOTIFICATION_ACK",
|
||||||
"确认已处理。",
|
summary,
|
||||||
"""
|
beforeSnapshotJson,
|
||||||
{"v4_source_notification_id":"%s","notification_status":"ACK_REQUIRED","raw":"Sensitive raw audit body"}
|
afterSnapshotJson,
|
||||||
""".formatted(notification.id()),
|
|
||||||
"""
|
|
||||||
{"v4_source_notification_id":"%s","notification_status":"ACKED"}
|
|
||||||
""".formatted(notification.id()),
|
|
||||||
LocalDateTime.ofInstant(occurredAt, ZoneOffset.UTC)));
|
LocalDateTime.ofInstant(occurredAt, ZoneOffset.UTC)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user