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

18 KiB
Raw Blame History

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.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-12new RestTemplate()13 行 ⚠️ 无 User-Agent / Accept 头
5 Spring 默认 RestTemplateSimpleClientHttpRequestFactoryHttpURLConnection,默认 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.md265 行完整设计) 设计已存在,但未覆盖此 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 重写

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"} 返回 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 使用点的业务代码

参考资料