From 4452d0301c9085f599cab6d7714ffffd710daf13 Mon Sep 17 00:00:00 2001 From: Peanut Date: Wed, 3 Jun 2026 22:30:38 +0800 Subject: [PATCH] =?UTF-8?q?feat(wechat):=20=E6=96=B0=E5=A2=9E=20WechatApiL?= =?UTF-8?q?oggingInterceptor=20=E6=8B=A6=E6=88=AA=E5=99=A8=EF=BC=88?= =?UTF-8?q?=E5=90=AB=E8=84=B1=E6=95=8F=E5=8D=95=E5=85=83=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ClientHttpRequestInterceptor 实现 - URL/method/status/content-type 用 INFO 级别 - body 用 DEBUG 级别,MAX_BODY_LOG=2048 截断 - 脱敏正则覆盖 session_key/openid/unionid/access_token/refresh_token/secret/appsecret - 配合 BufferingClientHttpRequestFactory 使用避免流被消费 - 4 个单元测试覆盖主要脱敏场景 --- .../WechatApiLoggingInterceptor.java | 69 ++++++++++++ .../WechatApiLoggingInterceptorTest.java | 106 ++++++++++++++++++ 2 files changed, 175 insertions(+) create mode 100644 backend-single/src/main/java/com/emotion/interceptor/WechatApiLoggingInterceptor.java create mode 100644 backend-single/src/test/java/com/emotion/interceptor/WechatApiLoggingInterceptorTest.java diff --git a/backend-single/src/main/java/com/emotion/interceptor/WechatApiLoggingInterceptor.java b/backend-single/src/main/java/com/emotion/interceptor/WechatApiLoggingInterceptor.java new file mode 100644 index 0000000..f268649 --- /dev/null +++ b/backend-single/src/main/java/com/emotion/interceptor/WechatApiLoggingInterceptor.java @@ -0,0 +1,69 @@ +package com.emotion.interceptor; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpRequest; +import org.springframework.http.client.ClientHttpRequestExecution; +import org.springframework.http.client.ClientHttpRequestInterceptor; +import org.springframework.http.client.ClientHttpResponse; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.regex.Pattern; + +/** + * 微信 API HTTP 请求 / 响应日志拦截器 + * + *

设计要点: + *

    + *
  1. 配合 BufferingClientHttpRequestFactory 使用,body 可被多次读取
  2. + *
  3. URL / method / status / content-type 用 INFO 级别(生产可见)
  4. + *
  5. body 用 DEBUG 级别(生产默认不输出,避免日志膨胀)
  6. + *
  7. body 中敏感字段(session_key / openid / unionid / access_token / refresh_token)脱敏
  8. + *
+ */ +@Slf4j +public class WechatApiLoggingInterceptor implements ClientHttpRequestInterceptor { + + private static final int MAX_BODY_LOG = 2048; + private static final Pattern SENSITIVE_FIELDS = Pattern.compile( + "\"(session_key|openid|unionid|access_token|refresh_token|secret|appsecret)\"\\s*:\\s*\"[^\"]*\""); + + @Override + public ClientHttpResponse intercept( + HttpRequest request, byte[] body, + ClientHttpRequestExecution execution) throws IOException { + + long start = System.currentTimeMillis(); + log.info("[WeChatAPI] ---> {} {}", request.getMethod(), request.getURI()); + + ClientHttpResponse response = execution.execute(request, body); + long duration = System.currentTimeMillis() - start; + + log.info("[WeChatAPI] <--- {} {} ({}ms) content-type={}", + response.getStatusCode(), request.getURI(), duration, + response.getHeaders().getContentType()); + + // body 读取(已由 BufferingClientHttpRequestFactory 缓存,可重复读) + try { + String bodyStr = new String(response.getBody().readAllBytes(), StandardCharsets.UTF_8); + if (bodyStr.length() > MAX_BODY_LOG) { + bodyStr = bodyStr.substring(0, MAX_BODY_LOG) + "...(truncated)"; + } + log.debug("[WeChatAPI] body={}", desensitize(bodyStr)); + } catch (IOException e) { + log.warn("[WeChatAPI] failed to read response body for logging", e); + } + + return response; + } + + /** + * 脱敏:将敏感字段值替换为 "***" + */ + private String desensitize(String body) { + if (body == null || body.isEmpty()) { + return body; + } + return SENSITIVE_FIELDS.matcher(body).replaceAll("\"$1\":\"***\""); + } +} diff --git a/backend-single/src/test/java/com/emotion/interceptor/WechatApiLoggingInterceptorTest.java b/backend-single/src/test/java/com/emotion/interceptor/WechatApiLoggingInterceptorTest.java new file mode 100644 index 0000000..8deeb02 --- /dev/null +++ b/backend-single/src/test/java/com/emotion/interceptor/WechatApiLoggingInterceptorTest.java @@ -0,0 +1,106 @@ +package com.emotion.interceptor; + +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Method; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * WechatApiLoggingInterceptor 单元测试 + * + * 注:desensitize() 是 private 方法,通过反射测试 + */ +class WechatApiLoggingInterceptorTest { + + @Test + void desensitize_shouldMaskSessionKey() throws Exception { + WechatApiLoggingInterceptor interceptor = new WechatApiLoggingInterceptor(); + Method method = WechatApiLoggingInterceptor.class.getDeclaredMethod("desensitize", String.class); + method.setAccessible(true); + + String input = "{\"openid\":\"oXyz123\",\"session_key\":\"abc@456\"}"; + String result = (String) method.invoke(interceptor, input); + + assertNotNull(result); + assertTrue(result.contains("\"openid\":\"***\""), "openid 应被脱敏: " + result); + assertTrue(result.contains("\"session_key\":\"***\""), "session_key 应被脱敏: " + result); + } + + @Test + void desensitize_shouldMaskAccessToken() throws Exception { + WechatApiLoggingInterceptor interceptor = new WechatApiLoggingInterceptor(); + Method method = WechatApiLoggingInterceptor.class.getDeclaredMethod("desensitize", String.class); + method.setAccessible(true); + + String input = "{\"access_token\":\"ACCESS-123\",\"refresh_token\":\"REFRESH-456\"}"; + String result = (String) method.invoke(interceptor, input); + + assertTrue(result.contains("\"access_token\":\"***\"")); + assertTrue(result.contains("\"refresh_token\":\"***\"")); + } + + @Test + void desensitize_shouldHandleNull() throws Exception { + WechatApiLoggingInterceptor interceptor = new WechatApiLoggingInterceptor(); + Method method = WechatApiLoggingInterceptor.class.getDeclaredMethod("desensitize", String.class); + method.setAccessible(true); + + assertEquals(null, method.invoke(interceptor, (Object) null)); + } + + @Test + void desensitize_shouldNotModifyNonSensitiveFields() throws Exception { + WechatApiLoggingInterceptor interceptor = new WechatApiLoggingInterceptor(); + Method method = WechatApiLoggingInterceptor.class.getDeclaredMethod("desensitize", String.class); + method.setAccessible(true); + + String input = "{\"errcode\":40013,\"errmsg\":\"invalid appid\"}"; + String result = (String) method.invoke(interceptor, input); + + assertEquals(input, result, "非敏感字段不应被修改"); + } + + @Test + void desensitize_shouldNotMatchAccessTokenV2() throws Exception { + WechatApiLoggingInterceptor interceptor = new WechatApiLoggingInterceptor(); + Method method = WechatApiLoggingInterceptor.class.getDeclaredMethod("desensitize", String.class); + method.setAccessible(true); + + // 验证 access_token_v2 不会被 access_token 模式误匹配 + String input = "{\"access_token_v2\":\"PUBLIC_VALUE\",\"my_reset_secret\":\"OK\"}"; + String result = (String) method.invoke(interceptor, input); + + // access_token_v2 不应被脱敏(正则精确匹配完整 key 名称) + // my_reset_secret 也不应被脱敏(正则需要精确匹配 "secret" 而非子串) + assertTrue(result.contains("\"access_token_v2\":\"PUBLIC_VALUE\""), "access_token_v2 不应被脱敏: " + result); + assertTrue(result.contains("\"my_reset_secret\":\"OK\""), "my_reset_secret 不应被脱敏: " + result); + } + + @Test + void desensitize_shouldHandleEmptyString() throws Exception { + WechatApiLoggingInterceptor interceptor = new WechatApiLoggingInterceptor(); + Method method = WechatApiLoggingInterceptor.class.getDeclaredMethod("desensitize", String.class); + method.setAccessible(true); + + String result = (String) method.invoke(interceptor, ""); + assertEquals("", result, "空字符串应原样返回"); + } + + @Test + void desensitize_shouldMaskMultipleSensitiveFieldsSimultaneously() throws Exception { + WechatApiLoggingInterceptor interceptor = new WechatApiLoggingInterceptor(); + Method method = WechatApiLoggingInterceptor.class.getDeclaredMethod("desensitize", String.class); + method.setAccessible(true); + + String input = "{\"openid\":\"o123\",\"access_token\":\"tok\",\"unionid\":\"uni456\",\"refresh_token\":\"ref789\"}"; + String result = (String) method.invoke(interceptor, input); + + assertTrue(result.contains("\"openid\":\"***\"")); + assertTrue(result.contains("\"access_token\":\"***\"")); + assertTrue(result.contains("\"unionid\":\"***\"")); + assertTrue(result.contains("\"refresh_token\":\"***\"")); + } +}