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

27 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-id 和 app-secret 硬编码在源码wxaf2eaba72d28f0e4 / 4f087bd363a4147ef543d634f9f6950d)—— 安全问题,必须移除默认值,强制从环境变量读取
  2. 错误日志不打印响应体 —— 看不到 text/plain 实际内容,下次出问题还是黑盒
  3. 无超时设置 —— 微信 API hang 住时整个请求线程被锁
  4. RestTemplate 无日志拦截器 —— 出问题时无任何请求/响应可查
  5. 缺全局异常处理 —— WechatApiException 无 handler,会落到 RuntimeException handler 暴露内部消息
  6. 日志明文打印 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-idapp-secret 的硬编码默认值,强制从环境变量读取
  • G5 RestTemplate 统一配置:超时、User-Agent、Accept 头、StringHttpMessageConverter、LoggingInterceptor、ErrorHandler
  • G6 WechatApiExceptionGlobalExceptionHandler 中有专门处理,返回脱敏的友好提示给前端
  • 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-idapp-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/statusINFO+ 脱敏 bodyDEBUG
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 行 保持纯 DTOrawBody 不放在 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/plainWAF 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 varCI 配 secretIDEA 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 使用点的业务代码

参考资料