fix(matchmaker): 受控联系审计 afterData 改 JSON 序列化(运行时实测抓到 PG jsonb 500)

- 根因: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 验证),非测试环境
This commit is contained in:
WorkBuddy
2026-10-12 09:56:26 +08:00
parent 16c82739db
commit 02bc01f9c9
2 changed files with 40 additions and 7 deletions
@@ -1,5 +1,7 @@
package com.danyang.match.platform.matchmaker.service; 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.AuditLog;
import com.danyang.match.platform.audit.entity.SensitiveAccessLog; import com.danyang.match.platform.audit.entity.SensitiveAccessLog;
import com.danyang.match.platform.audit.mapper.AuditLogMapper; import com.danyang.match.platform.audit.mapper.AuditLogMapper;
@@ -23,6 +25,7 @@ import org.springframework.transaction.annotation.Transactional;
import java.time.Instant; import java.time.Instant;
import java.util.Arrays; import java.util.Arrays;
import java.util.HashMap; import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Optional; import java.util.Optional;
@@ -76,6 +79,9 @@ public class ContactAuthorizationService {
private final SensitiveAccessLogMapper sensitiveAccessLogMapper; private final SensitiveAccessLogMapper sensitiveAccessLogMapper;
private final UserMapper userMapper; private final UserMapper userMapper;
/** afterData JSON 序列化(静态初始化,不参与构造注入,避免测试桩连带改动)。 */
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
// ==================== ① request:红娘发起申请 ==================== // ==================== ① request:红娘发起申请 ====================
@Transactional @Transactional
@@ -122,7 +128,7 @@ public class ContactAuthorizationService {
auth.getId(), operatorId, userId, channel); auth.getId(), operatorId, userId, channel);
saveAudit(operatorId, Constants.CONTACT_AUDIT_ACTION_REQUEST, auth.getId(), saveAudit(operatorId, Constants.CONTACT_AUDIT_ACTION_REQUEST, auth.getId(),
assignment != null ? assignment.getId() : null, assignment != null ? assignment.getId() : null,
"matchmaker.contact.request", "channel=" + channel + ";purpose=" + auth.getPurpose()); "matchmaker.contact.request", jsonOf("channel", channel, "purpose", auth.getPurpose()));
return auth; return auth;
} }
@@ -207,7 +213,7 @@ public class ContactAuthorizationService {
log.info("受控联系授权审批通过: authId={}, approver={}", authId, approverOperatorId); log.info("受控联系授权审批通过: authId={}, approver={}", authId, approverOperatorId);
saveAudit(approverOperatorId, Constants.CONTACT_AUDIT_ACTION_APPROVE, authId, saveAudit(approverOperatorId, Constants.CONTACT_AUDIT_ACTION_APPROVE, authId,
activeAssignmentIdOf(auth), "matchmaker.contact.approve", activeAssignmentIdOf(auth), "matchmaker.contact.approve",
"result=approved;channel=" + auth.getChannel()); jsonOf("result", "approved", "channel", auth.getChannel()));
return auth; return auth;
} }
@@ -230,7 +236,7 @@ public class ContactAuthorizationService {
log.info("受控联系授权审批拒绝: authId={}, approver={}", authId, approverOperatorId); log.info("受控联系授权审批拒绝: authId={}, approver={}", authId, approverOperatorId);
saveAudit(approverOperatorId, Constants.CONTACT_AUDIT_ACTION_APPROVE, authId, saveAudit(approverOperatorId, Constants.CONTACT_AUDIT_ACTION_APPROVE, authId,
activeAssignmentIdOf(auth), "matchmaker.contact.approve", activeAssignmentIdOf(auth), "matchmaker.contact.approve",
"result=rejected;channel=" + auth.getChannel()); jsonOf("result", "rejected", "channel", auth.getChannel()));
return auth; return auth;
} }
@@ -251,7 +257,7 @@ public class ContactAuthorizationService {
log.info("受控联系授权用户同意: authId={}, userId={}", authId, userId); log.info("受控联系授权用户同意: authId={}, userId={}", authId, userId);
saveAudit(userId, Constants.CONTACT_AUDIT_ACTION_CONSENT, authId, saveAudit(userId, Constants.CONTACT_AUDIT_ACTION_CONSENT, authId,
activeAssignmentIdOf(auth), "matchmaker.contact.consent", activeAssignmentIdOf(auth), "matchmaker.contact.consent",
"channel=" + auth.getChannel()); jsonOf("channel", auth.getChannel()));
return auth; return auth;
} }
@@ -268,7 +274,7 @@ public class ContactAuthorizationService {
log.info("受控联系授权用户拒绝: authId={}, userId={}", authId, userId); log.info("受控联系授权用户拒绝: authId={}, userId={}", authId, userId);
saveAudit(userId, Constants.CONTACT_AUDIT_ACTION_DECLINE, authId, saveAudit(userId, Constants.CONTACT_AUDIT_ACTION_DECLINE, authId,
activeAssignmentIdOf(auth), "matchmaker.contact.decline", activeAssignmentIdOf(auth), "matchmaker.contact.decline",
"channel=" + auth.getChannel()); jsonOf("channel", auth.getChannel()));
return auth; return auth;
} }
@@ -301,7 +307,7 @@ public class ContactAuthorizationService {
log.info("受控联系授权撤销: authId={}, by={}", authId, operatorId); log.info("受控联系授权撤销: authId={}, by={}", authId, operatorId);
saveAudit(operatorId, Constants.CONTACT_AUDIT_ACTION_REVOKE, authId, saveAudit(operatorId, Constants.CONTACT_AUDIT_ACTION_REVOKE, authId,
activeAssignmentIdOf(auth), "matchmaker.contact.revoke", activeAssignmentIdOf(auth), "matchmaker.contact.revoke",
"reason=" + auth.getRevokeReason()); jsonOf("reason", auth.getRevokeReason()));
return auth; return auth;
} }
@@ -347,7 +353,7 @@ public class ContactAuthorizationService {
auth.getId(), operatorId, userId, auth.getChannel()); auth.getId(), operatorId, userId, auth.getChannel());
saveAudit(operatorId, Constants.CONTACT_AUDIT_ACTION_DISCLOSE, auth.getId(), saveAudit(operatorId, Constants.CONTACT_AUDIT_ACTION_DISCLOSE, auth.getId(),
assignment != null ? assignment.getId() : null, assignment != null ? assignment.getId() : null,
"matchmaker.contact.disclose", "channel=" + auth.getChannel()); "matchmaker.contact.disclose", jsonOf("channel", auth.getChannel()));
Map<String, Object> result = new HashMap<>(); Map<String, Object> result = new HashMap<>();
result.put("phone", phone); result.put("phone", phone);
@@ -447,4 +453,24 @@ public class ContactAuthorizationService {
auditLog.setRequestTraceId(MDC.get("requestId")); auditLog.setRequestTraceId(MDC.get("requestId"));
auditLogMapper.insert(auditLog); 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<String, Object> 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);
}
}
} }
@@ -238,6 +238,13 @@ class MatchmakerContactAuthorizationTest {
pair[0] + " 必须携带请求追踪 ID"); pair[0] + " 必须携带请求追踪 ID");
assertFalse(String.valueOf(audit.getAfterData()).contains(userPhone), assertFalse(String.valueOf(audit.getAfterData()).contains(userPhone),
pair[0] + " 审计内容不得含 phone"); 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 对象");
} }
} }