From 13f773f3f006e7f2107717a9937270bf3320f54d Mon Sep 17 00:00:00 2001 From: Peanut Date: Wed, 3 Jun 2026 07:35:50 +0800 Subject: [PATCH] =?UTF-8?q?docs:=20=E4=BF=AE=E5=A4=8D=20spec=20=E8=AF=84?= =?UTF-8?q?=E5=AE=A1=2017=20=E4=B8=AA=E9=97=AE=E9=A2=98=EF=BC=884=20P0=20?= =?UTF-8?q?=E9=98=BB=E5=A1=9E=20+=207=20P1=20=E9=87=8D=E8=A6=81=20+=206=20?= =?UTF-8?q?P2=20=E6=AC=A1=E8=A6=81=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) - 增加回滚方案说明 --- ...6-03-wechat-resttemplate-waf-fix-design.md | 490 +++++++++++------- 1 file changed, 305 insertions(+), 185 deletions(-) diff --git a/docs/superpowers/specs/2026-06-03-wechat-resttemplate-waf-fix-design.md b/docs/superpowers/specs/2026-06-03-wechat-resttemplate-waf-fix-design.md index fa9f03b..cc7f35f 100644 --- a/docs/superpowers/specs/2026-06-03-wechat-resttemplate-waf-fix-design.md +++ b/docs/superpowers/specs/2026-06-03-wechat-resttemplate-waf-fix-design.md @@ -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` + 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` + 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/status(INFO)+ 脱敏 body(DEBUG) | +| 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 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 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 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"}` | 返回 DTO,rawBody 已设 | -| 2. 微信业务错误 | HTTP 200 + JSON `{"errcode":40013,"errmsg":"invalid appid"}` | 抛 WechatApiException(40013, rawBody 含 errmsg) | -| 3. text/plain(WAF) | 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/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 没破坏行为) | ### 集成验证 @@ -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 var;CI 配 secret | -| 生产 `WECHAT_MINI_PROGRAM_*` 未设导致启动失败 | 低 | 启动日志 warn 但不阻断(保持现状) | -| LoggingInterceptor 性能开销 | 低 | 仅记录前 2KB body;生产可调低 log level;interceptor 是单例 | -| 错误日志泄露敏感信息(openid / session_key) | 中 | rawBody 在 GlobalExceptionHandler 中**不返回**给前端,仅服务端日志;生产日志脱敏审计 | +| 改 `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) +- 不做 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` - 微信 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 条「敏感信息不得在日志中输出」