feat: 新增BFI客户端方法级文件桩Mock

thirdParty.bfi.mock-enabled=true 时经 BfiMockConfig 注册 @Primary 的 IBfiClient 代理,{mockDir}/{方法名}.json 有桩走桩、无桩透传真实 Feign,调用侧零改动;桩文件每次调用现读,解析失败返回失败结果而非 null;invoiceDownload 返回 feign.Response 始终透传。
This commit is contained in:
刘泉佳
2026-10-20 17:41:13 +08:00
parent 44e3ecfaa2
commit a8fa21c13e
4 changed files with 251 additions and 1 deletions
@@ -0,0 +1,64 @@
/**
* 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.bfi.config;
import org.springblade.thirdparty.bfi.feign.IBfiClient;
import org.springblade.thirdparty.bfi.mock.BfiClientMock;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
/**
* BFI 客户端 Mock 配置
* <p>
* thirdParty.bfi.mock-enabled=true(Nacos 单入口总开关)时注册 @Primary 的 mock 实现,
* 覆盖同类型 Feign 客户端 bean,{@link org.springblade.thirdparty.bfi.service.impl.BfiServiceImpl} 注入的 {@link IBfiClient}
* 无感知切换为"有桩用桩、没桩走接口":{mockDir}/{方法名}.json 有桩走本地 JSON 桩,
* 无桩透传真实 Feign 接口({@link BfiClientMock},目录经 thirdParty.bfi.mockDir 配置),调用侧零改动。
* 仅本地自测/演示环境开启,生产必须保持 false。
*
* @author Chill
*/
@Configuration
public class BfiMockConfig {
/**
* mock 版 BFI 客户端(@Primary 优先于 Feign 客户端注入)
*
* @param properties BFI 配置(取桩文件目录 mockDir)
* @param realClientProvider 真实客户端候选
* @return mock 客户端
*/
@Bean
@Primary
// @ConditionalOnProperty 不支持宽松绑定,key 必须与 Nacos 实际键完全一致(本仓库 Nacos 为 thirdParty.bfi.* 驼峰根)
@ConditionalOnProperty(name = "thirdParty.bfi.mock-enabled", havingValue = "true")
public IBfiClient bfiClientMock(BfiProperties properties, ObjectProvider<IBfiClient> realClientProvider) {
return BfiClientMock.create(properties.getMockDir(), realClientProvider);
}
}
@@ -65,7 +65,12 @@ public class BfiProperties {
*/
private String language = "zh_CN";
/**
* 是否启用 mock 模式(开发调试用)
* 是否启用 mock 模式(开发调试用):开启后 IBfiClient 走本地桩目录(有桩用桩、无桩透传真实接口,见 BfiClientMock)
*/
private boolean mockEnabled = false;
/**
* mock 客户端桩文件目录,文件名为接口方法名:{mockDir}/{方法名}.json,如 payApplyQuery.json;
* 仅 mock-enabled=true 生效,文件每次调用现读,改文件即生效
*/
private String mockDir = "./mock/bfi";
}
@@ -0,0 +1,176 @@
/**
* 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.bfi.mock;
import com.fasterxml.jackson.databind.JavaType;
import com.fasterxml.jackson.databind.type.TypeFactory;
import feign.Response;
import lombok.extern.slf4j.Slf4j;
import org.springblade.core.tool.jackson.JsonUtil;
import org.springblade.thirdparty.bfi.exception.BfiException;
import org.springblade.thirdparty.bfi.feign.IBfiClient;
import org.springblade.thirdparty.bfi.pojo.vo.BfiResultVO;
import org.springframework.beans.factory.ObjectProvider;
import java.io.InputStream;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;
import java.nio.file.Files;
import java.nio.file.Path;
/**
* BFI 客户端本地 Mock(thirdParty.bfi.mock-enabled=true 时经 BfiMockConfig 注册为 @Primary,包装真实 Feign 客户端)
* <p>
* 单入口总开关,方法级混合:每个 IBfiClient 方法对应一个外部目录桩文件 {mockDir}/{方法名}.json(thirdParty.bfi.mockDir 配置),
* 有桩即走 mock(内容即金蝶真实响应报文,含 BfiResultVO/BfiOpenApiResultVO 包装,按方法返回泛型反序列化,调用侧无感知),
* 无桩自动透传真实 Feign 接口,实现"有桩用桩、没桩走接口"。
* 桩文件每次调用现读,改文件即生效无需重启;桩解析失败返回失败的 BfiResultVO,不静默吞错。
* 文件下载接口 invoiceDownload 返回 feign.Response,无法按 JSON 桩反序列化,始终透传真实接口。
*
* @author Chill
*/
@Slf4j
public class BfiClientMock implements InvocationHandler {
/**
* 桩文件后缀(文件名即接口方法名)
*/
private static final String MOCK_FILE_SUFFIX = ".json";
/**
* 桩文件目录
*/
private final Path mockDir;
/**
* 真实客户端候选(排除 mock 自身,运行期首次透传时解析并缓存)
*/
private final ObjectProvider<IBfiClient> realClientProvider;
/**
* 真实 Feign 客户端缓存(double-checked locking)
*/
private volatile IBfiClient realClient;
/**
* 构建 mock 版 IBfiClient
*
* @param mockDir 桩文件目录(third-party.bfi.mock-dir)
* @param realClientProvider 真实客户端候选(容器内 IBfiClient 全量,含 mock 自身,解析时排除)
* @return mock 客户端
*/
public static IBfiClient create(String mockDir, ObjectProvider<IBfiClient> realClientProvider) {
return (IBfiClient) Proxy.newProxyInstance(IBfiClient.class.getClassLoader(),
new Class<?>[]{IBfiClient.class}, new BfiClientMock(mockDir, realClientProvider));
}
private BfiClientMock(String mockDir, ObjectProvider<IBfiClient> realClientProvider) {
this.mockDir = Path.of(mockDir);
this.realClientProvider = realClientProvider;
}
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
// Object 基础方法(toString/hashCode/equals)直接执行
if (method.getDeclaringClass() != IBfiClient.class) {
return method.invoke(this, args);
}
// 文件下载返回 feign.Response,无法按 JSON 桩反序列化,始终透传
if (method.getReturnType() == Response.class) {
return this.invokeReal(method, args, proxy);
}
return this.loadMock(method, args, proxy);
}
/**
* 有桩读桩,无桩透传真实接口
*
* @param method 接口方法
* @param args 入参
* @param proxy mock 代理自身(解析真实客户端时排除)
* @return 桩数据或真实接口响应
*/
private Object loadMock(Method method, Object[] args, Object proxy) throws Throwable {
Path mockFile = this.mockDir.resolve(method.getName() + MOCK_FILE_SUFFIX);
if (!Files.exists(mockFile)) {
log.debug("BFI mock 桩文件缺失,透传真实接口:{}", method.getName());
return this.invokeReal(method, args, proxy);
}
// 外部桩每次调用现读,改文件即生效
try (InputStream inputStream = Files.newInputStream(mockFile)) {
JavaType javaType = TypeFactory.defaultInstance().constructType(method.getGenericReturnType());
return JsonUtil.getInstance().readValue(inputStream, javaType);
} catch (Exception exception) {
log.error("BFI mock 桩文件解析失败:{}", mockFile.toAbsolutePath(), exception);
return this.failResult("BFI mock 桩文件解析失败:" + mockFile.toAbsolutePath());
}
}
/**
* 透传真实 Feign 客户端(首次调用时懒解析,排除 mock 代理自身,异常原样抛出)
*
* @param method 接口方法
* @param args 入参
* @param proxy mock 代理自身
* @return 真实接口响应
* @throws Throwable 真实调用异常(Feign 降级/业务异常原样透传)
*/
private Object invokeReal(Method method, Object[] args, Object proxy) throws Throwable {
IBfiClient real = this.realClient;
if (real == null) {
synchronized (this) {
if (this.realClient == null) {
this.realClient = this.realClientProvider.stream()
.filter(client -> client != proxy)
.findFirst()
.orElseThrow(() -> new BfiException("BFI mock 桩缺失且未找到真实 BFI 客户端:" + method.getName()));
}
real = this.realClient;
}
}
try {
return method.invoke(real, args);
} catch (InvocationTargetException exception) {
throw exception.getCause();
}
}
/**
* 构造失败的 BfiResultVO(桩解析异常时兜底,避免调用侧拿到 null)
*
* @param msg 失败原因
* @return 失败结果
*/
private BfiResultVO<?> failResult(String msg) {
BfiResultVO<?> result = new BfiResultVO<>();
result.setStatus(false);
result.setErrorCode("errors.mock");
result.setMessage(msg);
return result;
}
}
+5
View File
@@ -47,6 +47,11 @@ thirdParty:
accountId: ${BFI_ACCOUNT_ID:}
# 语言字串,默认 zh_CN
language: ${BFI_LANGUAGE:zh_CN}
# BFI 客户端本地 Mock 开关(联调自测用,生产必须 false):
# true 时 IBfiClient 有桩用桩({mockDir}/{接口方法名}.json)、无桩透传真实 Feign 接口,调用侧零改动
mock-enabled: ${BFI_MOCK_ENABLED:false}
# Mock 桩文件目录:文件名为接口方法名,如 getToken.json、payApplyQuery.json;文件每次调用现读,改文件即生效
mockDir: ${BFI_MOCK_DIR:./mock/bfi}
# 数据来源系统编码(推送金蝶公共参数:客商、付款申请等统一使用),默认 WLTMS(运输TMS)
srcSys: ${BFI_SRC_SYS:WLTMS}
# 应付/应收/收票单分录物料映射(A102-2/A302-2/A303-2 共用,未配置使用代码默认值):