539 lines
27 KiB
Markdown
539 lines
27 KiB
Markdown
---
|
||
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 条「敏感信息不得在日志中输出」
|