:feat: HCS接口500错误信息还原(Feign降级工厂还原远程msg/traceId,落库hcs_sync_msg)

This commit is contained in:
刘泉佳
2026-10-10 20:16:39 +08:00
parent 9ba9fe4dd8
commit 7e6970277c
4 changed files with 246 additions and 1 deletions
@@ -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 接口说明)
*/
@@ -0,0 +1,116 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license
* from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party
* without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for such purposes. Copyright of this software
* remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes.
* <p>
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
* not liable for any claims arising from secondary or illegal development.
* <p>
* 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 客户端降级工厂
* <p>
* HCS 返回非 2xx(如 500)时 Feign 抛 {@link FeignException},全局 BladeFeignFallback
* 对 HcsResult 返回值只会返回 null,导致远程错误信息丢失(调用侧只见"无响应/响应为空")。
* 本工厂在降级时将错误体还原为失败的 {@link HcsResult}:HCS 错误体与 HcsResult 同构
* (success/code/msg/traceId),直接反序列化取回远程 msg 与 traceId,
* 供调用侧日志与 hcs_sync_msg 等失败原因落库使用。
* <p>
* 通过 {@link IHcsClient} 的 fallbackFactory 声明并注册于 {@link org.springblade.hcs.config.HcsFeignClientConfig},
* 所有 HCS 接口(含双层嵌套 HcsResult 外层)统一生效,调用侧零改动。
*
* @author Chill
*/
public class HcsClientFallbackFactory implements FallbackFactory<IHcsClient> {
@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() + ")";
}
}
@@ -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}";
@@ -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<T>>` 外层) | 构造失败 `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` 配置或由合规方在地址本新建该组织后重推。