docs: 修复 spec 评审 17 个问题(4 P0 阻塞 + 7 P1 重要 + 6 P2 次要)

P0(阻塞):
- BufferingClientHttpRequestFactory 解决响应体流被消费破坏其他 7 个使用点
- WechatApiException 显式 handler 避免泄露原始错误消息给前端
- LoggingInterceptor 脱敏 session_key/openid/unionid 解决敏感信息泄露
- WechatResponseErrorHandler 显式重写 hasError 避免流消费冲突

P1(重要):
- 包结构统一(LoggingInterceptor → interceptor 包,ErrorHandler → config 包)
- WechatApiException 用 HTTP 风格错误码(400/500/502)替代负数
- app-id/app-secret 一致性:都删除硬编码默认值
- WAF 验证改为后端日志验证 UA(不再用 Postman)
- 列出 7 个其他 RestTemplate 使用点逐一评估

P2(次要):
- body 日志改 DEBUG(生产 INFO 不打印)
- 统一 [WeChatAPI] 日志前缀便于 grep
- rawBody 不放在 DTO(保持纯 DTO)
- 增加回滚方案说明
This commit is contained in:
2026-06-03 07:35:50 +08:00
parent 5631f22566
commit 13f773f3f0
@@ -44,31 +44,36 @@ purpose: 修复微信小程序登录 text/plain 错误(Spring RestTemplate 默
**次要根因(必须一并修复)**
1. **app-secret 硬编码在源码**`4f087bd363a4147ef543d634f9f6950d`)—— 安全问题,必须移除默认值
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),下次类似问题能直接看到内容
- **G4** 移除 `application.yml` 中微信 `app-secret` 的硬编码默认值,强制从环境变量读取
- **G5** RestTemplate 统一配置:超时、User-Agent、Accept 头、StringHttpMessageConverter、LoggingInterceptor
- **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 可被多次读取)
## 非目标
- 不改动现有 8 个 RestTemplate 使用点的业务代码(仅共享统一 bean)
- 不做 RestTemplate 连接池(沿用默认 SimpleClientHttpRequestFactory
- 不改动现有 7 个 RestTemplate 使用点的业务代码(仅共享统一 bean)
- 不做 RestTemplate 连接池(沿用 SimpleClientHttpRequestFactory,外面包一层 BufferingClientHttpRequestFactory
- 不重试 / 不熔断(业务层不需要)
- 不做 phone number binding / merge logic(已有 plan 覆盖)
- 不做 phone number binding / merge logic(已有 plan 覆盖,且当前代码尚未实施
- 不改 mini-program 前端(纯后端 bug
## 方案选择
@@ -85,28 +90,32 @@ purpose: 修复微信小程序登录 text/plain 错误(Spring RestTemplate 默
| # | 文件 | 操作 | 行数估算 | 目的 |
|---|---|---|---|---|
| 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 |
| 1 | `backend-single/src/main/java/com/emotion/config/RestTemplateConfig.java` | **重写** | 40 行 | 改用 `RestTemplateBuilder` + `BufferingClientHttpRequestFactory`:设置 User-Agent、超时、加 StringHttpMessageConverter、加 LoggingInterceptor、加 ErrorHandler |
| 2 | `backend-single/src/main/java/com/emotion/service/impl/WechatMiniProgramServiceImpl.java` | **重写** | 90 行 | `code2Session` 改用 `ResponseEntity<String>` + Jackson 二次解析;捕获所有失败场景的 raw body |
| 3 | `backend-single/src/main/resources/application.yml` | **修改** | 2 行 | 删除 `app-id``app-secret` 硬编码默认值(`${ENV_VAR:}` 空兜底) |
| 4 | `backend-single/src/main/java/com/emotion/exception/WechatApiException.java` | **新增** | 30 行 | 业务异常类(code 用 HTTP 风格状态码 + rawBody |
| 5 | `backend-single/src/main/java/com/emotion/interceptor/WechatApiLoggingInterceptor.java` | **新增** | 60 行 | ClientHttpRequestInterceptor:记录 method/URL/statusINFO+ 脱敏 bodyDEBUG |
| 6 | `backend-single/src/main/java/com/emotion/config/WechatResponseErrorHandler.java` | **新增** | 30 行 | 4xx/5xx 不抛 RestClientException重写 `hasError()` 显式声明 |
| 7 | `backend-single/src/main/java/com/emotion/exception/GlobalExceptionHandler.java` | **修改** | 25 行 | 新增 `@ExceptionHandler(WechatApiException.class)` 返回脱敏的友好提示 |
| 8 | `backend-single/src/main/java/com/emotion/dto/wechat/WechatCodeSessionResponse.java` | **不动** | 0 行 | **保持纯 DTO**rawBody 不放在 DTO,只在异常对象和日志中) |
**总计**2 重写 + 3 新增 + 2 修改,约 240 行 Java + 2 行 YAML。
**总计**2 重写 + 3 新增 + 3 修改,约 280 行 Java + 2 行 YAML。
### 详细设计
#### 1. RestTemplateConfig 重写
#### 1. RestTemplateConfig 重写(解决 P0-1 / P0-2 流消费问题)
**关键决策**:用 `BufferingClientHttpRequestFactory` 包装 `SimpleClientHttpRequestFactory`,让响应体被缓存到字节数组,可以被多次读取(Interceptor + ErrorHandler + Converter 都能读)。
```java
package com.emotion.config;
import com.emotion.http.LoggingInterceptor;
import com.emotion.http.WechatResponseErrorHandler;
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;
@@ -116,117 +125,45 @@ 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
// 防御性:绕过 WAF / CloudFlare 对默认 User-Agent "Java/*" 的拦截
.requestFactory(() -> bufferingFactory)
.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,由业务层解析
.additionalInterceptors(new WechatApiLoggingInterceptor())
.errorHandler(new WechatResponseErrorHandler())
.build();
}
}
```
#### 2. WechatMiniProgramServiceImpl.code2Session 重写
#### 2. WechatApiLoggingInterceptor 新增(解决 P0-3 敏感信息泄露)
```java
@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 新增
```java
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 新增
```java
package com.emotion.http;
package com.emotion.interceptor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpRequest;
@@ -236,11 +173,23 @@ 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 LoggingInterceptor implements ClientHttpRequestInterceptor {
public class WechatApiLoggingInterceptor implements ClientHttpRequestInterceptor {
private static final int MAX_BODY_LOG = 2048; // 仅记录前 2KB
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(
@@ -248,50 +197,197 @@ public class LoggingInterceptor implements ClientHttpRequestInterceptor {
ClientHttpRequestExecution execution) throws IOException {
long start = System.currentTimeMillis();
log.info("[HTTP] ---> {} {} (UA={})",
request.getMethod(), request.getURI(),
request.getHeaders().getFirst("User-Agent"));
log.info("[WeChatAPI] ---> {} {}", request.getMethod(), request.getURI());
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={}",
log.info("[WeChatAPI] <--- {} {} ({}ms) content-type={}",
response.getStatusCode(), request.getURI(), duration,
response.getHeaders().getContentType(), responseBody);
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\":\"***\"");
}
}
```
#### 5. WechatResponseErrorHandler 新增
#### 3. WechatResponseErrorHandler 新增(解决 P0-2 显式 hasError
```java
package com.emotion.http;
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;
import java.nio.charset.StandardCharsets;
/**
* 微信 API 错误处理器
*
* 设计要点:
* 1. 显式重写 hasError() 返回 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 {
// 显式重写:让 handleError 总是被调用(默认实现根据 status 4xx/5xx 判断)
return super.hasError(response);
}
@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 判断
// 不抛异常;body 已在 LoggingInterceptor 中读取并脱敏记录
// 业务层会通过 response.getStatusCode() 判断
log.warn("[WeChatAPI] HTTP error response, status={}", response.getStatusCode());
}
}
```
#### 6. application.yml 修改
#### 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 防御
if (contentType == null || !contentType.includesCompatibleWith(MediaType.APPLICATION_JSON)) {
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
/**
* 处理微信 API 异常(脱敏后返回友好提示)
*/
@ExceptionHandler(WechatApiException.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public Result<Void> handleWechatApiException(WechatApiException e, HttpServletRequest request) {
// 服务端日志:记录完整上下文(含 rawBody)
log.error("[WeChatAPI] 异常: {} {} - code={}, msg={}, rawBody={}",
request.getMethod(), request.getRequestURI(),
e.getCode(), e.getMessage(), 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:
@@ -303,46 +399,41 @@ wechat:
base-url: https://api.weixin.qq.com
```
#### 7. WechatCodeSessionResponse 修改
#### 8. WechatCodeSessionResponse 保持不动(解决 P2-6
```java
@Data
public class WechatCodeSessionResponse {
// ... 原有字段 ...
private String openid;
@JsonProperty("session_key")
private String sessionKey;
private String unionid;
private Integer errcode;
private String errmsg;
**决策**rawBody 不放在 DTO,只在 `WechatApiException` 对象和服务端日志中。理由:
- DTO 是纯数据传输对象,承载 rawBody 会污染数据模型
- DTO 已经通过 `@JsonProperty` 等注解映射字段,加 `@JsonIgnore` 字段是反模式
- rawBody 已经在异常对象中保留了,足够排查
// 新增:原始响应体(仅服务端排查用,绝不返回给前端)
@JsonIgnore
private String rawBody;
### 包结构(解决 P1-2
public boolean isSuccess() {
return (errcode == null || errcode == 0) && StringUtils.hasText(openid);
}
}
```
| 类 | 包 |
|---|---|
| `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(errcode=-4, rawBody=null)
├─ RestClientException (网络/SSL/DNS) → WechatApiException(502)
├─ HTTP 非 2xx
WechatApiException(errcode=status, rawBody)
├─ Content-Type 非 application/json
│ └─ 抛 WechatApiException(errcode=-1, rawBody)
├─ JSON 解析失败
│ └─ 抛 WechatApiException(errcode=-2, rawBody)
└─ errcode != 0
└─ 抛 WechatApiException(errcode, rawBody)
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 (已有)
捕获 WechatApiException → 500 + 友好提示(不暴露 rawBody)
GlobalExceptionHandler.handleWechatApiException
服务端日志: 完整 rawBody(脱敏)+ code + message
└─ 前端响应: 脱敏友好提示("微信服务暂不可用..."等)
```
## 测试
@@ -351,14 +442,16 @@ GlobalExceptionHandler (已有)
| 场景 | 模拟返回 | 期望 |
|---|---|---|
| 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) |
| 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 没破坏行为) |
### 集成验证
@@ -366,33 +459,58 @@ GlobalExceptionHandler (已有)
|---|---|---|
| 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 |
| 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 + 友好提示)
- 单元测试覆盖 8 个场景(见上表)
- 单元测试覆盖 10 个场景(见上表)
## 风险与缓解
| 风险 | 影响 | 缓解 |
|---|---|---|
| 改 `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 中**不返回**给前端,仅服务端日志;生产日志脱敏审计 |
| 改 `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
- 不做 RestTemplate 连接池(沿用 SimpleClientHttpRequestFactory
- 不重试 / 不熔断(业务层不需要)
- 不做 phone number binding / merge logic(已有 plan 覆盖
- 不做 phone number binding / merge logic(已有 2026-05-31 plan 覆盖,且当前代码尚未实施
- 不改 mini-program 前端(纯后端 bug
- 不改其他 8 个 RestTemplate 使用点的业务代码
- 不改其他 7 个 RestTemplate 使用点的业务代码
## 参考资料
@@ -400,4 +518,6 @@ GlobalExceptionHandler (已有)
- 已有 plan`docs/superpowers/plans/2026-05-31-mini-program-wechat-auth.md`
- 微信 APIhttps://developers.weixin.qq.com/miniprogram/dev/OpenApiDoc/user-login/code2Session.html
- Spring RestTemplateBuilderhttps://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/boot/web/client/RestTemplateBuilder.html
- BufferingClientHttpRequestFactoryhttps://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 条「敏感信息不得在日志中输出」