From 02bc01f9c958dde08417e5855158060da139a394 Mon Sep 17 00:00:00 2001 From: WorkBuddy Date: Mon, 12 Oct 2026 09:56:26 +0800 Subject: [PATCH] =?UTF-8?q?fix(matchmaker):=20=E5=8F=97=E6=8E=A7=E8=81=94?= =?UTF-8?q?=E7=B3=BB=E5=AE=A1=E8=AE=A1=20afterData=20=E6=94=B9=20JSON=20?= =?UTF-8?q?=E5=BA=8F=E5=88=97=E5=8C=96=EF=BC=88=E8=BF=90=E8=A1=8C=E6=97=B6?= =?UTF-8?q?=E5=AE=9E=E6=B5=8B=E6=8A=93=E5=88=B0=20PG=20jsonb=20500?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 根因:ContactAuthorizationService 7 处 saveAudit afterData 传 key=value 裸串,PG audit_logs.after_data 为 jsonb 严格解析 → request/disclose 全链路 500;H2 测试库该列 CLOB 不校验 → 集成测试假绿 - 修复:新增 jsonOf 辅助(Jackson writeValueAsString,成对 kv),7 调用点全部改写;purpose/reason 用户输入由 Jackson 转义防 JSON 注入 - 守卫加固:MatchmakerContactAuthorizationTest 审计断言补「afterData 必须可 JSON 解析且为对象」(readTree 真解析)——运行时事故进入自动化基线 - 同型排查:全仓 recordSensitiveAccess/saveAuditLog 其他调用点 afterData 干净,污染面仅本文件 - 验证:定向 20/20(授权 13+disabled 2+守卫 5);负向注入还原裸串 → 恰好 JSON 解析守卫判红(错误信息与生产一致 Unrecognized token 'channel')→ 还原复绿 - 发现渠道:本地栈运行时实测(V056 上线后首次真 PG 验证),非测试环境 --- .../service/ContactAuthorizationService.java | 40 +++++++++++++++---- .../MatchmakerContactAuthorizationTest.java | 7 ++++ 2 files changed, 40 insertions(+), 7 deletions(-) diff --git a/backend/src/main/java/com/danyang/match/platform/matchmaker/service/ContactAuthorizationService.java b/backend/src/main/java/com/danyang/match/platform/matchmaker/service/ContactAuthorizationService.java index 022a8f6..252cbc6 100644 --- a/backend/src/main/java/com/danyang/match/platform/matchmaker/service/ContactAuthorizationService.java +++ b/backend/src/main/java/com/danyang/match/platform/matchmaker/service/ContactAuthorizationService.java @@ -1,5 +1,7 @@ package com.danyang.match.platform.matchmaker.service; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; import com.danyang.match.platform.audit.entity.AuditLog; import com.danyang.match.platform.audit.entity.SensitiveAccessLog; import com.danyang.match.platform.audit.mapper.AuditLogMapper; @@ -23,6 +25,7 @@ import org.springframework.transaction.annotation.Transactional; import java.time.Instant; import java.util.Arrays; import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Optional; @@ -76,6 +79,9 @@ public class ContactAuthorizationService { private final SensitiveAccessLogMapper sensitiveAccessLogMapper; private final UserMapper userMapper; + /** afterData JSON 序列化(静态初始化,不参与构造注入,避免测试桩连带改动)。 */ + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + // ==================== ① request:红娘发起申请 ==================== @Transactional @@ -122,7 +128,7 @@ public class ContactAuthorizationService { auth.getId(), operatorId, userId, channel); saveAudit(operatorId, Constants.CONTACT_AUDIT_ACTION_REQUEST, auth.getId(), assignment != null ? assignment.getId() : null, - "matchmaker.contact.request", "channel=" + channel + ";purpose=" + auth.getPurpose()); + "matchmaker.contact.request", jsonOf("channel", channel, "purpose", auth.getPurpose())); return auth; } @@ -207,7 +213,7 @@ public class ContactAuthorizationService { log.info("受控联系授权审批通过: authId={}, approver={}", authId, approverOperatorId); saveAudit(approverOperatorId, Constants.CONTACT_AUDIT_ACTION_APPROVE, authId, activeAssignmentIdOf(auth), "matchmaker.contact.approve", - "result=approved;channel=" + auth.getChannel()); + jsonOf("result", "approved", "channel", auth.getChannel())); return auth; } @@ -230,7 +236,7 @@ public class ContactAuthorizationService { log.info("受控联系授权审批拒绝: authId={}, approver={}", authId, approverOperatorId); saveAudit(approverOperatorId, Constants.CONTACT_AUDIT_ACTION_APPROVE, authId, activeAssignmentIdOf(auth), "matchmaker.contact.approve", - "result=rejected;channel=" + auth.getChannel()); + jsonOf("result", "rejected", "channel", auth.getChannel())); return auth; } @@ -251,7 +257,7 @@ public class ContactAuthorizationService { log.info("受控联系授权用户同意: authId={}, userId={}", authId, userId); saveAudit(userId, Constants.CONTACT_AUDIT_ACTION_CONSENT, authId, activeAssignmentIdOf(auth), "matchmaker.contact.consent", - "channel=" + auth.getChannel()); + jsonOf("channel", auth.getChannel())); return auth; } @@ -268,7 +274,7 @@ public class ContactAuthorizationService { log.info("受控联系授权用户拒绝: authId={}, userId={}", authId, userId); saveAudit(userId, Constants.CONTACT_AUDIT_ACTION_DECLINE, authId, activeAssignmentIdOf(auth), "matchmaker.contact.decline", - "channel=" + auth.getChannel()); + jsonOf("channel", auth.getChannel())); return auth; } @@ -301,7 +307,7 @@ public class ContactAuthorizationService { log.info("受控联系授权撤销: authId={}, by={}", authId, operatorId); saveAudit(operatorId, Constants.CONTACT_AUDIT_ACTION_REVOKE, authId, activeAssignmentIdOf(auth), "matchmaker.contact.revoke", - "reason=" + auth.getRevokeReason()); + jsonOf("reason", auth.getRevokeReason())); return auth; } @@ -347,7 +353,7 @@ public class ContactAuthorizationService { auth.getId(), operatorId, userId, auth.getChannel()); saveAudit(operatorId, Constants.CONTACT_AUDIT_ACTION_DISCLOSE, auth.getId(), assignment != null ? assignment.getId() : null, - "matchmaker.contact.disclose", "channel=" + auth.getChannel()); + "matchmaker.contact.disclose", jsonOf("channel", auth.getChannel())); Map result = new HashMap<>(); result.put("phone", phone); @@ -447,4 +453,24 @@ public class ContactAuthorizationService { auditLog.setRequestTraceId(MDC.get("requestId")); auditLogMapper.insert(auditLog); } + + /** + * afterData 必须是合法 JSON(PG audit_logs.after_data 为 jsonb 严格解析; + * H2 测试库 CLOB 不校验——裸 key=value 拼接在测试侧假绿、生产侧 500)。 + * kv 成对传入,Jackson 负责用户输入(purpose/reason)的 JSON 转义,防 JSON 注入。 + */ + private String jsonOf(Object... kvPairs) { + if (kvPairs.length % 2 != 0) { + throw new IllegalStateException("jsonOf requires key-value pairs"); + } + Map data = new LinkedHashMap<>(); + for (int i = 0; i < kvPairs.length; i += 2) { + data.put(String.valueOf(kvPairs[i]), kvPairs[i + 1]); + } + try { + return OBJECT_MAPPER.writeValueAsString(data); + } catch (JsonProcessingException e) { + throw new IllegalStateException("audit afterData JSON serialization failed", e); + } + } } diff --git a/backend/src/test/java/com/danyang/match/platform/matchmaker/MatchmakerContactAuthorizationTest.java b/backend/src/test/java/com/danyang/match/platform/matchmaker/MatchmakerContactAuthorizationTest.java index a549761..2a71684 100644 --- a/backend/src/test/java/com/danyang/match/platform/matchmaker/MatchmakerContactAuthorizationTest.java +++ b/backend/src/test/java/com/danyang/match/platform/matchmaker/MatchmakerContactAuthorizationTest.java @@ -238,6 +238,13 @@ class MatchmakerContactAuthorizationTest { pair[0] + " 必须携带请求追踪 ID"); assertFalse(String.valueOf(audit.getAfterData()).contains(userPhone), pair[0] + " 审计内容不得含 phone"); + // afterData 必须是合法 JSON(PG audit_logs.after_data 为 jsonb 严格解析; + // H2 CLOB 不校验——裸 key=value 拼接在测试侧假绿、生产侧 500,此断言为运行时事故回归守卫) + com.fasterxml.jackson.databind.JsonNode afterJson = + assertDoesNotThrow(() -> new com.fasterxml.jackson.databind.ObjectMapper() + .readTree(String.valueOf(audit.getAfterData())), + pair[0] + " afterData 必须可 JSON 解析(PG jsonb 严格校验口径)"); + assertTrue(afterJson.isObject(), pair[0] + " afterData 必须是 JSON 对象"); } }