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\":\"***\"")); + } +}