docs: 修复微信小程序登录 text/plain 错误设计文档(WAF 拦截 + 凭据硬编码)

This commit is contained in:
2026-06-03 07:28:30 +08:00
parent 76bc979cfe
commit 5631f22566
@@ -0,0 +1,403 @@
---
author: 微信登录修复会话
created_at: 2026-06-03
purpose: 修复微信小程序登录 text/plain 错误(Spring RestTemplate 默认 User-Agent 触发 WAF 拦截 + 凭据硬编码安全问题)
---
# 修复微信小程序登录 text/plain 错误 — 设计文档
## 问题陈述
微信小程序用户在小程序里点击「微信授权登录」时,后端 `/api/auth/wechat/login` 接口返回 500 错误:
```json
{
"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.1602026-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` 所以能正常通过。
**次要根因(必须一并修复)**
1. **app-secret 硬编码在源码**`4f087bd363a4147ef543d634f9f6950d`)—— 安全问题,必须移除默认值
2. **错误日志不打印响应体** —— 看不到 text/plain 实际内容,下次出问题还是黑盒
3. **无超时设置** —— 微信 API hang 住时整个请求线程被锁
4. **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 重写
```java
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 重写
```java
@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 新增
```java
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 新增
```java
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 新增
```java
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 修改
```yaml
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 修改
```java
@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"}` | 返回 DTOrawBody 已设 |
| 2. 微信业务错误 | HTTP 200 + JSON `{"errcode":40013,"errmsg":"invalid appid"}` | 抛 WechatApiException(40013, rawBody 含 errmsg) |
| 3. text/plainWAF | 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 varCI 配 secret |
| 生产 `WECHAT_MINI_PROGRAM_*` 未设导致启动失败 | 低 | 启动日志 warn 但不阻断(保持现状) |
| LoggingInterceptor 性能开销 | 低 | 仅记录前 2KB body;生产可调低 log levelinterceptor 是单例 |
| 错误日志泄露敏感信息(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`
- 微信 APIhttps://developers.weixin.qq.com/miniprogram/dev/OpenApiDoc/user-login/code2Session.html
- Spring RestTemplateBuilderhttps://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/boot/web/client/RestTemplateBuilder.html
- WAF / CloudFlare 对 Java UA 的拦截(业界已知问题,常见解决方案:显式设置 User-Agent