27 KiB
author, created_at, purpose
| author | created_at | purpose |
|---|---|---|
| 微信登录修复会话 | 2026-06-03 | 修复微信小程序登录 text/plain 错误(Spring RestTemplate 默认 User-Agent 触发 WAF 拦截 + 凭据硬编码安全问题) |
修复微信小程序登录 text/plain 错误 — 设计文档
问题陈述
微信小程序用户在小程序里点击「微信授权登录」时,后端 /api/auth/wechat/login 接口返回 500 错误:
{
"code": 500,
"message": "系统运行异常: Could not extract response: no suitable HttpMessageConverter found for response type [class com.emotion.dto.wechat.WechatCodeSessionResponse] and content type [text/plain]",
"timestamp": 1780414444577
}
业务影响:
- 真实用户(IP 106.39.148.160)2026-06-02 23:34:04 尝试登录失败
- 21:22、22:11、23:06 还出现 3 次
WARN 业务异常: 微信小程序登录未配置(早期因 env var 未设被validateConfigured()拦截) - 微信登录完全不可用
根因分析
证据链
| # | 证据 | 结论 |
|---|---|---|
| 1 | 错误堆栈:WechatMiniProgramServiceImpl.java:33 调用 restTemplate.getForObject(url, WechatCodeSessionResponse.class) |
✓ 错误在微信 code2Session 调用 |
| 2 | application.yml:78-79 微信配置硬编码测试凭据(app-secret: 4f087bd363a4147ef543d634f9f6950d)作为默认值 |
⚠️ 安全风险(生产未设环境变量时会用测试 secret) |
| 3 | application-prod.yml 不覆盖微信配置 |
⚠️ 生产用 application.yml 默认值(测试 secret) |
| 4 | RestTemplateConfig.java:10-12 仅 new RestTemplate(),13 行 |
⚠️ 无 User-Agent / Accept 头 |
| 5 | Spring 默认 RestTemplate → SimpleClientHttpRequestFactory → HttpURLConnection,默认 User-Agent: Java/17.0.x |
❌ 触发了 WAF / CloudFlare / 边缘代理拦截 |
| 6 | SSH+curl 测试(带 User-Agent: curl/7.61.1)→ api.weixin.qq.com 返回 Content-Type: application/json; encoding=utf-8 |
✓ 微信 API 本身正常 |
| 7 | 日志中 23:34:04 之前的多次 Wechat mini program login is not configured |
23:34 env var 设上了,进入了真实 API 调用 |
| 8 | 已有 docs/superpowers/specs/2026-05-31-mini-program-wechat-auth-design.md(265 行完整设计) |
设计已存在,但未覆盖此 bug |
根因结论
主要根因(用户已确认):生产环境的 WAF / CloudFlare / 边缘代理拦截了 Java 默认 User-Agent: Java/17.0.x,返回 text/plain 错误页面(这是 WAF 常见行为)。SSH+curl 用 User-Agent: curl/7.61.1 所以能正常通过。
次要根因(必须一并修复):
- app-id 和 app-secret 硬编码在源码(
wxaf2eaba72d28f0e4/4f087bd363a4147ef543d634f9f6950d)—— 安全问题,必须移除默认值,强制从环境变量读取 - 错误日志不打印响应体 —— 看不到 text/plain 实际内容,下次出问题还是黑盒
- 无超时设置 —— 微信 API hang 住时整个请求线程被锁
- RestTemplate 无日志拦截器 —— 出问题时无任何请求/响应可查
- 缺全局异常处理 ——
WechatApiException无 handler,会落到 RuntimeException handler 暴露内部消息 - 日志明文打印 openid / session_key —— 违反项目规则第 37 条"敏感信息不得在日志中输出"
已排除假设
- ❌ 微信 API endpoint URL 配错:SSH+curl 直连
https://api.weixin.qq.com/sns/jscode2session返回正常 JSON - ❌ 部署代码 ≠ 本地代码:日志中
WechatMiniProgramServiceImpl.java:33与本地代码行号完全一致 - ❌ 网络层完全不通:SSH+curl TLS 握手成功
- ❌
getPhoneNumber/getStableAccessToken也有 bug:当前WechatMiniProgramServiceImpl只实现了code2Session(49 行),其他方法在 2026-05-31 设计文档中规划但尚未实施
目标
- G1 修复微信小程序登录
text/plain错误,使用户能成功通过微信授权登录 - G2 在 RestTemplate 中显式设置
User-Agent,绕过 WAF 对Java/*默认 UA 的拦截 - G3 错误日志打印原始响应体(raw body)+ 脱敏(
openid/session_key/unionid/access_token/refresh_token),下次类似问题能直接看到内容但不泄露敏感信息 - G4 移除
application.yml中微信app-id和app-secret的硬编码默认值,强制从环境变量读取 - G5 RestTemplate 统一配置:超时、User-Agent、Accept 头、StringHttpMessageConverter、LoggingInterceptor、ErrorHandler
- G6
WechatApiException在GlobalExceptionHandler中有专门处理,返回脱敏的友好提示给前端 - G7 不破坏其他 7 个 RestTemplate 使用点(用
BufferingClientHttpRequestFactory让 body 可被多次读取)
非目标
- 不改动现有 7 个 RestTemplate 使用点的业务代码(仅共享统一 bean)
- 不做 RestTemplate 连接池(沿用 SimpleClientHttpRequestFactory,外面包一层 BufferingClientHttpRequestFactory)
- 不重试 / 不熔断(业务层不需要)
- 不做 phone number binding / merge logic(已有 plan 覆盖,且当前代码尚未实施)
- 不改 mini-program 前端(纯后端 bug)
方案选择
经过 brainstorming 对话(澄清问题 → SSH+curl 探针 → 查 logs\server\emotion-single.log),用户选择 方案 A:防御性增强 + 错误捕获(整合根因)。
未选方案:
- 方案 B(仅加 message converter):不解决根因(WAF 仍会拦截)
- 方案 C(先用探针定位再设计):已通过日志 + SSH 探针定位根因,无需再加探针
设计
修改清单
| # | 文件 | 操作 | 行数估算 | 目的 |
|---|---|---|---|---|
| 1 | server/src/main/java/com/emotion/config/RestTemplateConfig.java |
重写 | 40 行 | 改用 RestTemplateBuilder + BufferingClientHttpRequestFactory:设置 User-Agent、超时、加 StringHttpMessageConverter、加 LoggingInterceptor、加 ErrorHandler |
| 2 | server/src/main/java/com/emotion/service/impl/WechatMiniProgramServiceImpl.java |
重写 | 90 行 | code2Session 改用 ResponseEntity<String> + Jackson 二次解析;捕获所有失败场景的 raw body |
| 3 | server/src/main/resources/application.yml |
修改 | 2 行 | 删除 app-id 和 app-secret 硬编码默认值(${ENV_VAR:} 空兜底) |
| 4 | server/src/main/java/com/emotion/exception/WechatApiException.java |
新增 | 30 行 | 业务异常类(code 用 HTTP 风格状态码 + rawBody) |
| 5 | server/src/main/java/com/emotion/interceptor/WechatApiLoggingInterceptor.java |
新增 | 60 行 | ClientHttpRequestInterceptor:记录 method/URL/status(INFO)+ 脱敏 body(DEBUG) |
| 6 | server/src/main/java/com/emotion/config/WechatResponseErrorHandler.java |
新增 | 30 行 | 4xx/5xx 不抛 RestClientException,重写 hasError() 显式声明 |
| 7 | server/src/main/java/com/emotion/exception/GlobalExceptionHandler.java |
修改 | 25 行 | 新增 @ExceptionHandler(WechatApiException.class) 返回脱敏的友好提示 |
| 8 | server/src/main/java/com/emotion/dto/wechat/WechatCodeSessionResponse.java |
不动 | 0 行 | 保持纯 DTO(rawBody 不放在 DTO,只在异常对象和日志中) |
总计:2 重写 + 3 新增 + 3 修改,约 280 行 Java + 2 行 YAML。
详细设计
1. RestTemplateConfig 重写(解决 P0-1 / P0-2 流消费问题)
关键决策:用 BufferingClientHttpRequestFactory 包装 SimpleClientHttpRequestFactory,让响应体被缓存到字节数组,可以被多次读取(Interceptor + ErrorHandler + Converter 都能读)。
package com.emotion.config;
import com.emotion.interceptor.WechatApiLoggingInterceptor;
import org.springframework.boot.web.client.RestTemplateBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.client.BufferingClientHttpRequestFactory;
import org.springframework.http.client.SimpleClientHttpRequestFactory;
import org.springframework.http.converter.StringHttpMessageConverter;
import org.springframework.web.client.RestTemplate;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
@Configuration
public class RestTemplateConfig {
/**
* 统一 RestTemplate Bean
*
* 设计要点:
* 1. 用 BufferingClientHttpRequestFactory 包装 SimpleClientHttpRequestFactory,
* 响应体被缓存到字节数组,可被 Interceptor / ErrorHandler / Converter 多次读取
* (避免破坏其他 7 个 RestTemplate 使用点:DifyProviderAdapter、
* CozeProviderAdapter、HttpTtsEngineClient、AsrServiceImpl、
* ApiEndpointServiceImpl、AiChatServiceImpl、ApiTestProxyController)
* 2. 显式设置 User-Agent 为 Mozilla/5.0,绕过 WAF / CloudFlare 对 Java/* 默认 UA 的拦截
* 3. 显式设置连接 / 读取超时,避免线程被 hang 住
* 4. 加 StringHttpMessageConverter(UTF_8) 支持 text/plain 响应
* 5. 加 WechatApiLoggingInterceptor 记录请求 / 响应(脱敏)
* 6. 加 WechatResponseErrorHandler 4xx/5xx 不抛异常
*/
@Bean
public RestTemplate restTemplate(RestTemplateBuilder builder) {
SimpleClientHttpRequestFactory simpleFactory = new SimpleClientHttpRequestFactory();
simpleFactory.setConnectTimeout((int) Duration.ofSeconds(5).toMillis());
simpleFactory.setReadTimeout((int) Duration.ofSeconds(10).toMillis());
BufferingClientHttpRequestFactory bufferingFactory =
new BufferingClientHttpRequestFactory(simpleFactory);
return builder
.requestFactory(() -> bufferingFactory)
.defaultHeader("User-Agent", "Mozilla/5.0 (compatible; EmotionMuseum/1.0)")
.defaultHeader("Accept", "application/json, text/plain, */*")
.additionalMessageConverters(new StringHttpMessageConverter(StandardCharsets.UTF_8))
.additionalInterceptors(new WechatApiLoggingInterceptor())
.errorHandler(new WechatResponseErrorHandler())
.build();
}
}
2. WechatApiLoggingInterceptor 新增(解决 P0-3 敏感信息泄露)
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. URL / method / status / content-type 用 INFO 级别(生产可见)
* 3. body 用 DEBUG 级别(生产默认不输出,避免日志膨胀)
* 4. 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\":\"***\"");
}
}
3. WechatResponseErrorHandler 新增(解决 P0-2 显式 hasError)
package com.emotion.config;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.client.ClientHttpResponse;
import org.springframework.web.client.DefaultResponseErrorHandler;
import java.io.IOException;
/**
* 微信 API 错误处理器
*
* 设计要点:
* 1. 显式重写 hasError() 委托父类判断(4xx/5xx → true),让 handleError() 在错误时被调用
* 2. handleError() 不抛 RestClientException,由业务层(WechatMiniProgramServiceImpl)
* 根据 status code 判断如何处理
* 3. 不在此处读 body(避免流消费,由 LoggingInterceptor 统一处理日志)
*/
@Slf4j
public class WechatResponseErrorHandler extends DefaultResponseErrorHandler {
@Override
public boolean hasError(ClientHttpResponse response) throws IOException {
// 委托父类 DefaultResponseErrorHandler.hasError():4xx/5xx 返回 true,其他返回 false
return super.hasError(response);
}
@Override
public void handleError(ClientHttpResponse response) throws IOException {
// 不抛异常;body 已在 LoggingInterceptor 中读取并脱敏记录
// 业务层会通过 response.getStatusCode() 判断
log.warn("[WeChatAPI] HTTP error response, status={}", response.getStatusCode());
}
}
4. WechatApiException 新增(解决 P1-3 错误码风格)
package com.emotion.exception;
import lombok.Getter;
/**
* 微信 API 业务异常
*
* code 字段使用 HTTP 风格状态码:
* - 400: 微信 API 业务错误(errcode != 0)或 4xx 响应
* - 500: 内部错误(JSON 解析失败)
* - 502: 上游服务错误(5xx 响应 / network / non-JSON content-type)
*/
@Getter
public class WechatApiException extends RuntimeException {
private final Integer code; // HTTP 风格状态码
private final String rawBody; // 原始响应体(仅服务端排查用,绝不返回给前端)
public WechatApiException(Integer code, String message, String rawBody) {
super(message);
this.code = code;
this.rawBody = rawBody;
}
}
5. WechatMiniProgramServiceImpl.code2Session 重写
@Override
public WechatCodeSessionResponse code2Session(String code) {
validateConfigured();
String url = buildCode2SessionUrl(code);
ResponseEntity<String> response;
try {
// 第一步:拿原始 String 响应(不直接反序列化为 DTO)
response = restTemplate.exchange(url, HttpMethod.GET, null, String.class);
} catch (RestClientException e) {
// 网络 / DNS / SSL 错误 → 502
log.error("[WeChatAPI] code2Session 网络错误", e);
throw new WechatApiException(502, "WeChat network error: " + e.getMessage(), null);
}
String rawBody = response.getBody();
HttpStatusCode status = response.getStatusCode();
MediaType contentType = response.getHeaders().getContentType();
// 第二步:HTTP 状态码检查
if (status == null || !status.is2xxSuccessful()) {
log.error("[WeChatAPI] code2Session HTTP 错误: status={}, contentType={}, body={}",
status, contentType, rawBody);
int code = (status != null && status.is4xxClientError()) ? 400 : 502;
throw new WechatApiException(code, "WeChat HTTP " + status, rawBody);
}
// 第三步:content-type 防御
// 注意:Spring MediaType 没有 includesCompatibleWith 方法(编译错误),用 isCompatibleWith 替代
if (contentType == null || !MediaType.APPLICATION_JSON.isCompatibleWith(contentType)) {
log.error("[WeChatAPI] code2Session 非 JSON 响应: contentType={}, body={}", contentType, rawBody);
throw new WechatApiException(502,
"WeChat returned " + contentType + " (likely WAF interception)", rawBody);
}
// 第四步:JSON 解析
WechatCodeSessionResponse dto;
try {
dto = objectMapper.readValue(rawBody, WechatCodeSessionResponse.class);
} catch (JsonProcessingException e) {
log.error("[WeChatAPI] code2Session JSON 解析失败. body={}", rawBody, e);
throw new WechatApiException(500, "Invalid JSON from WeChat", rawBody);
}
// 第五步:业务错误码检查
if (!dto.isSuccess()) {
log.warn("[WeChatAPI] code2Session 业务错误: errcode={}, errmsg={}, body={}",
dto.getErrcode(), dto.getErrmsg(), rawBody);
throw new WechatApiException(400,
dto.getErrmsg() != null ? dto.getErrmsg() : "WeChat business error",
rawBody);
}
return dto;
}
6. GlobalExceptionHandler 新增 WechatApiException handler(解决 P0-4)
在 GlobalExceptionHandler.java 中新增:
// 复用 Interceptor 的脱敏正则(避免重复定义)
private static final java.util.regex.Pattern SENSITIVE_FIELDS_FOR_LOG = java.util.regex.Pattern.compile(
"\"(session_key|openid|unionid|access_token|refresh_token|secret|appsecret|code)\"\\s*:\\s*\"[^\"]*\"");
private String desensitizeForLog(String body) {
if (body == null || body.isEmpty()) {
return body;
}
return SENSITIVE_FIELDS_FOR_LOG.matcher(body).replaceAll("\"$1\":\"***\"");
}
/**
* 处理微信 API 异常(脱敏后返回友好提示)
*
* 注意:不使用 @ResponseStatus,保持与现有 BusinessException handler 一致
* (HTTP 层返回 200,业务错误通过 Result.code 表达)
*/
@ExceptionHandler(WechatApiException.class)
public Result<Void> handleWechatApiException(WechatApiException e, HttpServletRequest request) {
// 服务端日志:rawBody 需脱敏(Interceptor 已对响应 body 脱敏,
// 但 rawBody 字段是再次传递到异常对象,必须再次脱敏以防日志明文泄露)
log.error("[WeChatAPI] 异常: {} {} - code={}, msg={}, rawBody={}",
request.getMethod(), request.getRequestURI(),
e.getCode(), e.getMessage(), desensitizeForLog(e.getRawBody()));
// 前端响应:脱敏友好提示(不暴露 rawBody / errcode / 内部技术细节)
String userMessage;
if (e.getCode() != null && e.getCode() == 400) {
userMessage = "微信授权失败,请重试";
} else if (e.getCode() != null && e.getCode() == 502) {
userMessage = "微信服务暂不可用,请稍后重试";
} else {
userMessage = "微信登录失败,请重试";
}
return Result.error(userMessage);
}
7. application.yml 修改(解决 P1-5 一致性)
wechat:
mini-program:
# 防御性:删除硬编码默认值,强制从环境变量读取
# 生产必须设置 WECHAT_MINI_PROGRAM_APP_ID 和 WECHAT_MINI_PROGRAM_APP_SECRET
app-id: ${WECHAT_MINI_PROGRAM_APP_ID:}
app-secret: ${WECHAT_MINI_PROGRAM_APP_SECRET:}
base-url: https://api.weixin.qq.com
8. WechatCodeSessionResponse 保持不动(解决 P2-6)
决策:rawBody 不放在 DTO,只在 WechatApiException 对象和服务端日志中。理由:
- DTO 是纯数据传输对象,承载 rawBody 会污染数据模型
- DTO 已经通过
@JsonProperty等注解映射字段,加@JsonIgnore字段是反模式 - rawBody 已经在异常对象中保留了,足够排查
包结构(解决 P1-2)
| 类 | 包 |
|---|---|
RestTemplateConfig |
com.emotion.config(保持) |
WechatResponseErrorHandler |
com.emotion.config(与 RestTemplateConfig 同包) |
WechatApiLoggingInterceptor |
com.emotion.interceptor(与 JwtAuthInterceptor 同包) |
WechatApiException |
com.emotion.exception(与 BusinessException 同包) |
WechatMiniProgramServiceImpl |
com.emotion.service.impl(保持) |
WechatCodeSessionResponse |
com.emotion.dto.wechat(保持) |
不新增 com.emotion.http 包(与项目现有结构不一致)。
错误处理层次(解决 P0-4)
WechatMiniProgramServiceImpl.code2Session()
├─ RestClientException (网络/SSL/DNS) → WechatApiException(502)
├─ HTTP 非 2xx
│ ├─ 4xx → WechatApiException(400, rawBody)
│ └─ 5xx → WechatApiException(502, rawBody)
├─ Content-Type 非 application/json → WechatApiException(502, rawBody)
├─ JSON 解析失败 → WechatApiException(500, rawBody)
└─ errcode != 0 → WechatApiException(400, rawBody)
GlobalExceptionHandler.handleWechatApiException
├─ 服务端日志: 完整 rawBody(脱敏)+ code + message
└─ 前端响应: 脱敏友好提示("微信服务暂不可用..."等)
测试
单元测试(WechatMiniProgramServiceImplTest)
| 场景 | 模拟返回 | 期望 |
|---|---|---|
| 1. 成功 | HTTP 200 + application/json + {"openid":"oX","session_key":"sK"} |
返回 DTO |
| 2. 微信业务错误(40013) | HTTP 200 + JSON {"errcode":40013,"errmsg":"invalid appid"} |
抛 WechatApiException(400, rawBody) |
| 3. text/plain(WAF) | HTTP 200 + text/plain + "blocked by waf" |
抛 WechatApiException(502, rawBody) |
| 4. 4xx | HTTP 401 + body | 抛 WechatApiException(400, rawBody) |
| 5. 5xx | HTTP 500 + body | 抛 WechatApiException(502, rawBody) |
| 6. 非法 JSON | HTTP 200 + application/json + not json |
抛 WechatApiException(500, rawBody) |
| 7. 网络异常 | RestClientException (IOException) | 抛 WechatApiException(502) |
| 8. 未配置 | appId 为空 | 抛 BusinessException("微信小程序登录未配置") |
| 9. LoggingInterceptor 脱敏 | 响应含 session_key |
日志中 session_key 字段值变为 *** |
| 10. 其他 7 个使用点不受影响 | 模拟 DifyProviderAdapter 等调用 | 正常返回(验证 BufferingClientHttpRequestFactory 没破坏行为) |
集成验证
| 步骤 | 命令 / 操作 | 期望 |
|---|---|---|
| 1. mvn 编译 | mvn clean install -pl :server -am -DskipTests |
退出码 0 |
| 2. 部署 | python deploy.py backend |
部署成功,重启 backend |
| 3. WAF 验证(修后 P1-7) | tail -f /data/logs/emotion-museum/emotion-single.log | grep "WeChatAPI" |
看到 [WeChatAPI] ---> GET https://api.weixin.qq.com/sns/jscode2session ... 请求日志;<--- 200 响应日志 |
| 4. UA 验证 | grep "User-Agent" /data/logs/emotion-museum/emotion-single.log |
看到 Mozilla/5.0(不再是 Java/17.0.x) |
| 5. 端到端(H5 模式) | 启动 mini-program H5 (端口 5173) → 微信授权登录 | 登录成功,跳转 onboarding 或主页 |
| 6. 凭据验证 | 部署后从服务器内部 curl 微信 API |
返回正常 JSON errcode 或 openid |
| 7. 错误响应验证 | 临时配置错误 appSecret 触发微信 40163 | 前端收到 "微信授权失败,请重试";后端日志有完整 rawBody |
回滚方案(解决 P2-5)
| 步骤 | 操作 |
|---|---|
| 1. 保留旧版本 | 部署脚本 python deploy.py backend 自动备份当前 JAR 到 /opt/emotion-museum/backup/emotion-single-{timestamp}.jar |
| 2. 健康检查 | 部署后 30s 内 curl https://lifescript.happylifeos.com/api/health 返回 200 |
| 3. 失败回滚 | 如果健康检查失败,执行 python deploy.py backend --rollback(脚本会读取最近一次备份 JAR 替换并重启) |
| 4. 数据库兼容 | 本次修改不涉及 schema,无需数据迁移 |
防御性测试
- 在测试环境人为让微信 API 返回
text/plain,确认不会 500(而是 WechatApiException 被 GlobalExceptionHandler 捕获,返回 500 + 友好提示) - 单元测试覆盖 10 个场景(见上表)
风险与缓解
| 风险 | 影响 | 缓解 |
|---|---|---|
改 RestTemplateConfig 影响其他 7 处使用点 |
中 | 用 BufferingClientHttpRequestFactory 包装确保 body 可多次读;additionalInterceptors / additionalMessageConverters 是追加(不覆盖);3 天观察期;单元测试场景 10 覆盖 |
默认 app-id / app-secret 删除后本地开发挂 |
低 | local 启动报错时本地 dev 配 env var;CI 配 secret;IDEA Run Configuration 加 env var |
生产 WECHAT_MINI_PROGRAM_* 未设导致启动失败 |
低 | 启动日志 warn 但不阻断(保持现状);调用时才抛 BusinessException |
| LoggingInterceptor 性能开销 | 低 | body 仅 DEBUG 级别(生产默认不输出);MAX_BODY_LOG=2048 截断 |
| 错误日志泄露敏感信息 | 中 | Interceptor 中脱敏(session_key/openid/unionid/access_token/refresh_token → ***);rawBody 不返回前端 |
| 微信 API 真实业务错误码(如 40013 invalid appid)被脱敏后用户无法知道原因 | 低 | 后端日志记录完整 errcode + errmsg;运维可查;前端用户只需重试 |
影响的 7 个其他 RestTemplate 使用点(解决 P1-8)
| 类 | 调用方式 | 是否会受影响 |
|---|---|---|
DifyProviderAdapter |
restTemplate.execute(url, POST, ..., body) 流式 POST |
❌ BufferingClientHttpRequestFactory 兼容 |
CozeProviderAdapter |
同上 | ❌ 兼容 |
HttpTtsEngineClient |
restTemplate.postForEntity(url, body, Map.class) |
❌ 兼容 |
AsrServiceImpl |
restTemplate.postForEntity(url, body, Map.class) |
❌ 兼容 |
ApiEndpointServiceImpl |
restTemplate.getForObject(url, String.class) |
❌ 兼容 |
AiChatServiceImpl |
restTemplate.exchange(...) ResponseEntity + 部分 HttpClient |
❌ 兼容(RestTemplate 部分) |
ApiTestProxyController |
restTemplate.exchange(...) + String.class |
❌ 兼容 |
结论:因为 BufferingClientHttpRequestFactory 把 body 缓存为字节数组,所有 RestTemplate 消费者(包括反序列化器)都能正常读 body。其他 7 个使用点的行为完全不变(仅多一份脱敏后的 body DEBUG 日志)。
范围外(Out of Scope)
- 不做 RestTemplate 连接池(沿用 SimpleClientHttpRequestFactory)
- 不重试 / 不熔断(业务层不需要)
- 不做 phone number binding / merge logic(已有 2026-05-31 plan 覆盖,且当前代码尚未实施)
- 不改 mini-program 前端(纯后端 bug)
- 不改其他 7 个 RestTemplate 使用点的业务代码
参考资料
- 已有设计:
docs/superpowers/specs/2026-05-31-mini-program-wechat-auth-design.md - 已有 plan:
docs/superpowers/plans/2026-05-31-mini-program-wechat-auth.md - 微信 API:https://developers.weixin.qq.com/miniprogram/dev/OpenApiDoc/user-login/code2Session.html
- Spring RestTemplateBuilder:https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/boot/web/client/RestTemplateBuilder.html
- BufferingClientHttpRequestFactory:https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/http/client/BufferingClientHttpRequestFactory.html
- WAF / CloudFlare 对 Java UA 的拦截(业界已知问题,常见解决方案:显式设置 User-Agent)
- 项目安全规则:
G:\IdeaProjects\emotion-museun\.cursor\rules\rules.mdc第 37 条「敏感信息不得在日志中输出」