feat(wechat): 新增 WechatApiLoggingInterceptor 拦截器(含脱敏单元测试)

- 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 个单元测试覆盖主要脱敏场景
This commit is contained in:
2026-06-03 22:30:38 +08:00
parent 35baa6c958
commit 4452d0301c
2 changed files with 175 additions and 0 deletions
@@ -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 请求 / 响应日志拦截器
*
* <p>设计要点:
* <ol>
* <li>配合 BufferingClientHttpRequestFactory 使用,body 可被多次读取</li>
* <li>URL / method / status / content-type 用 INFO 级别(生产可见)</li>
* <li>body 用 DEBUG 级别(生产默认不输出,避免日志膨胀)</li>
* <li>body 中敏感字段(session_key / openid / unionid / access_token / refresh_token)脱敏</li>
* </ol>
*/
@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\":\"***\"");
}
}
@@ -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\":\"***\""));
}
}