Procházet zdrojové kódy

fix(日志): 通知跳过操作日志并脱敏敏感载荷

alphaH před 1 týdnem
rodič
revize
3e1005003f

+ 7 - 9
java/src/main/java/com/payment/platform/core/log/OperationLogInterceptor.java

@@ -30,11 +30,14 @@ public class OperationLogInterceptor implements HandlerInterceptor {
     private final OperationLogMapper operationLogMapper;
 
     private static final Set<String> RECORD_METHODS = Set.of("POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS");
-    private static final int MAX_PAYLOAD = 2000;
 
     @Override
     public void afterCompletion(HttpServletRequest request, HttpServletResponse response,
                                  Object handler, Exception ex) throws Exception {
+        // 上游异步通知是免登录入口,参数含 channelExtra 等敏感值 —— 一律不进 sys_log。
+        // 必须是第一条判断:通知既不该按方法过滤,也不该被 referer 等条件放行。
+        if (request.getRequestURI().contains("/payment/notify/")) return;
+
         if (!RECORD_METHODS.contains(request.getMethod())) return;
 
         // 跳过 Swagger/Knife4j 请求
@@ -82,16 +85,13 @@ public class OperationLogInterceptor implements HandlerInterceptor {
         if (request instanceof ContentCachingRequestWrapper wrapper) {
             byte[] buf = wrapper.getContentAsByteArray();
             if (buf.length > 0) {
-                String body = new String(buf, StandardCharsets.UTF_8);
-                if (body.length() > MAX_PAYLOAD) body = body.substring(0, MAX_PAYLOAD);
-                return body;
+                return SensitivePayloadRedactor.redact(new String(buf, StandardCharsets.UTF_8));
             }
         }
         // 尝试从 query string 获取参数
         String qs = request.getQueryString();
         if (qs != null && !qs.isEmpty()) {
-            if (qs.length() > MAX_PAYLOAD) qs = qs.substring(0, MAX_PAYLOAD);
-            return qs;
+            return SensitivePayloadRedactor.redact(qs);
         }
         return "{}";
     }
@@ -100,9 +100,7 @@ public class OperationLogInterceptor implements HandlerInterceptor {
         if (response instanceof ContentCachingResponseWrapper wrapper) {
             byte[] buf = wrapper.getContentAsByteArray();
             if (buf.length > 0) {
-                String body = new String(buf, StandardCharsets.UTF_8);
-                if (body.length() > MAX_PAYLOAD) body = body.substring(0, MAX_PAYLOAD);
-                return body;
+                return SensitivePayloadRedactor.redact(new String(buf, StandardCharsets.UTF_8));
             }
         }
         return "{}";

+ 119 - 0
java/src/main/java/com/payment/platform/core/log/SensitivePayloadRedactor.java

@@ -0,0 +1,119 @@
+package com.payment.platform.core.log;
+
+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 com.fasterxml.jackson.databind.node.TextNode;
+
+import java.util.Comparator;
+import java.util.Locale;
+import java.util.Set;
+import java.util.regex.Pattern;
+import java.util.stream.Collectors;
+
+/**
+ * 敏感载荷脱敏器(spec §11.2)。
+ *
+ * <p>JSON 递归脱敏;非 JSON(form / 纯文本)按 {@code key=value} 正则替换。
+ * <b>先截断到 2000 字符再脱敏</b>,避免大报文递归开销。
+ *
+ * <p>键名集合大小写不敏感。JSON 中命中键的整个值(含对象/数组子树)替换为
+ * 文本 {@code "***"};非 JSON 只替换到下一个 {@code &} 或空白。
+ */
+public final class SensitivePayloadRedactor {
+
+    /** 落库前保留的最大载荷长度(与旧 {@code OperationLogInterceptor.MAX_PAYLOAD} 一致)。 */
+    private static final int MAX_PAYLOAD = 2000;
+
+    private static final String MASK = "***";
+
+    /** spec §11.2 的键名集合;比较时统一小写(大小写不敏感)。 */
+    private static final Set<String> SENSITIVE_KEYS = Set.of(
+            "app_secret", "appsecret",
+            "api_secret", "apisecret",
+            "api_key", "apikey",
+            "password",
+            "sign",
+            "signature",
+            "auth_code", "authcode",
+            "openid",
+            "buyeruserid", "buyer_user_id",
+            "channeluserid", "channel_user_id",
+            "channeluser", "channel_user",
+            "channelextra", "channel_extra");
+
+    /**
+     * 非 JSON 路径的键名交替分支 —— 与 JSON 路径共用同一份键名集合,
+     * 长键优先(避免短键在交替分支里先试而漏掉更长的名字)。
+     */
+    private static final Pattern KEY_VALUE = Pattern.compile(
+            "(?i)(" + SENSITIVE_KEYS.stream()
+                    .sorted(Comparator.comparingInt(String::length).reversed())
+                    .map(Pattern::quote)
+                    .collect(Collectors.joining("|")) + ")=([^&\\s]*)");
+
+    private static final ObjectMapper MAPPER = new ObjectMapper();
+
+    private SensitivePayloadRedactor() {
+    }
+
+    /**
+     * 脱敏:先截断到 {@link #MAX_PAYLOAD},再按 JSON / 非 JSON 分派。
+     * {@code null} 原样返回。
+     */
+    public static String redact(String raw) {
+        if (raw == null) {
+            return null;
+        }
+        String truncated = raw.length() > MAX_PAYLOAD ? raw.substring(0, MAX_PAYLOAD) : raw;
+        String stripped = truncated.stripLeading();
+        if (stripped.startsWith("{") || stripped.startsWith("[")) {
+            try {
+                JsonNode node = MAPPER.readTree(truncated);
+                if (node != null && node.isContainerNode()) {
+                    return MAPPER.writeValueAsString(redactNode(node));
+                }
+            } catch (Exception ignored) {
+                // 截断可能把 JSON 切断;按非 JSON 的 key=value 规则兜底
+            }
+        }
+        return KEY_VALUE.matcher(truncated).replaceAll("$1=" + MASK);
+    }
+
+    /**
+     * API Key 只保留前 6 位,统一加 {@code ***};不足 6 位也加。
+     * {@code null} 返回字符串 {@code "null"}。
+     */
+    public static String maskApiKey(String apiKey) {
+        if (apiKey == null) {
+            return "null";
+        }
+        String prefix = apiKey.length() > 6 ? apiKey.substring(0, 6) : apiKey;
+        return prefix + MASK;
+    }
+
+    private static JsonNode redactNode(JsonNode node) {
+        if (node.isObject()) {
+            ObjectNode copy = MAPPER.createObjectNode();
+            node.fields().forEachRemaining(entry -> {
+                if (isSensitive(entry.getKey())) {
+                    copy.set(entry.getKey(), TextNode.valueOf(MASK));
+                } else {
+                    copy.set(entry.getKey(), redactNode(entry.getValue()));
+                }
+            });
+            return copy;
+        }
+        if (node.isArray()) {
+            ArrayNode copy = MAPPER.createArrayNode();
+            node.forEach(item -> copy.add(redactNode(item)));
+            return copy;
+        }
+        return node;
+    }
+
+    private static boolean isSensitive(String key) {
+        return key != null && SENSITIVE_KEYS.contains(key.toLowerCase(Locale.ROOT));
+    }
+}

+ 8 - 7
java/src/main/java/com/payment/platform/core/security/TenantApiKeyAuthFilter.java

@@ -10,6 +10,7 @@ import com.payment.platform.module.payment.apikey.entity.TenantApiKeyEntity;
 import com.payment.platform.module.payment.apikey.entity.TenantApiLogEntity;
 import com.payment.platform.module.payment.apikey.mapper.TenantApiKeyMapper;
 import com.payment.platform.module.payment.apikey.mapper.TenantApiLogMapper;
+import com.payment.platform.module.payment.unified.util.UnifiedLogSanitizer;
 import jakarta.servlet.FilterChain;
 import jakarta.servlet.ReadListener;
 import jakarta.servlet.ServletException;
@@ -239,15 +240,15 @@ public class TenantApiKeyAuthFilter extends OncePerRequestFilter {
         //     Python: TenantApiKeyService.verify_signature(api_key_obj.api_secret, request_data, signature)
         //     → SignatureGenerator.verify_signature(api_secret, request_data, signature)
         if (!SignatureGenerator.verifySignature(keyEntity.getApiSecret(), requestData, signature)) {
-            // 诊断日志: 记录后端实际收到的请求体与两端签名, 用于联调定位 (不记录 apiSecret)
+            // 诊断日志: 只记 apiKey 前缀、收到的签名前 8 位与脱敏后的请求体。
+            // ★ 永不打印服务端计算出的签名值 —— 它是一条可直接重放的合法签名。
             try {
-                log.error("OpenAPI签名验证失败: apiKey={}, tenantId={}, 收到Signature={}, 期望签名={}, requestData={}",
+                log.error("OpenAPI签名验证失败: {}", UnifiedLogSanitizer.signatureFailure(
                         apiKey, keyEntity.getTenantId(), signature,
-                        SignatureGenerator.generateSignature(keyEntity.getApiSecret(), requestData),
-                        objectMapper.writeValueAsString(requestData));
-            } catch (Exception e) {
-                log.error("OpenAPI签名验证失败: apiKey={}, 收到Signature={} (记录请求体失败: {})",
-                        apiKey, signature, e.getMessage());
+                        objectMapper.writeValueAsString(requestData)));
+            } catch (Exception ignored) {
+                log.error("OpenAPI签名验证失败: {}", UnifiedLogSanitizer.signatureFailure(
+                        apiKey, keyEntity.getTenantId(), signature, "<unavailable>"));
             }
             // Python L106: "Invalid Signature"
             logApiCall(keyEntity.getId(), keyEntity.getTenantId(), request, response, 401, startTime);

+ 86 - 0
java/src/main/java/com/payment/platform/module/payment/unified/util/UnifiedLogSanitizer.java

@@ -0,0 +1,86 @@
+package com.payment.platform.module.payment.unified.util;
+
+import com.payment.platform.core.log.SensitivePayloadRedactor;
+
+import java.util.Collection;
+import java.util.Objects;
+import java.util.TreeSet;
+import java.util.stream.Collectors;
+
+/**
+ * 统一支付日志脱敏工具(spec §7.5 / §11.1)。
+ *
+ * <p>解决两类日志问题:
+ * <ul>
+ *   <li>CWE-117 日志注入:上游通知的 {@code mchNo} / 参数键名来自网络,进日志前先 {@link #flatten};</li>
+ *   <li>验签失败日志可重放:只记收到的签名前 8 位与脱敏后的请求体,<b>永不记期望签名</b>。</li>
+ * </ul>
+ */
+public final class UnifiedLogSanitizer {
+
+    /** 单段文本进日志前的最大长度。 */
+    private static final int MAX_FLATTEN_LENGTH = 200;
+
+    /** 签名只保留前 8 位。 */
+    private static final int SIGN_PREFIX_LENGTH = 8;
+
+    private UnifiedLogSanitizer() {
+    }
+
+    /**
+     * 去掉 {@code \r}/{@code \n}/其他控制字符(含 U+2028/U+2029),折叠空白,截断 200。
+     * {@code null} 返回空串。
+     */
+    public static String flatten(String raw) {
+        if (raw == null) {
+            return "";
+        }
+        StringBuilder cleaned = new StringBuilder(raw.length());
+        for (int i = 0; i < raw.length(); i++) {
+            char c = raw.charAt(i);
+            boolean control = Character.isISOControl(c) || c == '
' || c == '
';
+            cleaned.append(control ? ' ' : c);
+        }
+        String collapsed = cleaned.toString().replaceAll("\\s+", " ").strip();
+        return collapsed.length() > MAX_FLATTEN_LENGTH
+                ? collapsed.substring(0, MAX_FLATTEN_LENGTH)
+                : collapsed;
+    }
+
+    /**
+     * 开放 API 验签失败日志:{@code apiKey=<前 6 位+***>, tenantId=..., receivedSign=<前 8 位>,
+     * requestData=<脱敏后的请求体>}。不含 apiSecret、不含期望签名。
+     */
+    public static String signatureFailure(String apiKey, Long tenantId,
+                                          String receivedSign, String requestBody) {
+        return "apiKey=" + SensitivePayloadRedactor.maskApiKey(apiKey)
+                + ", tenantId=" + tenantId
+                + ", receivedSign=" + signPrefix(receivedSign)
+                + ", requestData=" + SensitivePayloadRedactor.redact(requestBody);
+    }
+
+    /**
+     * 上游通知验签失败日志:{@code mchNo=<flatten>, receivedSign=<前 8 位>, keys=<排序后的键名>}。
+     * <b>不含任何参数值</b>,也不含期望签名。
+     */
+    public static String notifySignatureFailure(String mchNo, String receivedSign,
+                                                Collection<String> paramKeys) {
+        String keys = paramKeys == null ? "" : paramKeys.stream()
+                .filter(Objects::nonNull)
+                .map(UnifiedLogSanitizer::flatten)
+                .collect(Collectors.toCollection(TreeSet::new))
+                .stream()
+                .collect(Collectors.joining(","));
+        return "mchNo=" + flatten(mchNo)
+                + ", receivedSign=" + signPrefix(receivedSign)
+                + ", keys=" + keys;
+    }
+
+    /** 收到的签名只留前 8 位,并先 flatten(签名来自请求头,同样不可信)。 */
+    private static String signPrefix(String sign) {
+        String flat = flatten(sign);
+        return flat.length() > SIGN_PREFIX_LENGTH
+                ? flat.substring(0, SIGN_PREFIX_LENGTH)
+                : flat;
+    }
+}

+ 77 - 0
java/src/test/java/com/payment/platform/core/log/OperationLogInterceptorNotifyTest.java

@@ -0,0 +1,77 @@
+package com.payment.platform.core.log;
+
+import com.payment.platform.module.system.log.entity.OperationLogEntity;
+import com.payment.platform.module.system.log.mapper.OperationLogMapper;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.mock.web.MockHttpServletResponse;
+import org.springframework.web.util.ContentCachingRequestWrapper;
+
+import java.nio.charset.StandardCharsets;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+
+/**
+ * 操作日志的「通知跳过」与「敏感载荷脱敏」(spec §11.1 / Task 5 brief)。
+ *
+ * <p>上游通知入口 {@code /payment/notify/**} 一秒都不进 {@code sys_log};
+ * 其余写请求的请求体过 {@link SensitivePayloadRedactor}。
+ */
+class OperationLogInterceptorNotifyTest {
+
+    private final OperationLogMapper mapper = mock(OperationLogMapper.class);
+    private final OperationLogInterceptor interceptor = new OperationLogInterceptor(mapper);
+
+    @Test
+    void 上游通知_完全不入库() throws Exception {
+        MockHttpServletRequest request = new MockHttpServletRequest("POST",
+                "/api/v1/payment/notify/unified/order");
+        request.setContent("{\"mchNo\":\"M1\",\"channelExtra\":\"{\\\"auth_code\\\":\\\"123456\\\"}\"}"
+                .getBytes(StandardCharsets.UTF_8));
+
+        interceptor.afterCompletion(wrapper(request), new MockHttpServletResponse(), null, null);
+
+        // 同一构造下普通 POST 会插入(见下一个用例),所以这里是「通知被跳过」而非「拦截器整体失效」
+        verifyNoInteractions(mapper);
+    }
+
+    @Test
+    void 普通POST_入库且请求体已脱敏() throws Exception {
+        MockHttpServletRequest request = new MockHttpServletRequest("POST",
+                "/api/v1/payment/unified/merchant");
+        request.setContent("{\"mch_no\":\"M1\",\"app_secret\":\"S1\"}".getBytes(StandardCharsets.UTF_8));
+
+        interceptor.afterCompletion(wrapper(request), new MockHttpServletResponse(), null, null);
+
+        ArgumentCaptor<OperationLogEntity> captor = ArgumentCaptor.forClass(OperationLogEntity.class);
+        verify(mapper).insert(captor.capture());
+        String payload = captor.getValue().getRequestPayload();
+        assertNotNull(payload);
+        assertTrue(payload.contains("M1"), payload);
+        assertTrue(payload.contains("***"), payload);
+        assertFalse(payload.contains("S1"), payload);
+    }
+
+    @Test
+    void GET请求_不入库() throws Exception {
+        MockHttpServletRequest request = new MockHttpServletRequest("GET",
+                "/api/v1/payment/unified/merchant");
+
+        interceptor.afterCompletion(wrapper(request), new MockHttpServletResponse(), null, null);
+
+        verifyNoInteractions(mapper);
+    }
+
+    /** 让请求真实经过 wrapper 读取一次,body 才会进入 ContentCachingRequestWrapper 的缓存。 */
+    private static ContentCachingRequestWrapper wrapper(MockHttpServletRequest request) throws Exception {
+        ContentCachingRequestWrapper wrapper = new ContentCachingRequestWrapper(request);
+        wrapper.getInputStream().readAllBytes();
+        return wrapper;
+    }
+}

+ 107 - 0
java/src/test/java/com/payment/platform/core/log/SensitivePayloadRedactorTest.java

@@ -0,0 +1,107 @@
+package com.payment.platform.core.log;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * 敏感载荷脱敏器(spec §11.2 / Task 5 brief)。
+ *
+ * <p>键名集合大小写不敏感;JSON 递归;非 JSON 按 {@code key=value} 正则;
+ * 先截断 2000 字符再脱敏。
+ */
+class SensitivePayloadRedactorTest {
+
+    private static final ObjectMapper MAPPER = new ObjectMapper();
+
+    @Test
+    void 嵌套JSON_敏感值变星号其它字段原样() throws Exception {
+        String out = SensitivePayloadRedactor.redact(
+                "{\"app_secret\":\"S1\",\"outer\":{\"auth_code\":\"123456\",\"keep\":\"ok\"}}");
+
+        JsonNode node = MAPPER.readTree(out);
+        assertEquals("***", node.get("app_secret").asText());
+        assertEquals("***", node.get("outer").get("auth_code").asText());
+        assertEquals("ok", node.get("outer").get("keep").asText());
+        assertFalse(out.contains("S1"), out);
+        assertFalse(out.contains("123456"), out);
+    }
+
+    @Test
+    void JSON字符串里再嵌JSON_整个值变星号() throws Exception {
+        String out = SensitivePayloadRedactor.redact(
+                "{\"channel_extra\":\"{\\\"openid\\\":\\\"o1\\\"}\"}");
+
+        JsonNode node = MAPPER.readTree(out);
+        assertEquals("***", node.get("channel_extra").asText());
+        assertFalse(out.contains("o1"), out);
+    }
+
+    @Test
+    void 非JSON表单_敏感键值变星号其它键保留() {
+        String out = SensitivePayloadRedactor.redact("a=1&app_secret=S1&authCode=123456&b=2");
+
+        assertTrue(out.contains("app_secret=***"), out);
+        assertTrue(out.contains("authCode=***"), out);
+        assertTrue(out.contains("a=1"), out);
+        assertTrue(out.contains("b=2"), out);
+        assertFalse(out.contains("S1"), out);
+        assertFalse(out.contains("123456"), out);
+    }
+
+    @Test
+    void 键名大小写不敏感() throws Exception {
+        String out = SensitivePayloadRedactor.redact(
+                "{\"AppSecret\":\"S1\",\"AUTH_CODE\":\"123456\",\"apiKey\":\"K1\"}");
+
+        JsonNode node = MAPPER.readTree(out);
+        assertEquals("***", node.get("AppSecret").asText());
+        assertEquals("***", node.get("AUTH_CODE").asText());
+        assertEquals("***", node.get("apiKey").asText());
+    }
+
+    @Test
+    void 表单形式的大小写不敏感() {
+        String out = SensitivePayloadRedactor.redact("API_KEY=K1&ChannelExtra=E1");
+
+        assertTrue(out.contains("API_KEY=***"), out);
+        assertTrue(out.contains("ChannelExtra=***"), out);
+        assertFalse(out.contains("K1"), out);
+        assertFalse(out.contains("E1"), out);
+    }
+
+    @Test
+    void 三千字符输入_输出不超过两千() {
+        String out = SensitivePayloadRedactor.redact("x".repeat(3000));
+
+        assertTrue(out.length() <= 2000, "实际长度 " + out.length());
+    }
+
+    @Test
+    void 非敏感字段原样保留() {
+        assertTrue(SensitivePayloadRedactor.redact("subject=测试").contains("subject=测试"));
+        assertTrue(SensitivePayloadRedactor.redact("{\"subject\":\"测试\"}").contains("测试"));
+    }
+
+    @Test
+    void 敏感键的整个子树都变星号() throws Exception {
+        String out = SensitivePayloadRedactor.redact(
+                "{\"channelExtra\":{\"auth_code\":\"123456\",\"openid\":\"o1\"},\"keep\":1}");
+
+        JsonNode node = MAPPER.readTree(out);
+        assertEquals("***", node.get("channelExtra").asText());
+        assertEquals(1, node.get("keep").asInt());
+    }
+
+    @Test
+    void maskApiKey_保留前六位其余星号() {
+        assertEquals("abcdef***", SensitivePayloadRedactor.maskApiKey("abcdef123456"));
+        assertEquals("abcdef***", SensitivePayloadRedactor.maskApiKey("abcdef"));
+        assertEquals("abc***", SensitivePayloadRedactor.maskApiKey("abc"));
+        assertEquals("null", SensitivePayloadRedactor.maskApiKey(null));
+    }
+}

+ 90 - 0
java/src/test/java/com/payment/platform/module/payment/unified/client/UnifiedClientLogCaptureTest.java

@@ -0,0 +1,90 @@
+package com.payment.platform.module.payment.unified.client;
+
+import ch.qos.logback.classic.Logger;
+import ch.qos.logback.classic.spi.ILoggingEvent;
+import ch.qos.logback.core.read.ListAppender;
+import com.payment.platform.module.payment.unified.config.UnifiedPayProperties;
+import com.payment.platform.module.payment.unified.entity.UnifiedMerchantEntity;
+import org.junit.jupiter.api.Test;
+import org.slf4j.LoggerFactory;
+
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * 上游客户端日志捕获测试(spec §6.2 / R16)。
+ *
+ * <p>请求体里带付款码与 openid,上游返回业务错误;断言错误日志保留单号便于排障,
+ * 但不含付款码、openid、appSecret。
+ */
+class UnifiedClientLogCaptureTest {
+
+    private static final String APP_SECRET = "SECRET-XYZ-123";
+    private static final String PAYMENT_CODE = "281234567890123456";
+    private static final String OPENID = "o-openid-capture-1";
+    private static final String MCH_ORDER_NO = "ORDER-CAP-1";
+
+    @Test
+    void 下单失败日志_含单号但不含付款码openid与密钥() {
+        Logger logger = (Logger) LoggerFactory.getLogger(UnifiedClient.class);
+        ListAppender<ILoggingEvent> appender = new ListAppender<>();
+        appender.start();
+        logger.addAppender(appender);
+        try {
+            UnifiedClient client = new UnifiedClient(properties(), transport());
+            UnifiedOrderPayload payload = new UnifiedOrderPayload(
+                    MCH_ORDER_NO, 100L, "测试订单", null, "AUTH_CODE",
+                    "{\"auth_code\":\"" + PAYMENT_CODE + "\",\"openid\":\"" + OPENID + "\"}",
+                    null, null, null, "127.0.0.1", null);
+
+            assertThrows(UnifiedApiException.class, () -> client.unifiedOrder(merchant(), payload));
+
+            List<String> logs = appender.list.stream()
+                    .map(ILoggingEvent::getFormattedMessage)
+                    .toList();
+
+            // 正控:必须先断言「确实捕获到了事件」,否则下面的否定断言会在空列表上恒真。
+            assertFalse(logs.isEmpty(),
+                    "没有捕获到任何日志 —— appender 很可能挂错了 logger,本测试已失效");
+            String text = String.join("\n", logs);
+            assertTrue(text.contains(MCH_ORDER_NO), "错误日志应保留单号: " + text);
+            assertFalse(text.contains(PAYMENT_CODE), "日志泄露了付款码: " + text);
+            assertFalse(text.contains(OPENID), "日志泄露了 openid: " + text);
+            assertFalse(text.contains(APP_SECRET), "日志泄露了 appSecret: " + text);
+        } finally {
+            logger.detachAppender(appender);
+        }
+    }
+
+    private static UnifiedPayProperties properties() {
+        UnifiedPayProperties props = new UnifiedPayProperties();
+        props.setBaseUrl("https://gw.example.test/gateway");
+        return props;
+    }
+
+    private static UnifiedMerchantEntity merchant() {
+        UnifiedMerchantEntity cfg = new UnifiedMerchantEntity();
+        cfg.setMchNo("M1000");
+        cfg.setAppId("A1");
+        cfg.setAppSecret(APP_SECRET);
+        return cfg;
+    }
+
+    /** 桩传输:不发网络请求,固定返回业务失败报文。 */
+    private static UnifiedTransport transport() {
+        return new UnifiedTransport() {
+            @Override
+            public String post(String url, String jsonBody) {
+                return "{\"code\":5,\"msg\":\"bad\"}";
+            }
+
+            @Override
+            public String get(String url) {
+                throw new UnsupportedOperationException("本测试不调用 GET");
+            }
+        };
+    }
+}

+ 80 - 0
java/src/test/java/com/payment/platform/module/payment/unified/util/UnifiedLogSanitizerTest.java

@@ -0,0 +1,80 @@
+package com.payment.platform.module.payment.unified.util;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * 统一支付日志脱敏工具(spec §7.5 / §11.1 / Task 5 brief)。
+ *
+ * <p>换行注入(CWE-117)必须被 flatten 抚平;验签失败日志只留 apiKey 前缀、
+ * 收到的签名前 8 位与脱敏后的请求体 —— 永不打印期望签名。
+ */
+class UnifiedLogSanitizerTest {
+
+    @Test
+    void flatten_去掉换行控制字符并折叠空白() {
+        String out = UnifiedLogSanitizer.flatten("M1\r\nFAKE\u0000\tline   two");
+
+        assertFalse(out.contains("\r"), out);
+        assertFalse(out.contains("\n"), out);
+        assertFalse(out.contains("\u0000"), out);
+        assertEquals("M1 FAKE line two", out);
+    }
+
+    @Test
+    void flatten_截断两百字符() {
+        assertEquals(200, UnifiedLogSanitizer.flatten("x".repeat(300)).length());
+    }
+
+    @Test
+    void signatureFailure_不含完整apiKey与请求体密钥() {
+        String out = UnifiedLogSanitizer.signatureFailure(
+                "abcdef123456", 2L, "0123456789abcdef",
+                "{\"app_secret\":\"S1\",\"amount\":10}");
+
+        assertFalse(out.contains("abcdef123456"), out);
+        assertTrue(out.contains("apiKey=abcdef***"), out);
+        assertTrue(out.contains("tenantId=2"), out);
+        assertTrue(out.contains("receivedSign=01234567"), out);
+        assertFalse(out.contains("0123456789"), out);
+        assertFalse(out.contains("S1"), out);
+        assertTrue(out.contains("requestData="), out);
+        assertTrue(out.contains("amount"), out);
+    }
+
+    @Test
+    void signatureFailure_短apiKey也带星号且空签名安全() {
+        String out = UnifiedLogSanitizer.signatureFailure("abc", 1L, null, null);
+
+        assertTrue(out.contains("apiKey=abc***"), out);
+        assertTrue(out.contains("tenantId=1"), out);
+        assertTrue(out.contains("receivedSign=,"), out);
+    }
+
+    @Test
+    void notifySignatureFailure_只含商户号八位签名与排序后的键名() {
+        String out = UnifiedLogSanitizer.notifySignatureFailure(
+                "M1\r\nFAKE", "0123456789abcdef",
+                List.of("sign", "amount", "mchNo", "amount"));
+
+        assertFalse(out.contains("\r"), out);
+        assertFalse(out.contains("\n"), out);
+        assertTrue(out.contains("mchNo=M1 FAKE"), out);
+        assertTrue(out.contains("receivedSign=01234567"), out);
+        assertFalse(out.contains("0123456789"), out);
+        assertTrue(out.contains("keys=amount,mchNo,sign"), out);
+    }
+
+    @Test
+    void notifySignatureFailure_键集合为空或null时仍可用() {
+        assertTrue(UnifiedLogSanitizer.notifySignatureFailure("M1", "abcdefghZ", null)
+                .contains("keys="));
+        assertTrue(UnifiedLogSanitizer.notifySignatureFailure("M1", "abcdefghZ", List.of())
+                .contains("keys="));
+    }
+}

+ 45 - 0
java/src/test/java/com/payment/platform/module/payment/unified/util/UnifiedLogSecurityWiringTest.java

@@ -0,0 +1,45 @@
+package com.payment.platform.module.payment.unified.util;
+
+import org.junit.jupiter.api.Test;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * 校验 {@code TenantApiKeyAuthFilter} 的验签失败日志已经接线到脱敏器(spec §11.3 #5)。
+ *
+ * <p>Surefire 的工作目录是模块根 {@code java/},故用相对路径读源码。
+ */
+class UnifiedLogSecurityWiringTest {
+
+    private static final Path FILTER_SOURCE = Path.of(
+            "src/main/java/com/payment/platform/core/security/TenantApiKeyAuthFilter.java");
+
+    @Test
+    void 验签失败日志_不打印期望签名且走脱敏器() throws IOException {
+        String source = Files.readString(FILTER_SOURCE, StandardCharsets.UTF_8);
+
+        // 正控:确认读到的是那个文件、且验签调用还在
+        assertFalse(source.isBlank(), "源文件为空 —— 路径错误,本测试已失去意义");
+        assertTrue(source.contains("SignatureGenerator.verifySignature("),
+                "源文件里找不到验签调用 —— 很可能读错了文件");
+
+        assertFalse(source.contains("期望签名"),
+                "验签失败日志不得打印期望签名(日志可读者能直接重放)");
+
+        int start = source.indexOf("if (!SignatureGenerator.verifySignature(");
+        int end = source.indexOf("\"Invalid Signature / 签名验证失败\"");
+        assertTrue(start >= 0 && end > start, "定位不到验签失败日志区段");
+
+        String failureLogRegion = source.substring(start, end);
+        assertFalse(failureLogRegion.contains("generateSignature("),
+                "验签失败日志区段不得调用 generateSignature 打印期望值");
+        assertTrue(failureLogRegion.contains("UnifiedLogSanitizer.signatureFailure("),
+                "验签失败日志必须走 UnifiedLogSanitizer.signatureFailure");
+    }
+}