diff --git a/blade-third-party-api/blade-hcs-api/src/main/java/org/springblade/hcs/config/HcsFeignClientConfig.java b/blade-third-party-api/blade-hcs-api/src/main/java/org/springblade/hcs/config/HcsFeignClientConfig.java index 9c893d56..122c083c 100644 --- a/blade-third-party-api/blade-hcs-api/src/main/java/org/springblade/hcs/config/HcsFeignClientConfig.java +++ b/blade-third-party-api/blade-hcs-api/src/main/java/org/springblade/hcs/config/HcsFeignClientConfig.java @@ -27,6 +27,7 @@ package org.springblade.hcs.config; import feign.Request; import feign.RequestInterceptor; +import org.springblade.hcs.feign.HcsClientFallbackFactory; import org.springblade.hcs.interceptor.HcsAuthInterceptor; import org.springframework.context.annotation.Bean; @@ -49,6 +50,14 @@ public class HcsFeignClientConfig { }; } + /** + * HCS 降级工厂:非 2xx 时还原远程错误体 msg/traceId,避免全局兜底吞成 null(配合 IHcsClient 的 fallbackFactory 声明) + */ + @Bean + public HcsClientFallbackFactory hcsClientFallbackFactory() { + return new HcsClientFallbackFactory(); + } + /** * HCS 认证拦截器:所有请求注入 Authorization + Key-Auth 两个静态认证头(v4 接口说明) */ diff --git a/blade-third-party-api/blade-hcs-api/src/main/java/org/springblade/hcs/feign/HcsClientFallbackFactory.java b/blade-third-party-api/blade-hcs-api/src/main/java/org/springblade/hcs/feign/HcsClientFallbackFactory.java new file mode 100644 index 00000000..a4da6201 --- /dev/null +++ b/blade-third-party-api/blade-hcs-api/src/main/java/org/springblade/hcs/feign/HcsClientFallbackFactory.java @@ -0,0 +1,116 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

+ * Use of this software is governed by the Commercial License Agreement + * obtained after purchasing a license from BladeX. + *

+ * 1. This software is for development use only under a valid license + * from BladeX. + *

+ * 2. Redistribution of this software's source code to any third party + * without a commercial license is strictly prohibited. + *

+ * 3. Licensees may copyright their own code but cannot use segments + * from this software for such purposes. Copyright of this software + * remains with BladeX. + *

+ * Using this software signifies agreement to this License, and the software + * must not be used for illegal purposes. + *

+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is + * not liable for any claims arising from secondary or illegal development. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.hcs.feign; + +import feign.FeignException; +import org.springblade.core.tool.jackson.JsonUtil; +import org.springblade.core.tool.utils.StringUtil; +import org.springblade.hcs.dto.HcsResult; +import org.springframework.cloud.openfeign.FallbackFactory; + +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; + +/** + * HCS Feign 客户端降级工厂 + *

+ * HCS 返回非 2xx(如 500)时 Feign 抛 {@link FeignException},全局 BladeFeignFallback + * 对 HcsResult 返回值只会返回 null,导致远程错误信息丢失(调用侧只见"无响应/响应为空")。 + * 本工厂在降级时将错误体还原为失败的 {@link HcsResult}:HCS 错误体与 HcsResult 同构 + * (success/code/msg/traceId),直接反序列化取回远程 msg 与 traceId, + * 供调用侧日志与 hcs_sync_msg 等失败原因落库使用。 + *

+ * 通过 {@link IHcsClient} 的 fallbackFactory 声明并注册于 {@link org.springblade.hcs.config.HcsFeignClientConfig}, + * 所有 HCS 接口(含双层嵌套 HcsResult 外层)统一生效,调用侧零改动。 + * + * @author Chill + */ +public class HcsClientFallbackFactory implements FallbackFactory { + + @Override + public IHcsClient create(Throwable cause) { + return (IHcsClient) Proxy.newProxyInstance(IHcsClient.class.getClassLoader(), new Class[]{IHcsClient.class}, + (Object proxy, Method method, Object[] args) -> { + // Object 基础方法(toString/hashCode/equals)直接执行 + if (method.getDeclaringClass() != IHcsClient.class) { + return method.invoke(this, args); + } + // 附件下载等非 HcsResult 返回值无业务结果可还原,保持 null(与原全局兜底行为一致) + if (!HcsResult.class.isAssignableFrom(method.getReturnType())) { + return null; + } + return buildFailResult(cause); + }); + } + + /** + * 按异常构造失败的 HcsResult,优先还原远程错误体中的 msg/traceId + */ + private HcsResult buildFailResult(Throwable cause) { + HcsResult result = new HcsResult<>(); + result.setSuccess(false); + // HCS 非 2xx 时错误体随 FeignException 下发,结构与 HcsResult 一致 + if (cause instanceof FeignException feignException) { + result.setCode("errors.http." + feignException.status()); + HcsResult remote = parseRemoteResult(feignException.contentUTF8()); + if (remote != null && StringUtil.isNotBlank(remote.getMsg())) { + result.setMsg(remote.getMsg()); + result.setTraceId(remote.getTraceId()); + } else { + result.setMsg(resolveFallbackMessage(cause)); + } + return result; + } + // 非 HTTP 错误(熔断打开、连接超时等) + result.setCode("errors.feignFallback"); + result.setMsg(resolveFallbackMessage(cause)); + return result; + } + + /** + * 错误体反序列化为 HcsResult(非 JSON 或解析失败时返回 null) + */ + private HcsResult parseRemoteResult(String body) { + if (StringUtil.isBlank(body)) { + return null; + } + try { + return JsonUtil.parse(body, HcsResult.class); + } catch (Exception exception) { + return null; + } + } + + /** + * 兜底消息:异常摘要非空用之,否则给可读默认值 + */ + private String resolveFallbackMessage(Throwable cause) { + if (StringUtil.isNotBlank(cause.getMessage())) { + return cause.getMessage(); + } + return "HCS 接口调用异常(" + cause.getClass().getSimpleName() + ")"; + } +} diff --git a/blade-third-party-api/blade-hcs-api/src/main/java/org/springblade/hcs/feign/IHcsClient.java b/blade-third-party-api/blade-hcs-api/src/main/java/org/springblade/hcs/feign/IHcsClient.java index 503bc9df..e545d6ec 100644 --- a/blade-third-party-api/blade-hcs-api/src/main/java/org/springblade/hcs/feign/IHcsClient.java +++ b/blade-third-party-api/blade-hcs-api/src/main/java/org/springblade/hcs/feign/IHcsClient.java @@ -78,7 +78,7 @@ import org.springframework.web.multipart.MultipartFile; * * @author Chill */ -@FeignClient(name = "HCS", url = "${thirdParty.hcs.baseUrl}", configuration = HcsFeignClientConfig.class) +@FeignClient(name = "HCS", url = "${thirdParty.hcs.baseUrl}", configuration = HcsFeignClientConfig.class, fallbackFactory = HcsClientFallbackFactory.class) public interface IHcsClient { String PROJECT_ADD_URL = "${thirdParty.hcs.projectAddUrl:/openapi/sys-modeling/apis/data/km-project1/V1/projectApproval/add}"; diff --git a/doc/docs/hcs合同/2026-10-10-HCS接口500错误信息还原设计(Feign降级工厂).md b/doc/docs/hcs合同/2026-10-10-HCS接口500错误信息还原设计(Feign降级工厂).md new file mode 100644 index 00000000..a11b92af --- /dev/null +++ b/doc/docs/hcs合同/2026-10-10-HCS接口500错误信息还原设计(Feign降级工厂).md @@ -0,0 +1,120 @@ +# HCS 接口 500 错误信息还原设计(Feign 降级工厂) + +> 日期:2026-10-10 +> 涉及模块:`blade-third-party-api/blade-hcs-api`、`blade-service/blade-transport` +> 背景:HCS(合同合规系统)接口返回 HTTP 500 时,TMS 侧只见"无响应/响应为空",远程真实失败原因丢失,`hcs_sync_msg` 无法落库有效信息。 + +--- + +## 一、问题现象 + +真实日志(C102-1 项目立项新建推送,projectCode=LX-2026-000064): + +```text +CustomFeignLogger : <==== [IHcsClient#projectAdd(HcsProjectAddDTO)] .../km-project1/V1/projectApproval/add | Status: 500 | Body: {"success":false,"code":"errors.noRecord","msg":"地址本数据标识{\"fdName\":\"桂物物流集团\"}在系统中查找不到对应的组织机构数据!","traceId":"1k4ikrub...","errStacks":[...]} +BladeFeignFallback : BladeFeignFallback:[org.springblade.hcs.feign.IHcsClient.projectAdd] serviceId:[HCS] message:[[500 Internal Server Error] during [POST] ...] +HcsProjectSyncSupport : HCS项目立项新建失败响应:无响应,projectCode=LX-2026-000064 +``` + +现象:HCS 明确返回了业务错误(地址本组织机构不存在),但业务代码只拿到"无响应",`hcs_sync_msg` 只能落"响应为空",排查必须翻 Feign 日志。 + +## 二、根因分析 + +结合报文与 `blade-core-cloud`(4.10.0.BASE-SNAPSHOT)反编译,失败链路如下: + +``` +HCS 返回 HTTP 500(body 含真实业务错误) + → Feign 对非 2xx 抛 FeignException(body 锁在异常 contentUTF8() 中) + → BladeSentinelInvocationHandler 捕获,进入降级 + → @FeignClient 未声明 fallback/fallbackFactory + → 落到全局 org.springblade.core.cloud.feign.BladeFallbackFactory + → BladeFeignFallback 代理对非 R 返回值一律返回 null + → 调用侧 HcsProjectSyncSupport#addProject 拿到 result == null + → 打印"无响应"、抛 HcsException("...响应为空") +``` + +关键依据(javap 反编译 `BladeFeignSentinel$Builder$1` 字节码): + +1. `@FeignClient.fallback()` 有值 → 从 Feign 子上下文取该 bean,构造 `BladeSentinelInvocationHandler`; +2. 否则 `@FeignClient.fallbackFactory()` 有值 → 同样取自定义工厂(**本方案接入点**); +3. 两者皆无 → `new BladeFallbackFactory(target)` 全局兜底(即吞异常返回 null 的根因)。 + +即:**声明专属 fallbackFactory 后,全局兜底不再介入**。 + +## 三、备选方案否决记录 + +### 3.1 在调用侧捕获 HcsException —— 不可行 + +`HcsException` 是 `HcsProjectSyncSupport` 在拿到 null **之后**抛的,此时远程消息早已丢失。真正的 `FeignException` 在到达调用方之前就被 `BladeSentinelInvocationHandler → BladeFeignFallback` 吞掉并转成 null,任何 try/catch 都拦不到它——没有可捕获的异常内容。 + +### 3.2 照搬 BFI 的 commonCall + BfiExceptionTranslator 方式 —— 不可行 + +BFI 同类报错能正常拿到原因,但其成立前提与 HCS 不同: + +| 对比项 | BFI(可行) | HCS(不可行) | +| --- | --- | --- | +| 业务失败时 HTTP 状态 | **200** | **500** | +| Feign 行为 | body 正常反序列化返回 | 抛 FeignException,body 进异常对象 | +| 错误体到达 service 层? | 到达 → `BfiServiceImpl#commonCall` 手动解析 `message` 后抛 `BfiException` | **到不了**,service 层只拿到 null | + +而 `BfiExceptionTranslator` 实际是 `@RestControllerAdvice`,职责是把**我们自己 Controller 往外抛的** `BfiException` 转成 `R.fail` 给前端——它在出站 Feign 链路之外,`@ExceptionHandler` 无从触发。HCS 失败时 service 层连异常都收不到,service 层没有可写解析逻辑的位置,Rest Controller Advice 也拦不到 Feign 出站降级。 + +结论:两者错误发生在不同环节。HCS 错误体唯一能拦住的位置是 **Feign 降级层**。 + +### 3.3 关闭熔断兜底(feign.circuitbreaker.enabled=false)—— 不采纳 + +异常虽可直接抛到调用侧,但该配置全局生效会波及服务内所有 Feign 客户端,且 32 个 HCS 调用点都要补 try/catch 解析,改动面大。 + +## 四、采用方案:HcsClientFallbackFactory + +### 4.1 改动清单(共 3 处) + +| # | 文件 | 改动 | +| --- | --- | --- | +| 1 | `blade-hcs-api/.../hcs/feign/HcsClientFallbackFactory.java` | 新增降级工厂(约 120 行) | +| 2 | `blade-hcs-api/.../hcs/feign/IHcsClient.java` | `@FeignClient` 增加 `fallbackFactory = HcsClientFallbackFactory.class` | +| 3 | `blade-hcs-api/.../hcs/config/HcsFeignClientConfig.java` | 注册 `HcsClientFallbackFactory` 为 Feign 子上下文 bean(`BladeFeignSentinel` 经 `FeignClientFactory.getInstance` 取用) | + +### 4.2 降级工厂处理规则 + +`create(cause)` 返回 JDK 动态代理(24 个接口方法无需逐一实现): + +| 场景 | 处理 | +| --- | --- | +| 方法返回类型是 `HcsResult`(含双层嵌套 `HcsResult>` 外层) | 构造失败 `HcsResult`:`cause` 为 `FeignException` 时,`contentUTF8()` 取错误体——HCS 错误体与 `HcsResult` 同构(success/code/msg/traceId),`JsonUtil.parse` 直接还原远程 `msg`/`traceId`;非 JSON body 降级为异常摘要;`status` 记入 `code`(errors.http.500 样式) | +| 非 `FeignException`(熔断打开、连接超时等) | `msg` 取异常摘要,空则给 `HCS 接口调用异常(异常类名)`,`code=errors.feignFallback` | +| 非 `HcsResult` 返回类型(附件下载 `Response` 等) | 返回 null,与原全局兜底行为一致 | +| Object 基础方法(toString/hashCode/equals) | 直接反射执行 | + +### 4.3 还原后的完整链路 + +``` +HCS 500 → FeignException(错误体) + → BladeSentinelInvocationHandler → HcsClientFallbackFactory.create(cause) + → 返回 HcsResult{success=false, msg=远程msg, traceId=远程traceId} + → 各 support 现有判断 result.isSuccess()==false + → 日志:HCS项目立项新建失败响应:msg=地址本数据标识{...}在系统中查找不到对应的组织机构数据!, traceId=1k4ikrub... + → 抛 HcsException(远程msg) + → markFailed → retryCount+1 → hcs_sync_msg = 远程错误原因 + → 手动重推场景:repushProject 转 ServiceException → BladeX 全局 RestExceptionTranslator → 前端收到远程原因 +``` + +## 五、影响范围 + +- 调用点:`IHcsClient` 共 32 处调用,全部位于 `blade-transport` 的 10 个 `support/hcs` 类中,**零改动**——各处已有的 `result == null || !result.isSuccess()` 防御判断继续有效(null 分支保留为极端兜底)。 +- 成功路径:降级工厂仅在异常时被调用,正常 2xx 响应不经过它,零影响。 +- 全局兜底:仅 HCS 客户端声明了专属 fallbackFactory,其余 Feign 客户端(BFI 等)行为不变。 +- 重试策略:不变,仍由扫描任务按 `hcs_retry_count` 补偿(D7 约定,Feign 层无 Retryer)。 + +## 六、验证与回归建议 + +- 编译验证:`mvn clean compile -pl blade-third-party-api/blade-hcs-api,blade-service/blade-transport -am -DskipTests` 通过。 +- 建议回归场景: + 1. **本案例复现**:HCS 地址本不含"桂物物流集团"时推送项目立项,确认日志与 `hcs_sync_msg` 出现远程 msg 及 traceId; + 2. 修正 `thirdParty.hcs.creator-unit-name` 为 HCS 地址本准确名称后手动重推,确认成功链路不受影响; + 3. HCS 停机/连接超时场景,确认失败原因为可读的连接类摘要(而非"无响应"); + 4. 其余 HCS 接口(合同 C103、开票登记 C111 等)抽查推送失败场景,确认 msg 还原一致生效。 + +## 七、遗留事项(与本设计无关,另案处理) + +- 本案例的业务根因:`fd_creator_unit`/`fd_avail_units` 传值"桂物物流集团"在 HCS 地址本不存在,需修正 `thirdParty.hcs.creator-unit-name` 配置或由合规方在地址本新建该组织后重推。