diff --git a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/config/BfiMockConfig.java b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/config/BfiMockConfig.java new file mode 100644 index 00000000..fcf24015 --- /dev/null +++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/config/BfiMockConfig.java @@ -0,0 +1,64 @@ +/** + * 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.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 配置 + *

+ * 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 realClientProvider) { + return BfiClientMock.create(properties.getMockDir(), realClientProvider); + } +} diff --git a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/config/BfiProperties.java b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/config/BfiProperties.java index dd979537..fd0ef5de 100644 --- a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/config/BfiProperties.java +++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/config/BfiProperties.java @@ -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"; } diff --git a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/mock/BfiClientMock.java b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/mock/BfiClientMock.java new file mode 100644 index 00000000..795d324c --- /dev/null +++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/mock/BfiClientMock.java @@ -0,0 +1,176 @@ +/** + * 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.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 客户端) + *

+ * 单入口总开关,方法级混合:每个 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 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 realClientProvider) { + return (IBfiClient) Proxy.newProxyInstance(IBfiClient.class.getClassLoader(), + new Class[]{IBfiClient.class}, new BfiClientMock(mockDir, realClientProvider)); + } + + private BfiClientMock(String mockDir, ObjectProvider 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; + } +} diff --git a/doc/nacos/third-party-api.yaml b/doc/nacos/third-party-api.yaml index 6e390a3d..41dd3399 100644 --- a/doc/nacos/third-party-api.yaml +++ b/doc/nacos/third-party-api.yaml @@ -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 共用,未配置使用代码默认值):