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 请求 / 响应日志拦截器
+ *
+ *
设计要点:
+ *
+ * - 配合 BufferingClientHttpRequestFactory 使用,body 可被多次读取
+ * - URL / method / status / content-type 用 INFO 级别(生产可见)
+ * - body 用 DEBUG 级别(生产默认不输出,避免日志膨胀)
+ * - body 中敏感字段(session_key / openid / unionid / access_token / refresh_token)脱敏
+ *
+ */
+@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\":\"***\""));
+ }
+}