---
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.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` 所以能正常通过。

**次要根因（必须一并修复）**：

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-id` 和 `app-secret` 的硬编码默认值，强制从环境变量读取
- **G5** RestTemplate 统一配置：超时、User-Agent、Accept 头、StringHttpMessageConverter、LoggingInterceptor、ErrorHandler
- **G6** `WechatApiException` 在 `GlobalExceptionHandler` 中有专门处理，返回脱敏的友好提示给前端
- **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-id` 和 `app-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/status（INFO）+ 脱敏 body（DEBUG） |
| 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 行 | **保持纯 DTO**（rawBody 不放在 DTO，只在异常对象和日志中） |

**总计**：2 重写 + 3 新增 + 3 修改，约 280 行 Java + 2 行 YAML。

### 详细设计

#### 1. RestTemplateConfig 重写（解决 P0-1 / P0-2 流消费问题）

**关键决策**：用 `BufferingClientHttpRequestFactory` 包装 `SimpleClientHttpRequestFactory`，让响应体被缓存到字节数组，可以被多次读取（Interceptor + ErrorHandler + Converter 都能读）。

```java
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 敏感信息泄露）

```java
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）

```java
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 错误码风格）

```java
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 重写

```java
@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` 中新增：

```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 一致性）

```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
```

#### 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/plain（WAF） | 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 var；CI 配 secret；IDEA 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 使用点的业务代码

## 参考资料

- 已有设计：`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
- BufferingClientHttpRequestFactory：https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/http/client/BufferingClientHttpRequestFactory.html
- WAF / CloudFlare 对 Java UA 的拦截（业界已知问题，常见解决方案：显式设置 User-Agent）
- 项目安全规则：`G:\IdeaProjects\emotion-museun\.cursor\rules\rules.mdc` 第 37 条「敏感信息不得在日志中输出」
