18 KiB
18 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-secret 硬编码在源码(
4f087bd363a4147ef543d634f9f6950d)—— 安全问题,必须移除默认值 - 错误日志不打印响应体 —— 看不到 text/plain 实际内容,下次出问题还是黑盒
- 无超时设置 —— 微信 API hang 住时整个请求线程被锁
- RestTemplate 无日志拦截器 —— 出问题时无任何请求/响应可查
已排除假设
- ❌ 微信 API endpoint URL 配错:SSH+curl 直连
https://api.weixin.qq.com/sns/jscode2session返回正常 JSON - ❌ 部署代码 ≠ 本地代码:日志中
WechatMiniProgramServiceImpl.java:33与本地代码行号完全一致 - ❌ 网络层完全不通:SSH+curl TLS 握手成功
目标
- G1 修复微信小程序登录
text/plain错误,使用户能成功通过微信授权登录 - G2 在 RestTemplate 中显式设置
User-Agent,绕过 WAF 对Java/*默认 UA 的拦截 - G3 错误日志打印原始响应体(raw body),下次类似问题能直接看到内容
- G4 移除
application.yml中微信app-secret的硬编码默认值,强制从环境变量读取 - G5 RestTemplate 统一配置:超时、User-Agent、Accept 头、StringHttpMessageConverter、LoggingInterceptor
非目标
- 不改动现有 8 个 RestTemplate 使用点的业务代码(仅共享统一 bean)
- 不做 RestTemplate 连接池(沿用默认 SimpleClientHttpRequestFactory)
- 不重试 / 不熔断(业务层不需要)
- 不做 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 | backend-single/src/main/java/com/emotion/config/RestTemplateConfig.java |
重写 | 30 行 | 改用 RestTemplateBuilder:设置 User-Agent、超时、加 StringHttpMessageConverter、加 LoggingInterceptor、加 ErrorHandler |
| 2 | backend-single/src/main/java/com/emotion/service/impl/WechatMiniProgramServiceImpl.java |
重写 | 80 行 | 改用 ResponseEntity<String> + Jackson 二次解析;捕获所有失败场景的 raw body |
| 3 | backend-single/src/main/resources/application.yml |
修改 | 2 行 | 删除 app-secret 硬编码默认值(保留 ${ENV_VAR:} 空兜底) |
| 4 | backend-single/src/main/java/com/emotion/exception/WechatApiException.java |
新增 | 35 行 | 业务异常类(errcode + errmsg + rawBody + httpStatus) |
| 5 | backend-single/src/main/java/com/emotion/http/LoggingInterceptor.java |
新增 | 50 行 | ClientHttpRequestInterceptor:记录 method/URL/headers/status/body(前 2KB) |
| 6 | backend-single/src/main/java/com/emotion/http/WechatResponseErrorHandler.java |
新增 | 40 行 | 4xx/5xx 不抛 RestClientException,记录 body |
| 7 | backend-single/src/main/java/com/emotion/dto/wechat/WechatCodeSessionResponse.java |
修改 | 3 行 | 加 rawBody 字段 + getter/setter |
总计:2 重写 + 3 新增 + 2 修改,约 240 行 Java + 2 行 YAML。
详细设计
1. RestTemplateConfig 重写
package com.emotion.config;
import com.emotion.http.LoggingInterceptor;
import com.emotion.http.WechatResponseErrorHandler;
import org.springframework.boot.web.client.RestTemplateBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
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 {
@Bean
public RestTemplate restTemplate(RestTemplateBuilder builder) {
return builder
// 防御性:绕过 WAF / CloudFlare 对默认 User-Agent "Java/*" 的拦截
.defaultHeader("User-Agent", "Mozilla/5.0 (compatible; EmotionMuseum/1.0)")
.defaultHeader("Accept", "application/json, text/plain, */*")
// 防御性:连接 / 读取超时,避免线程被 hang 住
.setConnectTimeout(Duration.ofSeconds(5))
.setReadTimeout(Duration.ofSeconds(10))
// 防御性:支持 text/plain 响应(即使 WAF 返回 text/plain 错误页也能拿到 body)
.additionalMessageConverters(new StringHttpMessageConverter(StandardCharsets.UTF_8))
// 防御性:记录所有外发请求 / 响应(含 body 前 2KB),方便排查
.additionalInterceptors(new LoggingInterceptor())
// 防御性:4xx / 5xx 不抛 RestClientException,由业务层解析
.errorHandler(new WechatResponseErrorHandler())
.build();
}
}
2. WechatMiniProgramServiceImpl.code2Session 重写
@Override
public WechatCodeSessionResponse code2Session(String code) {
validateConfigured();
String url = buildCode2SessionUrl(code);
try {
// 第一步:拿原始 String 响应(不直接反序列化为 DTO,避免 content-type 错误就丢 body)
ResponseEntity<String> response = restTemplate.exchange(
url, HttpMethod.GET, null, String.class);
String rawBody = response.getBody();
HttpStatusCode status = response.getStatusCode();
MediaType contentType = response.getHeaders().getContentType();
log.debug("WeChat code2Session raw response: status={}, contentType={}, body={}",
status, contentType, rawBody);
// 第二步:HTTP 状态码检查
if (status == null || !status.is2xxSuccessful()) {
log.error("WeChat API HTTP error: status={}, contentType={}, body={}",
status, contentType, rawBody);
throw new WechatApiException(
status != null ? status.value() : -1,
"WeChat HTTP " + status, rawBody);
}
// 第三步:content-type 防御
if (contentType == null || !contentType.includesCompatibleWith(MediaType.APPLICATION_JSON)) {
log.error("WeChat API returned non-JSON: contentType={}, body={}", contentType, rawBody);
throw new WechatApiException(-1,
"WeChat API returned " + contentType + " (body captured in logs)", rawBody);
}
// 第四步:JSON 解析
WechatCodeSessionResponse dto;
try {
dto = objectMapper.readValue(rawBody, WechatCodeSessionResponse.class);
} catch (JsonProcessingException e) {
log.error("WeChat API returned invalid JSON. body={}", rawBody, e);
throw new WechatApiException(-2, "Invalid JSON from WeChat", rawBody);
}
// 第五步:业务错误码检查
if (!dto.isSuccess()) {
log.warn("WeChat code2Session business error: errcode={}, errmsg={}, body={}",
dto.getErrcode(), dto.getErrmsg(), rawBody);
throw new WechatApiException(
dto.getErrcode() != null ? dto.getErrcode() : -3,
dto.getErrmsg() != null ? dto.getErrmsg() : "Unknown",
rawBody);
}
dto.setRawBody(rawBody); // 保存原始响应便于排查
return dto;
} catch (WechatApiException e) {
throw e;
} catch (RestClientException e) {
// 网络 / DNS / SSL 错误
log.error("WeChat API call failed (network/SSL/DNS)", e);
throw new WechatApiException(-4, "Network error: " + e.getMessage(), null);
}
}
3. WechatApiException 新增
package com.emotion.exception;
import lombok.Getter;
@Getter
public class WechatApiException extends RuntimeException {
private final Integer errcode; // 微信 errcode 或 HTTP status
private final String rawBody; // 原始响应体
public WechatApiException(Integer errcode, String message, String rawBody) {
super(message);
this.errcode = errcode;
this.rawBody = rawBody;
}
}
4. LoggingInterceptor 新增
package com.emotion.http;
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;
@Slf4j
public class LoggingInterceptor implements ClientHttpRequestInterceptor {
private static final int MAX_BODY_LOG = 2048; // 仅记录前 2KB
@Override
public ClientHttpResponse intercept(
HttpRequest request, byte[] body,
ClientHttpRequestExecution execution) throws IOException {
long start = System.currentTimeMillis();
log.info("[HTTP] ---> {} {} (UA={})",
request.getMethod(), request.getURI(),
request.getHeaders().getFirst("User-Agent"));
ClientHttpResponse response = execution.execute(request, body);
long duration = System.currentTimeMillis() - start;
String responseBody = new String(response.getBody().readAllBytes(), StandardCharsets.UTF_8);
if (responseBody.length() > MAX_BODY_LOG) {
responseBody = responseBody.substring(0, MAX_BODY_LOG) + "...(truncated)";
}
log.info("[HTTP] <--- {} {} ({}ms) content-type={} body={}",
response.getStatusCode(), request.getURI(), duration,
response.getHeaders().getContentType(), responseBody);
return response;
}
}
5. WechatResponseErrorHandler 新增
package com.emotion.http;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.client.ClientHttpResponse;
import org.springframework.web.client.DefaultResponseErrorHandler;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
@Slf4j
public class WechatResponseErrorHandler extends DefaultResponseErrorHandler {
@Override
public void handleError(ClientHttpResponse response) throws IOException {
String body = new String(response.getBody().readAllBytes(), StandardCharsets.UTF_8);
log.error("WeChat API HTTP error: status={}, body={}", response.getStatusCode(), body);
// 不抛异常,由 WechatMiniProgramServiceImpl 业务层根据 status 判断
}
}
6. application.yml 修改
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
7. WechatCodeSessionResponse 修改
@Data
public class WechatCodeSessionResponse {
// ... 原有字段 ...
private String openid;
@JsonProperty("session_key")
private String sessionKey;
private String unionid;
private Integer errcode;
private String errmsg;
// 新增:原始响应体(仅服务端排查用,绝不返回给前端)
@JsonIgnore
private String rawBody;
public boolean isSuccess() {
return (errcode == null || errcode == 0) && StringUtils.hasText(openid);
}
}
错误处理层次
WechatMiniProgramServiceImpl.code2Session()
├─ RestClientException (网络/SSL/DNS)
│ └─ 抛 WechatApiException(errcode=-4, rawBody=null)
├─ HTTP 非 2xx
│ └─ 抛 WechatApiException(errcode=status, rawBody)
├─ Content-Type 非 application/json
│ └─ 抛 WechatApiException(errcode=-1, rawBody)
├─ JSON 解析失败
│ └─ 抛 WechatApiException(errcode=-2, rawBody)
└─ errcode != 0
└─ 抛 WechatApiException(errcode, rawBody)
GlobalExceptionHandler (已有)
└─ 捕获 WechatApiException → 500 + 友好提示(不暴露 rawBody)
测试
单元测试(WechatMiniProgramServiceImplTest)
| 场景 | 模拟返回 | 期望 |
|---|---|---|
| 1. 成功 | HTTP 200 + application/json + {"openid":"oX","session_key":"sK"} |
返回 DTO,rawBody 已设 |
| 2. 微信业务错误 | HTTP 200 + JSON {"errcode":40013,"errmsg":"invalid appid"} |
抛 WechatApiException(40013, rawBody 含 errmsg) |
| 3. text/plain(WAF) | HTTP 200 + text/plain + "blocked" |
抛 WechatApiException(-1, rawBody="blocked") |
| 4. 4xx | HTTP 401 + body | 抛 WechatApiException(401, rawBody) |
| 5. 5xx | HTTP 500 + body | 抛 WechatApiException(500, rawBody) |
| 6. 非法 JSON | HTTP 200 + application/json + not json |
抛 WechatApiException(-2, rawBody) |
| 7. 网络异常 | RestClientException (IOException) | 抛 WechatApiException(-4) |
| 8. 未配置 | appId 为空 | 抛 BusinessException("微信小程序登录未配置") |
集成验证
| 步骤 | 命令 / 操作 | 期望 |
|---|---|---|
| 1. mvn 编译 | mvn clean install -pl :backend-single -am -DskipTests |
退出码 0 |
| 2. 部署 | python deploy.py backend |
部署成功,重启 backend |
| 3. 端到端(H5 模式) | 启动 mini-program H5 (端口 5173) → 微信授权登录 | 登录成功,跳转 onboarding 或主页 |
| 4. 看后端日志 | tail -f /data/logs/emotion-museum/emotion-single.log |
看到 [HTTP] ---> GET https://api.weixin.qq.com/sns/jscode2session ... 和 <--- 200 ... body={...} |
| 5. WAF 验证 | Postman 设 User-Agent: Java/17.0.x 测后端 |
期望:现在 WAF 不再拦截(因为后端已设 Mozilla UA) |
| 6. 凭据验证 | 部署后从服务器内部 curl 微信 API |
期望:返回正常 JSON errcode 或 openid |
防御性测试
- 在测试环境人为让微信 API 返回
text/plain,确认不会 500(而是 WechatApiException 被 GlobalExceptionHandler 捕获,返回 500 + 友好提示) - 单元测试覆盖 8 个场景(见上表)
风险与缓解
| 风险 | 影响 | 缓解 |
|---|---|---|
改 RestTemplateConfig 影响其他 8 处使用点 |
中 | 用 additionalInterceptors / additionalMessageConverters(追加,不覆盖);3 天观察期 |
默认 app-secret 删除后本地开发挂 |
低 | local 启动报错时本地 dev 配 env var;CI 配 secret |
生产 WECHAT_MINI_PROGRAM_* 未设导致启动失败 |
低 | 启动日志 warn 但不阻断(保持现状) |
| LoggingInterceptor 性能开销 | 低 | 仅记录前 2KB body;生产可调低 log level;interceptor 是单例 |
| 错误日志泄露敏感信息(openid / session_key) | 中 | rawBody 在 GlobalExceptionHandler 中不返回给前端,仅服务端日志;生产日志脱敏审计 |
范围外(Out of Scope)
- 不做 RestTemplate 连接池(沿用默认 SimpleClientHttpRequestFactory)
- 不重试 / 不熔断(业务层不需要)
- 不做 phone number binding / merge logic(已有 plan 覆盖)
- 不改 mini-program 前端(纯后端 bug)
- 不改其他 8 个 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
- WAF / CloudFlare 对 Java UA 的拦截(业界已知问题,常见解决方案:显式设置 User-Agent)