🐛 还原MK Feign降级根因并返回可读错误

避免全局 BladeFeignFallback 将域名解析/超时等异常吞成 null,前端与日志可直接看到失败原因。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-11-09 18:04:52 +08:00
co-authored by Cursor
parent 967e44ec87
commit d43b6a2566
4 changed files with 180 additions and 11 deletions
@@ -1,9 +1,17 @@
package org.springblade.thirdparty.mk.config; package org.springblade.thirdparty.mk.config;
import feign.Request;
import feign.RequestInterceptor; import feign.RequestInterceptor;
import org.springblade.thirdparty.mk.feign.MKClientFallbackFactory;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import java.util.concurrent.TimeUnit;
/** /**
* MK Feign 客户端配置
* <p>
* 仅通过 {@link org.springframework.cloud.openfeign.FeignClient#configuration} 引用,避免拦截器全局生效。
*
* @author bfhuange * @author bfhuange
* @since 2024/12/18 * @since 2024/12/18
*/ */
@@ -14,4 +22,20 @@ public class MKFeignClientConfig {
// 空实现屏蔽 全局拦截器 BladeFeignRequestInterceptor // 空实现屏蔽 全局拦截器 BladeFeignRequestInterceptor
}; };
} }
/**
* MK 降级工厂:保留 Feign 根因(域名解析/超时/远程错误体),避免全局兜底吞成 null
*/
@Bean
public MKClientFallbackFactory mkClientFallbackFactory() {
return new MKClientFallbackFactory();
}
/**
* 超时:connect 10s / read 60s(与 HCS/BFI 对齐,避免默认约 5s 过早降级)
*/
@Bean
public Request.Options options() {
return new Request.Options(10, TimeUnit.SECONDS, 60, TimeUnit.SECONDS, true);
}
} }
@@ -18,7 +18,8 @@ import java.util.List;
* @author bfhuange * @author bfhuange
* @date 2024/9/11 * @date 2024/9/11
*/ */
@FeignClient(name = "MK", url = "${thirdParty.mk.baseUrl}", configuration = MKFeignClientConfig.class) @FeignClient(name = "MK", url = "${thirdParty.mk.baseUrl}", configuration = MKFeignClientConfig.class,
fallbackFactory = MKClientFallbackFactory.class, primary = false)
public interface IMKClient { public interface IMKClient {
/** /**
@@ -0,0 +1,128 @@
/**
* 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.thirdparty.mk.feign;
import feign.FeignException;
import org.springblade.core.tool.jackson.JsonUtil;
import org.springblade.core.tool.utils.StringUtil;
import org.springblade.thirdparty.mk.exception.MKException;
import org.springblade.thirdparty.mk.pojo.vo.MKResultVO;
import org.springblade.thirdparty.mk.pojo.vo.MKTokenVO;
import org.springframework.cloud.openfeign.FallbackFactory;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;
import java.net.SocketTimeoutException;
import java.net.UnknownHostException;
/**
* MK Feign 客户端降级工厂
* <p>
* Feign 调用失败(域名解析失败、连接超时、非 2xx 等)时,全局 BladeFeignFallback
* 对非 R 返回值一律返回 null,导致调用侧只能看到“获取token异常/响应参数:null”。
* 本工厂接管降级:{@link MKResultVO} 还原为带详细 msg 的失败结果;
* {@link MKTokenVO}、附件下载等直接抛出带根因的 {@link MKException},供前端展示。
*
* @author Chill
*/
public class MKClientFallbackFactory implements FallbackFactory<IMKClient> {
@Override
public IMKClient create(Throwable cause) {
return (IMKClient) Proxy.newProxyInstance(IMKClient.class.getClassLoader(), new Class<?>[]{IMKClient.class},
(Object proxy, Method method, Object[] args) -> {
if (method.getDeclaringClass() != IMKClient.class) {
return method.invoke(this, args);
}
String detail = resolveFallbackMessage(cause);
if (MKResultVO.class.isAssignableFrom(method.getReturnType())) {
return buildFailResult(cause, detail);
}
// getToken / downloadFile 等无业务失败体可还原,直接抛出可读异常
throw new MKException("调用MK接口[" + method.getName() + "]失败:" + detail, cause);
});
}
/**
* 构造失败的 MKResultVO,优先还原远程错误体中的 msg/traceId
*/
private MKResultVO<?> buildFailResult(Throwable cause, String detail) {
MKResultVO<Object> result = new MKResultVO<>();
result.setSuccess(false);
if (cause instanceof FeignException feignException) {
result.setCode("errors.http." + feignException.status());
MKResultVO<?> remote = parseRemoteResult(feignException.contentUTF8());
if (remote != null && StringUtil.isNotBlank(remote.getMsg())) {
result.setMsg(remote.getMsg());
result.setTraceId(remote.getTraceId());
return result;
}
} else {
result.setCode("errors.feignFallback");
}
result.setMsg(detail);
return result;
}
/**
* 错误体反序列化为 MKResultVO(非 JSON 或解析失败时返回 null)
*/
private MKResultVO<?> parseRemoteResult(String body) {
if (StringUtil.isBlank(body) || !body.trim().startsWith("{")) {
return null;
}
try {
return JsonUtil.parse(body, MKResultVO.class);
} catch (Exception exception) {
return null;
}
}
/**
* 兜底消息:优先识别域名/超时等网络类根因,其次用异常摘要
*/
private String resolveFallbackMessage(Throwable cause) {
Throwable current = cause;
while (current != null) {
if (current instanceof UnknownHostException) {
return "MK域名解析失败(" + nullToEmpty(current.getMessage()) + "),请检查容器DNS或hosts";
}
if (current instanceof SocketTimeoutException) {
return "MK接口连接/读取超时(" + nullToEmpty(current.getMessage()) + ")";
}
current = current.getCause();
}
if (cause != null && StringUtil.isNotBlank(cause.getMessage())) {
return cause.getMessage();
}
String type = cause == null ? "Unknown" : cause.getClass().getSimpleName();
return "MK接口调用异常(" + type + ")";
}
private String nullToEmpty(String value) {
return value == null ? "" : value;
}
}
@@ -160,12 +160,12 @@ public class MKServiceImpl implements IMKService {
} }
log.error("调用mk接口 {}异常响应参数:{}", type, JSONUtil.toJsonStr(result)); log.error("调用mk接口 {}异常响应参数:{}", type, JSONUtil.toJsonStr(result));
String message = getMessage(result); String message = getMessage(result);
throw new MKException("调用mk接口" + type + "异常 " + message); throw new MKException("调用mk接口" + type + "异常" + (StringUtil.isBlank(message) ? "" : ":" + message));
} catch (MKException e) { } catch (MKException e) {
throw e; throw e;
} catch (Exception e) { } catch (Exception e) {
log.error("调用mk接口 {}异常", type, e); log.error("调用mk接口 {}异常", type, e);
throw new MKException("调用mk接口" + type + "异常"); throw new MKException("调用mk接口" + type + "异常:" + resolveExceptionMessage(e), e);
} }
} }
@@ -515,12 +515,28 @@ public class MKServiceImpl implements IMKService {
return accessToken; return accessToken;
} }
log.error("调用mk接口 获取token 异常响应参数:{}", JSONUtil.toJsonStr(tokenResult)); log.error("调用mk接口 获取token 异常响应参数:{}", JSONUtil.toJsonStr(tokenResult));
throw new MKException("调用mk接口获取token异常"); throw new MKException(tokenResult == null
? "调用mk接口获取token异常:无响应(Feign已降级)"
: "调用mk接口获取token异常:errcode=" + tokenResult.getErrCode());
} catch (MKException e) { } catch (MKException e) {
throw e; throw e;
} catch (Exception e) { } catch (Exception e) {
log.error("调用mk接口 获取token 异常", e); log.error("调用mk接口 获取token 异常", e);
throw new MKException("调用mk接口获取token异常"); throw new MKException("调用mk接口获取token异常:" + resolveExceptionMessage(e), e);
} }
} }
/**
* 异常摘要:优先 cause 链上的可读 message
*/
private String resolveExceptionMessage(Throwable throwable) {
Throwable current = throwable;
while (current != null) {
if (StringUtil.isNotBlank(current.getMessage())) {
return current.getMessage();
}
current = current.getCause();
}
return throwable == null ? "未知错误" : throwable.getClass().getSimpleName();
}
} }