Files
happy-life-star/docs/superpowers/specs/2026-06-03-wechat-resttemplate-waf-fix-design.md
T

404 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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