--- 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` + 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 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 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 条「敏感信息不得在日志中输出」