From ba02133449206cf8d90f6b304b26e3fe9b58c2da Mon Sep 17 00:00:00 2001 From: dzc <2388224167@qq.com> Date: Tue, 10 Nov 2026 11:32:21 +0800 Subject: [PATCH] =?UTF-8?q?feat:=E6=94=AF=E4=BB=98=E5=AE=9D=E6=94=AF?= =?UTF-8?q?=E4=BB=98=E8=AF=81=E4=B9=A6=E6=A8=A1=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pjcloud-thirdparty/docs/aggregate-h5.md | 46 +- .../config/AlipayClientContext.java | 88 ++++ .../config/AlipayClientSupport.java | 142 ++++++ .../thirdparty/config/AppPlusPayConfig.java | 30 +- .../ThirdAggregateAliPayServiceImpl.java | 51 +- .../ThirdAppPlusAliPayServiceImpl.java | 93 ++-- .../impl/appplus/ThirdAppPlusServiceImpl.java | 7 +- .../pay/ThirdMchFzRelationServiceImpl.java | 27 +- .../config/AlipayClientSupportTest.java | 474 ++++++++++++++++++ .../ThirdAggregateAliPayServiceImplTest.java | 20 +- .../ThirdAppPlusAliPayServiceImplTest.java | 24 +- .../ThirdMchFzRelationServiceImplTest.java | 22 +- 12 files changed, 867 insertions(+), 157 deletions(-) create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/config/AlipayClientContext.java create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/config/AlipayClientSupport.java create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/config/AlipayClientSupportTest.java diff --git a/pjcloud-thirdparty/docs/aggregate-h5.md b/pjcloud-thirdparty/docs/aggregate-h5.md index ba00975bc..839f2b809 100644 --- a/pjcloud-thirdparty/docs/aggregate-h5.md +++ b/pjcloud-thirdparty/docs/aggregate-h5.md @@ -10,6 +10,7 @@ app-plus: # 网页/移动应用:聚合支付宝 WAP 签名及付款通知;原 App 继续使用此组。 ali-pay-config: app-id: ${ALIPAY_WEB_APP_ID} + sign-mode: KEY # 省略时默认 KEY;证书模式示例见下文 app-private-key: ${ALIPAY_WEB_APP_PRIVATE_KEY} alipay-public-key: ${ALIPAY_WEB_PUBLIC_KEY} base-callback-url: ${THIRD_PUBLIC_BASE_URL} # 网关公网地址,包含实际 /api 子路径 @@ -18,6 +19,7 @@ app-plus: # 小程序及历史小程序交易凭据:当前 H5 新下单不读取此组;已有相关流水时保留原凭据。 ali-pay-mini-config: app-id: ${ALIPAY_MINI_APP_ID} + sign-mode: KEY # 每组应用独立选择 KEY 或 CERT app-private-key: ${ALIPAY_MINI_APP_PRIVATE_KEY} alipay-public-key: ${ALIPAY_MINI_PUBLIC_KEY} base-callback-url: ${THIRD_PUBLIC_BASE_URL} @@ -26,6 +28,7 @@ app-plus: # 第三方应用独立预留,当前不参与 App/聚合新下单;不用时整组省略。 # ali-pay-third-party-config: # app-id: ${ALIPAY_THIRD_PARTY_APP_ID} + # sign-mode: KEY # app-private-key: ${ALIPAY_THIRD_PARTY_APP_PRIVATE_KEY} # alipay-public-key: ${ALIPAY_THIRD_PARTY_PUBLIC_KEY} # base-callback-url: ${THIRD_PUBLIC_BASE_URL} @@ -43,7 +46,7 @@ app-plus: `h5-base-url` 只接受 HTTPS 基础地址,不能带查询参数、Hash 或用户信息。可以包含实际部署子路径;H5 的路由 base 需对应配置。 -支付宝按网页/移动应用、小程序、第三方应用分类保存配套 AppID、应用私钥和支付宝公钥。新支付宝聚合付款使用现有 `ali-pay-config` 的 `alipay.trade.wap.pay` 手机网站支付能力,产品码为 `QUICK_WAP_WAY`,不再先取网页 OAuth 身份,也不调用 `alipay.trade.create`。网页/移动应用需要已开通手机网站支付及实际直付通商户收单权限,切换凭据不会自动开通产品。没有新增 `ali-pay-h5-config`。原 App 默认配置与 `app.pay` 路径保持兼容。聚合支付宝旧 OAuth、`alipay.trade.create` 和 H5 `tradeNO` 调起代码已删除。 +支付宝按网页/移动应用、小程序、第三方应用分类保存配套 AppID、应用私钥及支付宝公钥或证书。新支付宝聚合付款使用现有 `ali-pay-config` 的 `alipay.trade.wap.pay` 手机网站支付能力,产品码为 `QUICK_WAP_WAY`,不再先取网页 OAuth 身份,也不调用 `alipay.trade.create`。网页/移动应用需要已开通手机网站支付及实际直付通商户收单权限,切换凭据不会自动开通产品。没有新增 `ali-pay-h5-config`。原 App 默认配置与 `app.pay` 路径保持兼容。聚合支付宝旧 OAuth、`alipay.trade.create` 和 H5 `tradeNO` 调起代码已删除。 新支付单在已有 `mch_pay_setting_json` 中保存网页应用的非敏感 `alipayAppId`,查单、关单、退款、退款查询及结算/分账按这个 AppID 选择原应用;支付与退款回调先按已登记 AppID 验签,再校验是否与支付单一致。历史小程序流水仍按其快照选择原小程序凭据;旧快照没有该标识时继续使用原网页/移动应用。只启用微信的商户不要求支付宝配置。更新凭据或切换下单应用后需重启 third 的全部实例。本次不增加数据库字段,不改写历史订单。 @@ -53,6 +56,47 @@ H5 只使用项目根目录一个 `.env`,变量命名沿用参考 App:开发 后端仅为配置的 H5 精确 Origin 开放匿名商户、创建、上下文和状态 JSON 接口的跨域访问,不开放凭据跨域;其他接口保持既有安全规则。 +### 支付宝密钥与证书模式 + +三组应用分别支持 `KEY` 和 `CERT`,未提供 `sign-mode` 时使用 `KEY`。上述配置展示普通公钥方式,网页/App 应用切换证书时采用以下配置。小程序及第三方应用使用各自配置组的同名字段,允许三组应用混用两种模式。 + +```yaml +app-plus: + pay: + ali-pay-config: + app-id: ${ALIPAY_WEB_APP_ID} + sign-mode: CERT + app-private-key: ${ALIPAY_WEB_APP_PRIVATE_KEY} + app-cert-path: ${ALIPAY_WEB_APP_CERT_PATH} + alipay-public-cert-path: ${ALIPAY_WEB_PUBLIC_CERT_PATH} + alipay-root-cert-path: ${ALIPAY_WEB_ROOT_CERT_PATH} + # 过渡期保留原支付宝公钥,通知证书验签失败后使用此公钥再次校验。 + # 全新证书应用或过渡结束后可省略本项。 + alipay-public-key: ${ALIPAY_WEB_PUBLIC_KEY:} + base-callback-url: ${THIRD_PUBLIC_BASE_URL} + pay-callback-url: /third/third-app-pay/app/pay-callback + refund-callback-url: /third/third-app-pay/app/refund-callback +``` + +| 配置项 | KEY 要求 | CERT 要求 | +| --- | --- | --- | +| `app-id` | 必填,当前应用标识 | 必填,当前应用标识 | +| `app-private-key` | 必填,应用私钥 | 必填,与应用公钥证书配套的私钥 | +| `alipay-public-key` | 必填,支付宝公钥 | 可选,当前应用原公钥用于过渡期通知 | +| `app-cert-path` | 可省略 | 必填,应用公钥证书绝对路径 | +| `alipay-public-cert-path` | 可省略 | 必填,支付宝公钥证书绝对路径 | +| `alipay-root-cert-path` | 可省略 | 必填,支付宝根证书绝对路径,可含多个根证书 | + +三份证书采用 PEM 格式,并以只读文件挂载到每个 thirdparty 容器或服务器。例如应用证书挂载至 `/opt/certs/alipay/appCertPublicKey.crt`,配置项填写容器内部绝对路径。运行用户须具备目录访问和证书读取权限,密钥和证书材料由部署环境管理。 + +统一组件按已登记 AppID 延迟初始化,在同一次初始化中读取证书内容并固定客户端、模式和通知公钥。配置模式、私钥、公钥或证书文件更新后重启全部 thirdparty 实例,新的进程重新读取凭据。证书缺失、路径不可读或内容无法解析时反馈对应配置错误,初始化失败的实例可以在修正配置或文件后重试。 + +支付请求使用 RSA2、UTF-8 和 JSON。服务端支付、查询、退款、结算分账、商户资料及图片请求按模式执行普通或证书接口。App 沿用 `sdkExecute`,WAP 沿用 `pageExecute(request, "GET")`,应用证书及根证书序列号由 SDK 自动填入本地签名参数。 + +支付、退款及订阅通知按已登记 `app_id` 选择验签凭据。CERT 优先使用固定证书公钥,签名不匹配且配置了原公钥时再执行过渡期校验。每次验签使用独立参数副本,原始签名字段保留用于后续处理。配置和证书初始化失败按异常反馈。支付单固定应用、商户、金额和重复通知的业务检查继续执行。 + +发布先部署兼容现有 KEY 配置的代码,再逐应用完成支付宝开放平台的证书配置、部署三份文件、设置 `sign-mode: CERT` 并重启全部实例。切换期间保留原支付宝公钥,历史通知处理完成后可移除该项并再次重启。集中联调应覆盖支付及通知、查单、关闭和撤销、退款及退款查询、结算分账、商户资料与图片上传,并检查历史订单仍使用其固定 AppID。 + ## 平台配置 - 微信沿用服务商统一公众号 OAuth 与 JSAPI 下单配置,确认公众号网页授权域名对应现有后端 OAuth 回调域名。 diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/config/AlipayClientContext.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/config/AlipayClientContext.java new file mode 100644 index 000000000..614654e31 --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/config/AlipayClientContext.java @@ -0,0 +1,88 @@ +package com.bsd.pjcloud.thirdparty.config; + +import cn.hutool.core.util.StrUtil; +import com.alipay.api.AlipayApiException; +import com.alipay.api.AlipayClient; +import com.alipay.api.AlipayRequest; +import com.alipay.api.AlipayResponse; +import com.alipay.api.internal.util.AlipaySignature; + +import java.util.HashMap; +import java.util.Map; + +/** + * 一个支付宝应用在本次进程生命周期内固定的 SDK 客户端、签名模式及通知验签凭据。 + * 请求执行与模式保存在同一快照中,避免更新配置后用旧客户端执行另一种模式的请求。 + */ +public final class AlipayClientContext { + + /** 已初始化且可复用的支付宝 SDK 客户端,由统一支持组件创建。 */ + private final AlipayClient sdkClient; + + /** 创建客户端时确定的签名模式,后续配置变更通过重启生效。 */ + private final AppPlusPayConfig.AliPaySignMode signMode; + + /** 当前通知验签公钥,CERT 模式从首次初始化读取的支付宝证书内容提取。 */ + private final String verificationPublicKey; + + /** CERT 模式明确保留的旧支付宝公钥,空值表示仅采用当前证书验签。 */ + private final String legacyPublicKey; + + /** + * 固定同一应用的 SDK 与验签凭据,调用前由支持组件完成配置和证书校验。 + */ + AlipayClientContext(AlipayClient sdkClient, AppPlusPayConfig.AliPaySignMode signMode, String verificationPublicKey, String legacyPublicKey) { + this.sdkClient = sdkClient; + this.signMode = signMode; + this.verificationPublicKey = verificationPublicKey; + this.legacyPublicKey = legacyPublicKey; + } + + /** + * 发起服务端请求,按固定模式选择普通或证书执行接口,保留请求中的业务及授权参数。 + * SDK、网络异常交回业务层按原有查单、重试和幂等规则处理。 + */ + public T execute(AlipayRequest request) throws AlipayApiException { + if (signMode == AppPlusPayConfig.AliPaySignMode.CERT) { + return sdkClient.certificateExecute(request); + } + return sdkClient.execute(request); + } + + /** 本地生成 App 支付签名参数;证书序列号由 SDK 从固定证书配置自动填充。 */ + public T sdkExecute(AlipayRequest request) throws AlipayApiException { + return sdkClient.sdkExecute(request); + } + + /** 本地生成网页支付地址或表单,httpMethod 沿用调用方的 GET/POST 语义。 */ + public T pageExecute(AlipayRequest request, String httpMethod) throws AlipayApiException { + return sdkClient.pageExecute(request, httpMethod); + } + + /** + * 验证本应用通知,证书签名不匹配时可使用明确保留的旧公钥再次校验。 + * 每次交给 SDK 独立参数副本,保留原始 sign、sign_type 供后续验证及业务处理。 + */ + boolean verifySignature(Map params) { + if (params == null || StrUtil.isBlank(params.get("sign"))) { + return false; + } + if (verifyWithPublicKey(params, verificationPublicKey)) { + return true; + } + return signMode == AppPlusPayConfig.AliPaySignMode.CERT && StrUtil.isNotBlank(legacyPublicKey) + && verifyWithPublicKey(params, legacyPublicKey); + } + + /** + * 使用可信配置中的公钥执行 RSA2 验签,签名内容或长度异常均按本次验签失败处理。 + * 配置及证书初始化在调用前完成,其异常不会进入过渡期补充验签。 + */ + private boolean verifyWithPublicKey(Map params, String publicKey) { + try { + return AlipaySignature.rsaCheckV1(new HashMap<>(params), publicKey, "UTF-8", "RSA2"); + } catch (AlipayApiException exception) { + return false; + } + } +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/config/AlipayClientSupport.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/config/AlipayClientSupport.java new file mode 100644 index 000000000..13895ea3c --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/config/AlipayClientSupport.java @@ -0,0 +1,142 @@ +package com.bsd.pjcloud.thirdparty.config; + +import cn.hutool.core.bean.BeanUtil; +import cn.hutool.core.util.StrUtil; +import com.alipay.api.AlipayApiException; +import com.alipay.api.AlipayConfig; +import com.alipay.api.DefaultAlipayClient; +import com.alipay.api.internal.util.AntCertificationUtil; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.InvalidPathException; +import java.nio.file.Path; +import java.security.cert.CertificateException; +import java.security.cert.CertificateFactory; +import java.util.Base64; +import java.util.HashMap; +import java.util.Map; + +/** + * thirdparty 支付宝应用的统一初始化、配置校验及通知验签入口。 + * 三组已登记应用分别延迟初始化,成功后固定客户端与凭据,更新配置及证书须重启全部实例。 + */ +@Component +@RequiredArgsConstructor +public class AlipayClientSupport { + + /** 已登记的网页/App、小程序及第三方应用配置,来源于 app-plus.pay。 */ + private final AppPlusPayConfig appPlusPayConfig; + + /** 按已登记 AppID 保存成功初始化的快照,仅在同步获取方法中读写,数量受三组配置限制。 */ + private final Map applicationClients = new HashMap<>(); + + /** + * 校验指定模式的必填凭据;实际证书文件只在客户端首次初始化时读取和解析。 + * 供支付前置检查复用,CERT 模式的旧支付宝公钥为可选项。 + */ + public static void validateConfiguration(AppPlusPayConfig.AliPayConfig config) { + if (config == null || StrUtil.hasBlank(config.getAppId(), config.getAppPrivateKey())) { + throw new IllegalStateException("支付宝应用配置不完整,请配置 app-id 和 app-private-key"); + } + if (config.getSignMode() == null) { + throw new IllegalStateException("支付宝签名模式不能为空,请配置 KEY 或 CERT"); + } + if (config.getSignMode() == AppPlusPayConfig.AliPaySignMode.KEY && StrUtil.isBlank(config.getAlipayPublicKey())) { + throw new IllegalStateException("支付宝 KEY 模式缺少 alipay-public-key"); + } + if (config.getSignMode() == AppPlusPayConfig.AliPaySignMode.CERT + && StrUtil.hasBlank(config.getAppCertPath(), config.getAlipayPublicCertPath(), config.getAlipayRootCertPath())) { + throw new IllegalStateException("支付宝 CERT 模式须配置 app-cert-path、alipay-public-cert-path 和 alipay-root-cert-path"); + } + } + + /** + * 获取已登记应用的固定运行快照;未知、重复 AppID 或非登记配置拒绝使用。 + * 同步初始化保证并发首次访问只发布一个完整实例,初始化失败时允许修正后重试。 + */ + public synchronized AlipayClientContext getClient(AppPlusPayConfig.AliPayConfig config) throws AlipayApiException { + String appId = config == null ? null : config.getAppId(); + if (StrUtil.isBlank(appId) || appPlusPayConfig.findAliPayConfig(appId) != config) { + throw new IllegalStateException("支付宝应用未唯一登记,请检查对应配置组的 app-id"); + } + AlipayClientContext client = applicationClients.get(appId); + if (client == null) { + // 初始化全程使用独立配置副本,使 SDK 参数、模式和通知公钥属于同一个运行快照。 + AppPlusPayConfig.AliPayConfig snapshot = BeanUtil.copyProperties(config, AppPlusPayConfig.AliPayConfig.class); + snapshot.setAppId(appId); + validateConfiguration(snapshot); + client = createClient(snapshot); + applicationClients.put(appId, client); + } + return client; + } + + /** + * 使用明确选定的应用验证通知;配置异常交回调用方,不能将初始化失败当作旧公钥匹配。 + */ + public boolean verifyCallback(AppPlusPayConfig.AliPayConfig config, Map params) throws AlipayApiException { + return config != null && params != null && config.getAppId() != null + && config.getAppId().equals(params.get("app_id")) && getClient(config).verifySignature(params); + } + + /** + * 以指定应用创建 SDK 和通知验签快照;CERT 使用同一批读取内容初始化两者,避免文件轮换时错配。 + * 旧支付宝公钥仅保存到通知快照,CERT 的 SDK 由证书建立响应验签信任。 + */ + private AlipayClientContext createClient(AppPlusPayConfig.AliPayConfig config) throws AlipayApiException { + AlipayConfig sdkConfig = new AlipayConfig(); + sdkConfig.setServerUrl("https://openapi.alipay.com/gateway.do"); + sdkConfig.setAppId(config.getAppId()); + sdkConfig.setPrivateKey(config.getAppPrivateKey()); + sdkConfig.setFormat("json"); + sdkConfig.setCharset("UTF-8"); + sdkConfig.setSignType("RSA2"); + String verificationPublicKey = config.getAlipayPublicKey(); + String legacyPublicKey = null; + if (config.getSignMode() == AppPlusPayConfig.AliPaySignMode.CERT) { + String appCertificate = readCertificate(config.getAppCertPath(), "应用公钥证书"); + String alipayCertificate = readCertificate(config.getAlipayPublicCertPath(), "支付宝公钥证书"); + String rootCertificate = readCertificate(config.getAlipayRootCertPath(), "支付宝根证书"); + sdkConfig.setAppCertPath(config.getAppCertPath()); + sdkConfig.setAppCertContent(appCertificate); + sdkConfig.setAlipayPublicCertPath(config.getAlipayPublicCertPath()); + sdkConfig.setAlipayPublicCertContent(alipayCertificate); + sdkConfig.setRootCertPath(config.getAlipayRootCertPath()); + sdkConfig.setRootCertContent(rootCertificate); + verificationPublicKey = Base64.getEncoder().encodeToString(AntCertificationUtil.getCertFromContent(alipayCertificate).getPublicKey().getEncoded()); + legacyPublicKey = config.getAlipayPublicKey(); + } else { + sdkConfig.setAlipayPublicKey(config.getAlipayPublicKey()); + } + return new AlipayClientContext(new DefaultAlipayClient(sdkConfig), config.getSignMode(), verificationPublicKey, legacyPublicKey); + } + + /** + * 从服务器绝对路径一次读取 PEM 证书并确认包含可解析的 X.509 证书,根证书支持证书集合。 + * 错误仅描述证书类型和操作,避免把文件内容或底层异常中的凭据传出。 + */ + private String readCertificate(String configuredPath, String certificateLabel) { + try { + Path path = Path.of(configuredPath); + if (!path.isAbsolute() || !Files.isRegularFile(path) || !Files.isReadable(path)) { + throw new IllegalStateException(certificateLabel + "须为可读取的本地文件绝对路径"); + } + String content = Files.readString(path, StandardCharsets.UTF_8); + try (ByteArrayInputStream input = new ByteArrayInputStream(content.getBytes(StandardCharsets.UTF_8))) { + if (CertificateFactory.getInstance("X.509").generateCertificates(input).isEmpty()) { + throw new IllegalStateException(certificateLabel + "文件未包含有效的 X.509 证书"); + } + } + return content; + } catch (IOException | InvalidPathException exception) { + throw new IllegalStateException(certificateLabel + "读取失败,请检查路径及文件权限"); + } catch (CertificateException exception) { + throw new IllegalStateException(certificateLabel + "解析失败,请提供有效的 PEM 格式 X.509 证书"); + } + } +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/config/AppPlusPayConfig.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/config/AppPlusPayConfig.java index 0b3f7ca91..547e80b01 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/config/AppPlusPayConfig.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/config/AppPlusPayConfig.java @@ -34,8 +34,8 @@ public class AppPlusPayConfig { * @return 已校验凭据、公网基础地址和支付通知路径的现有 ali-pay-config */ public AliPayConfig requireAliPayWebPayConfig() { - if (AliPayConfig == null || StrUtil.hasBlank(AliPayConfig.getAppId(), AliPayConfig.getAppPrivateKey(), - AliPayConfig.getAlipayPublicKey(), AliPayConfig.getBaseCallbackUrl())) { + AlipayClientSupport.validateConfiguration(AliPayConfig); + if (StrUtil.isBlank(AliPayConfig.getBaseCallbackUrl())) { throw new IllegalStateException("支付宝手机网站支付配置不完整,请配置网页/移动应用 app-plus.pay.ali-pay-config"); } AliPayConfig config = findAliPayConfig(AliPayConfig.getAppId()); @@ -167,23 +167,45 @@ public class AppPlusPayConfig { } + /** 支付宝应用的签名配置模式,名称直接绑定配置中心的 sign-mode。 */ + public enum AliPaySignMode { + + /** 普通公钥模式,应用私钥签名、支付宝公钥验签,兼容省略模式的历史配置。 */ + KEY, + + /** 证书模式,使用应用证书、支付宝公钥证书与根证书,服务端请求走证书执行接口。 */ + CERT + } + /** 同一支付宝应用的完整凭据与回调配置,不允许只替换 AppID 而混用其他应用密钥。 */ @Data public static class AliPayConfig { - /** 支付宝开放平台的应用 ID,必须与当前组的应用私钥及支付宝公钥配套。 */ + /** 支付宝开放平台应用 ID,须与本组私钥及公钥或证书配套。 */ private String appId; + /** 签名凭据模式,省略时沿用 KEY;更新后须重启全部 thirdparty 实例。 */ + private AliPaySignMode signMode = AliPaySignMode.KEY; + /** * 应用私钥 */ private String appPrivateKey; /** - * 支付公钥 + * KEY 模式必填的支付宝公钥;CERT 模式可保留原公钥,专供过渡期通知补充验签。 */ private String alipayPublicKey; + /** CERT 模式必填的应用公钥证书绝对路径,文件在应用首次使用时读取并固定到内存。 */ + private String appCertPath; + + /** CERT 模式必填的支付宝公钥证书绝对路径,用于响应及异步通知验签。 */ + private String alipayPublicCertPath; + + /** CERT 模式必填的支付宝根证书文件绝对路径,允许一个文件包含多份根证书。 */ + private String alipayRootCertPath; + /** * 基础回调地址 */ diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateAliPayServiceImpl.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateAliPayServiceImpl.java index 0eb3bf0cd..b21eb0691 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateAliPayServiceImpl.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateAliPayServiceImpl.java @@ -8,9 +8,7 @@ import com.bsd.pjcloud.common.core.exception.ErrorCodes; import com.bsd.pjcloud.common.core.exception.I18nBizException; import cn.hutool.json.JSONObject; import cn.hutool.json.JSONUtil; -import com.alipay.api.AlipayClient; -import com.alipay.api.AlipayConfig; -import com.alipay.api.DefaultAlipayClient; +import com.alipay.api.AlipayApiException; import com.alipay.api.domain.AlipayTradeWapPayModel; import com.alipay.api.domain.SettleDetailInfo; import com.alipay.api.domain.SettleInfo; @@ -22,6 +20,8 @@ import com.alipay.api.response.AlipayTradeCloseResponse; import com.alipay.api.response.AlipayTradeQueryResponse; import com.bsd.pjcloud.common.core.constant.enums.YesNoEnum; import com.bsd.pjcloud.thirdparty.config.AppPlusPayConfig; +import com.bsd.pjcloud.thirdparty.config.AlipayClientContext; +import com.bsd.pjcloud.thirdparty.config.AlipayClientSupport; import com.bsd.pjcloud.thirdparty.constant.miniplatform.AppPlusPlatformTypeEnum; import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPayOrderStatusEnum; import com.bsd.pjcloud.thirdparty.entity.pay.ThirdPayOrderEntity; @@ -103,6 +103,9 @@ public class ThirdAggregateAliPayServiceImpl implements ThirdAggregateAliPayServ */ private final ThirdAppPlusAliPayService thirdAppPlusAliPayService; + /** 网页/App 应用的双模式客户端入口,与原 App、退款及商户管理复用同一应用快照。 */ + private final AlipayClientSupport alipayClientSupport; + /** 聚合子订单创建前校验支付宝直付通关系归属、绑定状态和抽佣比例。 */ @Autowired(required = false) private ThirdMchFzRelationService mchFzRelationService; @@ -111,11 +114,6 @@ public class ThirdAggregateAliPayServiceImpl implements ThirdAggregateAliPayServ @Autowired private StringEncryptor stringEncryptor; - /** - * 网页/移动应用的支付客户端,延迟初始化并在聚合下单请求间复用。 - */ - private volatile AlipayClient alipayClient; - /** * 签名 WAP GET 请求并先落本地待支付流水;真正的渠道建单在浏览器访问官方网关时发生。 * 复用直付通全额结算快照,不要求或伪造 buyer_id/open_id;生成地址不能当作支付宝已受理。 @@ -218,38 +216,17 @@ public class ThirdAggregateAliPayServiceImpl implements ThirdAggregateAliPayServ } /** - * 获取延迟初始化的支付宝客户端。 + * 获取延迟初始化且固定签名模式的支付宝网页支付上下文。 * - * @return 使用现有网页/移动应用 AppID 与配套密钥初始化的支付客户端 + * @return 使用现有网页/移动应用凭据初始化的客户端和模式快照 */ - protected synchronized AlipayClient getAlipayClient() { - if (alipayClient == null) { - try { - alipayClient = createAlipayClient(appPlusPayConfig.requireAliPayWebPayConfig()); - } catch (Exception exception) { - throw new I18nBizException(ErrorCodes.THIRD_ALIPAY_CLIENT_INIT_FAIL); - } + protected AlipayClientContext getAlipayClient() { + try { + return alipayClientSupport.getClient(appPlusPayConfig.requireAliPayWebPayConfig()); + } catch (AlipayApiException exception) { + log.error("支付宝网页应用客户端初始化失败,errorCode:{}", exception.getErrCode()); + throw new I18nBizException(ErrorCodes.THIRD_ALIPAY_CLIENT_INIT_FAIL); } - return alipayClient; - } - - /** - * 仅以调用方明确指定的服务端配置创建 SDK 客户端,不在两类应用之间自动降级。 - * - * @param config 当前调用使用的完整应用凭据 - * @return 对应应用的客户端 - * @throws Exception SDK 初始化失败时由上层转换为统一业务错误 - */ - private AlipayClient createAlipayClient(AppPlusPayConfig.AliPayConfig config) throws Exception { - AlipayConfig sdkConfig = new AlipayConfig(); - sdkConfig.setServerUrl("https://openapi.alipay.com/gateway.do"); - sdkConfig.setAppId(config.getAppId()); - sdkConfig.setPrivateKey(config.getAppPrivateKey()); - sdkConfig.setFormat("json"); - sdkConfig.setAlipayPublicKey(config.getAlipayPublicKey()); - sdkConfig.setCharset("UTF-8"); - sdkConfig.setSignType("RSA2"); - return new DefaultAlipayClient(sdkConfig); } /** diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusAliPayServiceImpl.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusAliPayServiceImpl.java index 80741a854..4309e76d4 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusAliPayServiceImpl.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusAliPayServiceImpl.java @@ -8,9 +8,6 @@ import cn.hutool.core.util.StrUtil; import cn.hutool.json.JSONObject; import cn.hutool.json.JSONUtil; import com.alipay.api.AlipayApiException; -import com.alipay.api.AlipayClient; -import com.alipay.api.AlipayConfig; -import com.alipay.api.DefaultAlipayClient; import com.alipay.api.domain.AlipayTradeAppPayModel; import com.alipay.api.domain.AlipayTradeCancelModel; import com.alipay.api.domain.AlipayTradeCloseModel; @@ -29,7 +26,6 @@ import com.alipay.api.domain.SettleDetailInfo; import com.alipay.api.domain.SettleInfo; import com.alipay.api.domain.SettleConfirmExtendParams; import com.alipay.api.domain.SettleExtendParams; -import com.alipay.api.internal.util.AlipaySignature; import com.alipay.api.request.AlipayTradeAppPayRequest; import com.alipay.api.request.AlipayTradeCancelRequest; import com.alipay.api.request.AlipayTradeCloseRequest; @@ -54,6 +50,8 @@ import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; import com.bsd.pjcloud.common.core.exception.ErrorCodes; import com.bsd.pjcloud.common.core.exception.I18nBizException; import com.bsd.pjcloud.thirdparty.config.AppPlusPayConfig; +import com.bsd.pjcloud.thirdparty.config.AlipayClientContext; +import com.bsd.pjcloud.thirdparty.config.AlipayClientSupport; import com.bsd.pjcloud.thirdparty.constant.fz.ThirdMchFzRelationStatusEnum; import com.bsd.pjcloud.thirdparty.constant.miniplatform.AppPlusPlatformTypeEnum; import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPayOrderStatusEnum; @@ -86,7 +84,6 @@ import java.math.BigDecimal; import java.math.RoundingMode; import java.time.LocalDateTime; import java.util.ArrayList; -import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.function.BiConsumer; @@ -141,14 +138,6 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService private static final List ALIPAY_REFUND_NOT_FOUND_CODES = List.of( "ACQ.REFUND_NOT_EXIST", "ACQ.TRADE_NOT_EXIST"); - /** - * 当前应用配置对应的支付宝客户端;延迟初始化后由所有支付请求复用。 - */ - private volatile AlipayClient alipayClient; - - /** 非默认支付宝应用的 SDK 客户端,按已登记 AppID 缓存;仅在同步方法内读写。 */ - private final Map applicationClients = new HashMap<>(); - /** * 支付宝应用级配置,来源于现有 app-plus.pay 配置中心。 */ @@ -159,6 +148,9 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService */ private final ThirdPayOrderService thirdPayOrderService; + /** 支付宝双模式客户端与通知验签入口,跨 App、聚合收银及商户管理统一复用应用快照。 */ + private final AlipayClientSupport alipayClientSupport; + /** * 支付宝直付通分账订单服务;使用现有 third_fz_order 表保存两阶段请求快照。 */ @@ -192,43 +184,19 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService private ThirdPaymentOrderReservationService paymentOrderReservationService; /** - * 构建支付宝 SDK 客户端配置,不输出或复制任何密钥到日志。 + * 获取默认网页/App 应用的双模式客户端上下文。 * - * @param aliPayConfig 已按支付单 AppID 选定的服务端应用配置 - * @return 指定应用的支付宝 SDK 配置 + *

保留 protected 测试替换入口;生产实例由统一组件按应用延迟初始化并复用。

+ * + * @return 已固定签名模式、SDK 和通知验签凭据的应用上下文 */ - private AlipayConfig getAlipayConfig(AppPlusPayConfig.AliPayConfig aliPayConfig) { - if (aliPayConfig == null || StrUtil.hasBlank(aliPayConfig.getAppId(), aliPayConfig.getAppPrivateKey(), aliPayConfig.getAlipayPublicKey())) { - throw new I18nBizException(ErrorCodes.THIRD_ALIPAY_APP_CONFIG_INCOMPLETE); + protected AlipayClientContext getAlipayClient() { + try { + return alipayClientSupport.getClient(appPlusPayConfig.getAliPayConfig()); + } catch (AlipayApiException exception) { + log.error("支付宝默认应用客户端初始化失败,errorCode:{}", exception.getErrCode()); + throw new I18nBizException(ErrorCodes.THIRD_ALIPAY_CLIENT_INIT_FAIL); } - AlipayConfig alipayConfig = new AlipayConfig(); - alipayConfig.setServerUrl("https://openapi.alipay.com/gateway.do"); - alipayConfig.setAppId(aliPayConfig.getAppId()); - alipayConfig.setPrivateKey(aliPayConfig.getAppPrivateKey()); - alipayConfig.setFormat("json"); - alipayConfig.setAlipayPublicKey(aliPayConfig.getAlipayPublicKey()); - alipayConfig.setCharset("UTF-8"); - alipayConfig.setSignType("RSA2"); - return alipayConfig; - } - - /** - * 获取延迟初始化的支付宝客户端。 - * - *

方法保留为 protected,便于单元测试替换为 Mock 客户端;生产环境只会创建一次。

- * - * @return 可用的支付宝客户端 - */ - protected synchronized AlipayClient getAlipayClient() { - if (alipayClient == null) { - try { - alipayClient = new DefaultAlipayClient(getAlipayConfig(appPlusPayConfig.getAliPayConfig())); - } catch (Exception e) { - log.error("初始化支付宝客户端失败", e); - throw new I18nBizException(ErrorCodes.THIRD_ALIPAY_CLIENT_INIT_FAIL); - } - } - return alipayClient; } /** @@ -258,30 +226,26 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService * @param settingJson 创建时保存的商户和应用标识快照 * @return 对应应用的客户端 */ - private AlipayClient getOrderAlipayClient(String settingJson) { + private AlipayClientContext getOrderAlipayClient(String settingJson) { return getAlipayClient(resolveOrderAliPayConfig(settingJson)); } /** - * 缓存已登记应用的客户端;默认应用沿用原方法,保留 App 链路及现有测试扩展点。 + * 按原支付应用获取统一双模式上下文;默认应用沿用原方法,保留测试扩展点。 * * @param config 从可信服务端配置选出的完整应用凭据 - * @return 对应 AppID 的 SDK 客户端 + * @return 对应 AppID 的固定客户端及验签凭据快照 */ - protected synchronized AlipayClient getAlipayClient(AppPlusPayConfig.AliPayConfig config) { + protected AlipayClientContext getAlipayClient(AppPlusPayConfig.AliPayConfig config) { if (config == appPlusPayConfig.getAliPayConfig()) { return getAlipayClient(); } - AlipayClient client = applicationClients.get(config.getAppId()); - if (client == null) { - try { - client = new DefaultAlipayClient(getAlipayConfig(config)); - applicationClients.put(config.getAppId(), client); - } catch (Exception exception) { - throw new I18nBizException(ErrorCodes.THIRD_ALIPAY_CLIENT_INIT_FAIL); - } + try { + return alipayClientSupport.getClient(config); + } catch (AlipayApiException exception) { + log.error("支付宝应用客户端初始化失败,appId:{},errorCode:{}", config.getAppId(), exception.getErrCode()); + throw new I18nBizException(ErrorCodes.THIRD_ALIPAY_CLIENT_INIT_FAIL); } - return client; } /** @@ -1850,7 +1814,7 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService public ThirdALiPayCallbackVo miniSubscribeCallback(Map params, String body, Map headers) { try { AppPlusPayConfig.AliPayConfig config = resolveCallbackAliPayConfig(params.get("app_id")); - boolean flag = config != null && AlipaySignature.rsaCheckV1(params, config.getAlipayPublicKey(), "UTF-8", "RSA2"); + boolean flag = alipayClientSupport.verifyCallback(config, params); if (!flag) return null; String bizContent = params.get("biz_content"); String msgMethod = params.get("msg_method"); @@ -1997,7 +1961,7 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService } /** - * 使用应用支付宝公钥对支付回调执行 RSA2 验签。 + * 按已登记应用的固定公钥或证书快照对支付、退款回调执行 RSA2 验签。 * * @param params 支付宝表单回调参数 * @return 验签通过时返回 {@code true} @@ -2005,8 +1969,7 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService protected boolean verifyPayCallbackSignature(Map params) { try { AppPlusPayConfig.AliPayConfig callbackConfig = resolveCallbackAliPayConfig(params.get("app_id")); - return callbackConfig != null && AlipaySignature.rsaCheckV1(params, - callbackConfig.getAlipayPublicKey(), "UTF-8", "RSA2"); + return alipayClientSupport.verifyCallback(callbackConfig, params); } catch (Exception exception) { log.warn("|{}|支付宝支付回调验签异常,payNo:{}", TAG, params.get("out_trade_no")); return false; @@ -2014,7 +1977,7 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService } /** - * 按回调 app_id 选取已登记应用的支付宝公钥;验签后仍须匹配订单固定 AppID。 + * 按回调 app_id 选取已登记应用的完整配置;验签后仍须匹配订单固定 AppID。 * * @param appId 支付宝回调中的应用 ID * @return 应用 ID 唯一匹配时返回对应配置,未知应用返回 {@code null} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusServiceImpl.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusServiceImpl.java index 9a518fa37..5ab448d81 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusServiceImpl.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusServiceImpl.java @@ -32,6 +32,7 @@ import com.bsd.pjcloud.hard.api.feign.RemoteHardOrderService; import com.bsd.pjcloud.supermarket.api.feign.RemoteSmHotelOrderService; import com.bsd.pjcloud.supermarket.vo.SmHotelOrderPayInfoVo; import com.bsd.pjcloud.thirdparty.config.AppPlusPayConfig; +import com.bsd.pjcloud.thirdparty.config.AlipayClientSupport; import com.bsd.pjcloud.thirdparty.constant.miniplatform.AppPlusPlatformLoginErrorEnum; import com.bsd.pjcloud.thirdparty.constant.miniplatform.AppPlusPlatformPayType; import com.bsd.pjcloud.thirdparty.constant.miniplatform.AppPlusPlatformTypeEnum; @@ -1016,12 +1017,12 @@ public class ThirdAppPlusServiceImpl implements ThirdAppPlusService { } /** - * 校验现有支付宝应用配置满足预创建、回调验签和回调地址拼接要求。 + * 按签名模式校验支付宝应用凭据,并检查订单码预创建所需的回调地址。 */ private void validateAliPayApplicationConfig() { AppPlusPayConfig.AliPayConfig config = appPlusPayConfig.getAliPayConfig(); - if (config == null || StrUtil.hasBlank(config.getAppId(), config.getAppPrivateKey(), config.getAlipayPublicKey(), - config.getBaseCallbackUrl(), config.getPayCallbackUrl())) { + AlipayClientSupport.validateConfiguration(config); + if (StrUtil.hasBlank(config.getBaseCallbackUrl(), config.getPayCallbackUrl())) { throw new I18nBizException(ErrorCodes.THIRD_ALIPAY_APP_CONFIG_INCOMPLETE); } } diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdMchFzRelationServiceImpl.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdMchFzRelationServiceImpl.java index 50ddb6ad8..13f53bf9e 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdMchFzRelationServiceImpl.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdMchFzRelationServiceImpl.java @@ -3,10 +3,7 @@ package com.bsd.pjcloud.thirdparty.service.impl.pay; import cn.hutool.core.bean.BeanUtil; import cn.hutool.core.util.StrUtil; import cn.hutool.json.JSONUtil; -import com.alipay.api.AlipayClient; import com.alipay.api.AlipayApiException; -import com.alipay.api.AlipayConfig; -import com.alipay.api.DefaultAlipayClient; import com.alipay.api.FileItem; import com.alipay.api.domain.AlipayTradeRoyaltyRelationBatchqueryModel; import com.alipay.api.domain.AlipayTradeRoyaltyRelationBindModel; @@ -33,6 +30,8 @@ import com.bsd.pjcloud.common.core.constant.enums.YesNoEnum; import com.bsd.pjcloud.common.core.util.R; import com.bsd.pjcloud.common.data.tenant.TenantContextHolder; import com.bsd.pjcloud.thirdparty.config.AppPlusPayConfig; +import com.bsd.pjcloud.thirdparty.config.AlipayClientContext; +import com.bsd.pjcloud.thirdparty.config.AlipayClientSupport; import com.bsd.pjcloud.thirdparty.constant.fz.ThirdMchFzRelationSceneEnum; import com.bsd.pjcloud.thirdparty.constant.fz.ThirdMchFzRelationStatusEnum; import com.bsd.pjcloud.thirdparty.constant.miniplatform.AppPlusPlatformPayType; @@ -150,6 +149,9 @@ public class ThirdMchFzRelationServiceImpl implements ThirdMchFzRelationService */ private final RemoteMchService remoteMchService; + /** 商户资料、图片及分账关系请求统一使用的支付宝双模式客户端入口。 */ + private final AlipayClientSupport alipayClientSupport; + /** * 查询支付配置下的一对多关系,接收方账号只返回脱敏值。 * @@ -1823,25 +1825,14 @@ public class ThirdMchFzRelationServiceImpl implements ThirdMchFzRelationService } /** - * 动态创建平台统一支付宝客户端;测试实现可替换客户端以验证渠道响应边界。 + * 获取平台应用的固定双模式客户端;测试实现可替换上下文以验证渠道响应边界。 * * @param config 已解析的平台支付宝账号配置 - * @return 使用指定平台账号创建的支付宝 SDK 客户端 + * @return 指定平台账号的客户端与签名模式快照 * @throws AlipayApiException SDK 配置无法初始化时抛出 */ - protected AlipayClient client(AppPlusPayConfig.AliPayConfig config) throws AlipayApiException { - if (config == null || StrUtil.hasBlank(config.getAppId(), config.getAppPrivateKey(), config.getAlipayPublicKey())) { - throw new IllegalStateException("支付宝平台账号配置不完整"); - } - AlipayConfig sdk = new AlipayConfig(); - sdk.setServerUrl("https://openapi.alipay.com/gateway.do"); - sdk.setAppId(config.getAppId()); - sdk.setPrivateKey(config.getAppPrivateKey()); - sdk.setFormat("json"); - sdk.setAlipayPublicKey(config.getAlipayPublicKey()); - sdk.setCharset("UTF-8"); - sdk.setSignType("RSA2"); - return new DefaultAlipayClient(sdk); + protected AlipayClientContext client(AppPlusPayConfig.AliPayConfig config) throws AlipayApiException { + return alipayClientSupport.getClient(config); } /** diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/config/AlipayClientSupportTest.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/config/AlipayClientSupportTest.java new file mode 100644 index 000000000..534351d1f --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/config/AlipayClientSupportTest.java @@ -0,0 +1,474 @@ +package com.bsd.pjcloud.thirdparty.config; + +import com.alipay.api.AlipayApiException; +import com.alipay.api.AlipayClient; +import com.alipay.api.internal.util.AlipaySignature; +import com.alipay.api.internal.util.AntCertificationUtil; +import com.alipay.api.request.AlipayTradeAppPayRequest; +import com.alipay.api.request.AlipayTradeQueryRequest; +import com.alipay.api.request.AlipayTradeWapPayRequest; +import com.alipay.api.response.AlipayTradeQueryResponse; +import org.bouncycastle.x509.X509V3CertificateGenerator; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.springframework.boot.context.properties.bind.BindException; +import org.springframework.boot.context.properties.bind.Binder; +import org.springframework.boot.context.properties.source.MapConfigurationPropertySource; + +import javax.security.auth.x500.X500Principal; +import java.math.BigInteger; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.cert.X509Certificate; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Base64; +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.Callable; +import java.util.concurrent.Executors; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** 支付宝模式绑定、执行分流、证书快照及旧公钥通知兼容的离线回归。 */ +class AlipayClientSupportTest { + + /** 运行时生成的应用证书及配套私钥,各用例复用以减少 RSA 密钥生成开销。 */ + private static CertificateMaterial appMaterial; + + /** 运行时生成的支付宝测试公钥证书,用于真实 RSA2 通知验签。 */ + private static CertificateMaterial alipayMaterial; + + /** 运行时生成的根证书,SDK 据此计算根证书序列号。 */ + private static CertificateMaterial rootMaterial; + + /** 旧公钥使用不同签名长度,覆盖首次验签抛出签名长度异常后的兼容路径。 */ + private static KeyPair legacyKeys; + + /** 每个测试独立的证书目录,由 JUnit 在测试结束后清理。 */ + @TempDir + Path certificateDirectory; + + /** 当前测试的三组应用配置容器。 */ + private AppPlusPayConfig applications; + + /** 默认网页/App 应用配置,按用例切换 KEY 或 CERT。 */ + private AppPlusPayConfig.AliPayConfig config; + + /** 当前测试的进程级客户端快照缓存。 */ + private AlipayClientSupport support; + + /** 所有证书和私钥均在测试进程中生成,仓库只保存生成逻辑。 */ + @BeforeAll + static void generateTestCertificates() throws Exception { + appMaterial = certificate("Application Test", 1); + alipayMaterial = certificate("Alipay Test", 2); + rootMaterial = certificate("Root Test", 3); + KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA"); + generator.initialize(1024); + legacyKeys = generator.generateKeyPair(); + } + + /** 默认建立完整 KEY 配置,覆盖省略 sign-mode 的历史部署方式。 */ + @BeforeEach + void setUp() { + applications = new AppPlusPayConfig(); + config = new AppPlusPayConfig.AliPayConfig(); + config.setAppId("test-web-app"); + config.setAppPrivateKey(privateKey(appMaterial.keys())); + config.setAlipayPublicKey(publicKey(alipayMaterial.keys())); + config.setBaseCallbackUrl("https://pay.example.com"); + config.setPayCallbackUrl("/third/third-app-pay/app/pay-callback"); + applications.setAliPayConfig(config); + support = new AlipayClientSupport(applications); + } + + /** 三组配置真实绑定各自模式,省略模式继续使用 KEY。 */ + @Test + void configurationShouldBindIndependentModesAndPaths() { + Map values = Map.of( + "app-plus.pay.ali-pay-config.app-id", "web", + "app-plus.pay.ali-pay-mini-config.app-id", "mini", + "app-plus.pay.ali-pay-mini-config.sign-mode", "CERT", + "app-plus.pay.ali-pay-mini-config.app-cert-path", "/certs/app.crt", + "app-plus.pay.ali-pay-mini-config.alipay-public-cert-path", "/certs/alipay.crt", + "app-plus.pay.ali-pay-mini-config.alipay-root-cert-path", "/certs/root.crt", + "app-plus.pay.ali-pay-third-party-config.app-id", "third", + "app-plus.pay.ali-pay-third-party-config.sign-mode", "KEY"); + AppPlusPayConfig bound = new Binder(new MapConfigurationPropertySource(values)) + .bind("app-plus.pay", AppPlusPayConfig.class).get(); + + assertEquals(AppPlusPayConfig.AliPaySignMode.KEY, bound.getAliPayConfig().getSignMode()); + assertEquals(AppPlusPayConfig.AliPaySignMode.CERT, bound.getAliPayMiniConfig().getSignMode()); + assertEquals(AppPlusPayConfig.AliPaySignMode.KEY, bound.getAliPayThirdPartyConfig().getSignMode()); + assertEquals("/certs/app.crt", bound.getAliPayMiniConfig().getAppCertPath()); + assertEquals("/certs/alipay.crt", bound.getAliPayMiniConfig().getAlipayPublicCertPath()); + assertEquals("/certs/root.crt", bound.getAliPayMiniConfig().getAlipayRootCertPath()); + } + + /** 拼错模式必须由配置绑定明确拒绝。 */ + @Test + void unknownModeShouldFailConfigurationBinding() { + Binder binder = new Binder(new MapConfigurationPropertySource(Map.of( + "app-plus.pay.ali-pay-config.sign-mode", "UNKNOWN"))); + + assertThrows(BindException.class, () -> binder.bind("app-plus.pay", AppPlusPayConfig.class)); + } + + /** KEY 配置独立要求普通公钥,CERT 必填项按三份证书路径校验。 */ + @Test + void requiredCredentialsShouldFollowConfiguredMode() { + config.setAlipayPublicKey(null); + assertThrows(IllegalStateException.class, () -> AlipayClientSupport.validateConfiguration(config)); + config.setSignMode(AppPlusPayConfig.AliPaySignMode.CERT); + assertThrows(IllegalStateException.class, () -> AlipayClientSupport.validateConfiguration(config)); + config.setSignMode(null); + assertThrows(IllegalStateException.class, () -> AlipayClientSupport.validateConfiguration(config)); + } + + /** KEY 服务端调用沿用普通执行接口,证书接口保持零调用。 */ + @Test + void keyServerRequestShouldUseOrdinaryExecute() throws Exception { + AlipayClient sdk = mock(AlipayClient.class); + AlipayTradeQueryRequest request = new AlipayTradeQueryRequest(); + AlipayTradeQueryResponse response = new AlipayTradeQueryResponse(); + when(sdk.execute(request)).thenReturn(response); + AlipayClientContext context = new AlipayClientContext(sdk, AppPlusPayConfig.AliPaySignMode.KEY, null, null); + + assertSame(response, context.execute(request)); + verify(sdk).execute(request); + verify(sdk, never()).certificateExecute(request); + } + + /** CERT 服务端调用始终选择证书执行接口,避免 SDK 的 CERT_EXECUTE_ERROR。 */ + @Test + void certificateServerRequestShouldUseCertificateExecute() throws Exception { + AlipayClient sdk = mock(AlipayClient.class); + AlipayTradeQueryRequest request = new AlipayTradeQueryRequest(); + AlipayTradeQueryResponse response = new AlipayTradeQueryResponse(); + when(sdk.certificateExecute(request)).thenReturn(response); + AlipayClientContext context = new AlipayClientContext(sdk, AppPlusPayConfig.AliPaySignMode.CERT, null, null); + + assertSame(response, context.execute(request)); + verify(sdk).certificateExecute(request); + verify(sdk, never()).execute(request); + } + + /** 证书请求失败保留原异常,业务层据此执行原有未知结果和重试逻辑。 */ + @Test + void certificateRequestFailureShouldPropagateWithoutAnotherSubmission() throws Exception { + AlipayClient sdk = mock(AlipayClient.class); + AlipayTradeQueryRequest request = new AlipayTradeQueryRequest(); + AlipayApiException failure = new AlipayApiException("TEST_NETWORK_ERROR", "test"); + when(sdk.certificateExecute(request)).thenThrow(failure); + AlipayClientContext context = new AlipayClientContext(sdk, AppPlusPayConfig.AliPaySignMode.CERT, null, null); + + assertSame(failure, assertThrows(AlipayApiException.class, () -> context.execute(request))); + verify(sdk, never()).execute(request); + } + + /** CERT 无普通公钥时可通过网页支付前置检查,并在两种本地签名中自动带入证书序列号。 */ + @Test + void certificateModeShouldSignAppAndWapWithoutOrdinaryPublicKey() throws Exception { + enableCertificateMode(config); + assertSame(config, applications.requireAliPayWebPayConfig()); + AlipayClientContext context = support.getClient(config); + String appParameters = appParameters(context); + AlipayTradeWapPayRequest request = new AlipayTradeWapPayRequest(); + request.setBizContent("{}"); + String wapParameters = context.pageExecute(request, "GET").getBody(); + String appSerial = "app_cert_sn=" + AntCertificationUtil.getCertSN(appMaterial.certificate()); + String rootSerial = "alipay_root_cert_sn=" + AntCertificationUtil.getRootCertSN(rootMaterial.pem()); + + assertTrue(appParameters.contains(appSerial)); + assertTrue(appParameters.contains(rootSerial)); + assertTrue(wapParameters.startsWith("https://openapi.alipay.com/gateway.do?")); + assertTrue(wapParameters.contains(appSerial)); + assertTrue(wapParameters.contains(rootSerial)); + } + + /** 省略模式的旧配置无需证书文件,即可执行 App 和 WAP 本地签名。 */ + @Test + void defaultKeyModeShouldKeepLocalSigningWithoutCertificateParameters() throws Exception { + AlipayClientContext context = support.getClient(config); + AlipayTradeWapPayRequest request = new AlipayTradeWapPayRequest(); + request.setBizContent("{}"); + + assertFalse(appParameters(context).contains("app_cert_sn=")); + assertFalse(context.pageExecute(request, "GET").getBody().contains("alipay_root_cert_sn=")); + } + + /** 普通公钥通知可使用不可修改参数集合,原始签名字段保留。 */ + @Test + void keyNotificationShouldVerifyWithoutChangingOriginalParameters() throws Exception { + Map params = notification(alipayMaterial.keys()); + + assertTrue(support.verifyCallback(config, params)); + assertTrue(params.containsKey("sign")); + assertEquals("RSA2", params.get("sign_type")); + } + + /** 支付宝公钥证书提取出的公钥可以验证真实 RSA2 通知。 */ + @Test + void certificateNotificationShouldVerifyUsingCertificatePublicKey() throws Exception { + enableCertificateMode(config); + + assertTrue(support.verifyCallback(config, notification(alipayMaterial.keys()))); + } + + /** 旧签名长度与当前证书不同,第一次验签异常后仍用完整参数进行旧公钥复核。 */ + @Test + void certificateNotificationShouldSupportExplicitLegacyKeyWithFreshParameters() throws Exception { + enableCertificateMode(config); + config.setAlipayPublicKey(publicKey(legacyKeys)); + Map params = notification(legacyKeys); + Map original = new HashMap<>(params); + + assertTrue(support.verifyCallback(config, params)); + assertEquals(original, params); + } + + /** 旧公钥缺省时旧签名被拒绝,金额篡改在开启兼容后同样被拒绝。 */ + @Test + void missingLegacyKeyAndTamperedNotificationShouldBeRejected() throws Exception { + enableCertificateMode(config); + assertFalse(support.verifyCallback(config, notification(legacyKeys))); + + AlipayClientSupport withLegacyKey = new AlipayClientSupport(applications); + config.setAlipayPublicKey(publicKey(legacyKeys)); + Map tampered = new HashMap<>(notification(legacyKeys)); + tampered.put("total_amount", "200.00"); + assertFalse(withLegacyKey.verifyCallback(config, tampered)); + } + + /** 未知 AppID、错误配置组、缺失签名及重复登记 AppID 均被明确拒绝。 */ + @Test + void callbackApplicationAndRegistrationShouldBeStrictlyMatched() throws Exception { + Map params = new HashMap<>(notification(alipayMaterial.keys())); + params.put("app_id", "unknown-app"); + assertFalse(support.verifyCallback(applications.findAliPayConfig("unknown-app"), params)); + assertFalse(support.verifyCallback(config, params)); + assertFalse(support.verifyCallback(config, Map.of("app_id", config.getAppId()))); + + AppPlusPayConfig.AliPayConfig duplicate = new AppPlusPayConfig.AliPayConfig(); + duplicate.setAppId(config.getAppId()); + applications.setAliPayMiniConfig(duplicate); + assertThrows(IllegalStateException.class, () -> support.getClient(config)); + } + + /** 每份证书缺失均指出对应类型,失败实例不进入缓存,修正路径后可再次初始化。 */ + @Test + void eachMissingCertificateShouldFailAndAllowCorrectedInitialization() throws Exception { + enableCertificateMode(config); + String missing = certificateDirectory.resolve("missing.crt").toString(); + String original = config.getAppCertPath(); + config.setAppCertPath(missing); + assertTrue(assertThrows(IllegalStateException.class, () -> support.getClient(config)).getMessage().contains("应用公钥证书")); + config.setAppCertPath(original); + original = config.getAlipayPublicCertPath(); + config.setAlipayPublicCertPath(missing); + assertTrue(assertThrows(IllegalStateException.class, () -> support.getClient(config)).getMessage().contains("支付宝公钥证书")); + config.setAlipayPublicCertPath(original); + original = config.getAlipayRootCertPath(); + config.setAlipayRootCertPath(missing); + assertTrue(assertThrows(IllegalStateException.class, () -> support.getClient(config)).getMessage().contains("支付宝根证书")); + config.setAlipayRootCertPath(original); + + assertTrue(appParameters(support.getClient(config)).contains("app_cert_sn=")); + } + + /** 三种证书的格式错误均在初始化阶段明确反馈。 */ + @Test + void malformedCertificateShouldBeRejectedForEveryRole() throws Exception { + enableCertificateMode(config); + Path malformed = certificateDirectory.resolve("malformed.crt"); + Files.writeString(malformed, "invalid-certificate", StandardCharsets.UTF_8); + String original = config.getAppCertPath(); + config.setAppCertPath(malformed.toString()); + assertTrue(assertThrows(IllegalStateException.class, () -> support.getClient(config)).getMessage().contains("应用公钥证书")); + config.setAppCertPath(original); + original = config.getAlipayPublicCertPath(); + config.setAlipayPublicCertPath(malformed.toString()); + assertTrue(assertThrows(IllegalStateException.class, () -> support.getClient(config)).getMessage().contains("支付宝公钥证书")); + config.setAlipayPublicCertPath(original); + config.setAlipayRootCertPath(malformed.toString()); + assertTrue(assertThrows(IllegalStateException.class, () -> support.getClient(config)).getMessage().contains("支付宝根证书")); + } + + /** 相对路径或目录不能被当作本地证书文件使用。 */ + @Test + void relativePathAndDirectoryShouldBeRejected() throws Exception { + enableCertificateMode(config); + config.setAppCertPath("relative/app.crt"); + assertThrows(IllegalStateException.class, () -> support.getClient(config)); + config.setAppCertPath(certificateDirectory.toString()); + assertThrows(IllegalStateException.class, () -> support.getClient(config)); + } + + /** 即使旧公钥签名有效,证书初始化失败也按配置异常向上传递。 */ + @Test + void invalidCertificateMustNotBeHiddenByValidLegacySignature() throws Exception { + enableCertificateMode(config); + config.setAlipayPublicKey(publicKey(legacyKeys)); + config.setAlipayPublicCertPath(certificateDirectory.resolve("missing.crt").toString()); + + assertThrows(IllegalStateException.class, () -> support.verifyCallback(config, notification(legacyKeys))); + } + + /** 并发首次读取发布同一个完整客户端快照。 */ + @Test + void concurrentInitializationShouldReuseOneApplicationContext() throws Exception { + enableCertificateMode(config); + var executor = Executors.newFixedThreadPool(4); + try { + List> tasks = new ArrayList<>(); + for (int index = 0; index < 8; index++) { + tasks.add(() -> support.getClient(config)); + } + var results = executor.invokeAll(tasks); + AlipayClientContext first = results.get(0).get(); + for (var result : results) { + assertSame(first, result.get()); + } + } finally { + executor.shutdownNow(); + } + } + + /** 已缓存的模式固定至重启,新实例才采纳 CERT 配置。 */ + @Test + void modeChangeShouldTakeEffectOnlyForNewSupportInstance() throws Exception { + AlipayClientContext original = support.getClient(config); + enableCertificateMode(config); + + assertSame(original, support.getClient(config)); + assertFalse(appParameters(original).contains("app_cert_sn=")); + AlipayClientContext restarted = new AlipayClientSupport(applications).getClient(config); + assertNotSame(original, restarted); + assertTrue(appParameters(restarted).contains("app_cert_sn=")); + } + + /** 本地证书轮换不改变已初始化通知公钥,新实例重新读取并报告异常文件。 */ + @Test + void cachedVerificationKeyShouldSurviveCertificateFileReplacement() throws Exception { + enableCertificateMode(config); + support.getClient(config); + Files.writeString(Path.of(config.getAlipayPublicCertPath()), "invalid-certificate", StandardCharsets.UTF_8); + + assertTrue(support.verifyCallback(config, notification(alipayMaterial.keys()))); + AlipayClientSupport restarted = new AlipayClientSupport(applications); + assertThrows(IllegalStateException.class, () -> restarted.getClient(config)); + } + + /** 三组应用可同时使用不同模式,缓存和通知公钥按各自 AppID 隔离。 */ + @Test + void mixedApplicationGroupsShouldKeepIndependentContexts() throws Exception { + AppPlusPayConfig.AliPayConfig mini = new AppPlusPayConfig.AliPayConfig(); + mini.setAppId("test-mini-app"); + mini.setAppPrivateKey(privateKey(appMaterial.keys())); + enableCertificateMode(mini); + applications.setAliPayMiniConfig(mini); + AppPlusPayConfig.AliPayConfig third = new AppPlusPayConfig.AliPayConfig(); + third.setAppId("test-third-app"); + third.setAppPrivateKey(privateKey(appMaterial.keys())); + third.setAlipayPublicKey(publicKey(legacyKeys)); + applications.setAliPayThirdPartyConfig(third); + + assertFalse(appParameters(support.getClient(config)).contains("app_cert_sn=")); + assertTrue(appParameters(support.getClient(mini)).contains("app_cert_sn=")); + assertFalse(appParameters(support.getClient(third)).contains("app_cert_sn=")); + assertNotSame(support.getClient(config), support.getClient(mini)); + assertNotSame(support.getClient(mini), support.getClient(third)); + Map thirdNotification = notification(third.getAppId(), legacyKeys); + assertTrue(support.verifyCallback(third, thirdNotification)); + assertFalse(support.verifyCallback(mini, thirdNotification)); + } + + /** 将指定应用切换为无旧公钥的证书模式,文件全部写入本用例临时目录。 */ + private void enableCertificateMode(AppPlusPayConfig.AliPayConfig target) throws Exception { + target.setSignMode(AppPlusPayConfig.AliPaySignMode.CERT); + target.setAlipayPublicKey(null); + target.setAppCertPath(Files.writeString(certificateDirectory.resolve("app.crt"), appMaterial.pem()).toString()); + target.setAlipayPublicCertPath(Files.writeString(certificateDirectory.resolve("alipay.crt"), alipayMaterial.pem()).toString()); + target.setAlipayRootCertPath(Files.writeString(certificateDirectory.resolve("root.crt"), rootMaterial.pem()).toString()); + } + + /** 使用真实 SDK 在内存中生成 App 支付参数,调用过程不访问支付宝网关。 */ + private String appParameters(AlipayClientContext context) throws Exception { + AlipayTradeAppPayRequest request = new AlipayTradeAppPayRequest(); + request.setBizContent("{}"); + return context.sdkExecute(request).getBody(); + } + + /** 构造默认应用通知,方便核对当前证书与旧公钥的信任范围。 */ + private Map notification(KeyPair signingKeys) throws Exception { + return notification(config.getAppId(), signingKeys); + } + + /** 使用运行时测试私钥生成真实 RSA2 通知,并返回不可修改集合以检查验签副作用。 */ + private Map notification(String appId, KeyPair signingKeys) throws Exception { + Map params = new HashMap<>(); + params.put("app_id", appId); + params.put("out_trade_no", "test-payment"); + params.put("total_amount", "100.00"); + params.put("sign_type", "RSA2"); + String content = AlipaySignature.getSignCheckContentV1(new HashMap<>(params)); + params.put("sign", AlipaySignature.rsaSign(content, privateKey(signingKeys), "UTF-8", "RSA2")); + return Map.copyOf(params); + } + + /** 生成短期自签名 RSA 证书,复用 SDK 已依赖的 BouncyCastle API。 */ + @SuppressWarnings("deprecation") + private static CertificateMaterial certificate(String commonName, long serial) throws Exception { + KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA"); + generator.initialize(2048); + KeyPair keys = generator.generateKeyPair(); + X509V3CertificateGenerator certificateGenerator = new X509V3CertificateGenerator(); + X500Principal principal = new X500Principal("CN=" + commonName); + certificateGenerator.setIssuerDN(principal); + certificateGenerator.setSubjectDN(principal); + certificateGenerator.setSerialNumber(BigInteger.valueOf(serial)); + certificateGenerator.setNotBefore(Date.from(Instant.now().minusSeconds(60))); + certificateGenerator.setNotAfter(Date.from(Instant.now().plusSeconds(86400))); + certificateGenerator.setPublicKey(keys.getPublic()); + certificateGenerator.setSignatureAlgorithm("SHA256withRSA"); + X509Certificate certificate = certificateGenerator.generate(keys.getPrivate()); + String encoded = Base64.getMimeEncoder(64, new byte[]{'\n'}).encodeToString(certificate.getEncoded()); + return new CertificateMaterial(keys, certificate, "-----BEGIN CERTIFICATE-----\n" + encoded + "\n-----END CERTIFICATE-----\n"); + } + + /** 将运行时测试私钥编码为 SDK 支持的 PKCS#8 Base64 字符串。 */ + private static String privateKey(KeyPair keys) { + return Base64.getEncoder().encodeToString(keys.getPrivate().getEncoded()); + } + + /** 将运行时测试公钥编码为 SDK 支持的 X.509 Base64 字符串。 */ + private static String publicKey(KeyPair keys) { + return Base64.getEncoder().encodeToString(keys.getPublic().getEncoded()); + } + + /** + * 仅在测试进程内保存的证书材料。 + * @param keys 运行时生成的 RSA 密钥对 + * @param certificate 对应的短期自签名证书 + * @param pem 临时证书文件内容 + */ + private record CertificateMaterial(KeyPair keys, X509Certificate certificate, String pem) { + } +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateAliPayServiceImplTest.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateAliPayServiceImplTest.java index 45e05eb73..784207a9f 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateAliPayServiceImplTest.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateAliPayServiceImplTest.java @@ -4,7 +4,8 @@ import cn.hutool.core.date.DatePattern; import cn.hutool.core.date.LocalDateTimeUtil; import cn.hutool.json.JSONObject; import cn.hutool.json.JSONUtil; -import com.alipay.api.AlipayClient; +import com.bsd.pjcloud.thirdparty.config.AlipayClientContext; +import com.bsd.pjcloud.thirdparty.config.AlipayClientSupport; import com.alipay.api.domain.AlipayTradeWapPayModel; import com.alipay.api.internal.util.json.JSONWriter; import com.alipay.api.request.AlipayTradeWapPayRequest; @@ -49,8 +50,9 @@ class ThirdAggregateAliPayServiceImplTest { /** * 网页/移动应用支付 SDK 客户端 Mock,仅用于捕获聚合交易创建请求。 */ + /** 网页/App 应用上下文 Mock,仅验证 WAP 本地签名入口。 */ @Mock - private AlipayClient alipayClient; + private AlipayClientContext alipayClient; /** * 第三方支付订单服务 Mock,用于验证子订单快照和保存失败补偿。 @@ -354,9 +356,9 @@ class ThirdAggregateAliPayServiceImplTest { private static final class TestableThirdAggregateAliPayService extends ThirdAggregateAliPayServiceImpl { /** - * 当前测试方法共享的支付宝 SDK 客户端 Mock。 + * 当前测试方法共享的支付宝双模式上下文 Mock。 */ - private final AlipayClient testAlipayClient; + private final AlipayClientContext testAlipayClient; /** * 创建可替换 SDK 客户端的支付宝聚合服务。 @@ -369,18 +371,18 @@ class ThirdAggregateAliPayServiceImplTest { private TestableThirdAggregateAliPayService(AppPlusPayConfig appPlusPayConfig, ThirdPayOrderService thirdPayOrderService, ThirdAppPlusAliPayService thirdAppPlusAliPayService, - AlipayClient testAlipayClient) { - super(appPlusPayConfig, thirdPayOrderService, thirdAppPlusAliPayService); + AlipayClientContext testAlipayClient) { + super(appPlusPayConfig, thirdPayOrderService, thirdAppPlusAliPayService, new AlipayClientSupport(appPlusPayConfig)); this.testAlipayClient = testAlipayClient; } /** - * 返回测试注入的支付宝 SDK 客户端。 + * 返回测试注入的支付宝双模式上下文。 * - * @return 支付宝 SDK 客户端 Mock + * @return 支付宝双模式上下文 Mock */ @Override - protected AlipayClient getAlipayClient() { + protected AlipayClientContext getAlipayClient() { return testAlipayClient; } } diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusAliPayServiceImplTest.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusAliPayServiceImplTest.java index f9f7d5d7d..480c6dea6 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusAliPayServiceImplTest.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusAliPayServiceImplTest.java @@ -3,7 +3,8 @@ package com.bsd.pjcloud.thirdparty.service.impl.appplus; import cn.hutool.core.date.DatePattern; import cn.hutool.core.date.LocalDateTimeUtil; import cn.hutool.json.JSONUtil; -import com.alipay.api.AlipayClient; +import com.bsd.pjcloud.thirdparty.config.AlipayClientContext; +import com.bsd.pjcloud.thirdparty.config.AlipayClientSupport; import com.alipay.api.domain.AlipayTradeAppPayModel; import com.alipay.api.domain.AlipayTradeCloseModel; import com.alipay.api.domain.AlipayTradeOrderSettleModel; @@ -96,12 +97,13 @@ class ThirdAppPlusAliPayServiceImplTest { @Mock private ThirdPayOrderService thirdPayOrderService; + /** 网页/App 应用的双模式上下文 Mock,业务断言继续核对原始 SDK 请求内容。 */ @Mock - private AlipayClient alipayClient; + private AlipayClientContext alipayClient; /** 小程序应用客户端,与网页/移动客户端分开,防止测试掩盖应用路由错误。 */ @Mock - private AlipayClient miniAlipayClient; + private AlipayClientContext miniAlipayClient; /** * 分账订单服务 Mock,用于保存支付宝确认结算、平台抽佣和退分账快照。 @@ -324,7 +326,7 @@ class ThirdAppPlusAliPayServiceImplTest { appPlusPayConfig.getAliPayMiniConfig().setAlipayPublicKey(Base64.getEncoder().encodeToString(miniKeys.getPublic().getEncoded())); appPlusPayConfig.getAliPayConfig().setAlipayPublicKey(Base64.getEncoder().encodeToString(webKeys.getPublic().getEncoded())); String privateKey = Base64.getEncoder().encodeToString(miniKeys.getPrivate().getEncoded()); - ThirdAppPlusAliPayServiceImpl realService = new ThirdAppPlusAliPayServiceImpl(appPlusPayConfig, thirdPayOrderService); + ThirdAppPlusAliPayServiceImpl realService = new ThirdAppPlusAliPayServiceImpl(appPlusPayConfig, thirdPayOrderService, new AlipayClientSupport(appPlusPayConfig)); Map params = successCallbackParams(); params.put("app_id", appPlusPayConfig.getAliPayMiniConfig().getAppId()); params.put("sign_type", "RSA2"); @@ -1322,10 +1324,11 @@ class ThirdAppPlusAliPayServiceImplTest { private static final class TestableThirdAppPlusAliPayService extends ThirdAppPlusAliPayServiceImpl { - private final AlipayClient testAlipayClient; + /** 网页/App 应用上下文 Mock,替代渠道调用。 */ + private final AlipayClientContext testAlipayClient; /** 小程序场景专用客户端 Mock,用于确认操作没有误用旧应用客户端。 */ - private final AlipayClient testMiniAlipayClient; + private final AlipayClientContext testMiniAlipayClient; /** 被测小程序 AppID,仅用于为已解析的应用选择对应 Mock。 */ private final String testMiniAppId; @@ -1335,21 +1338,22 @@ class ThirdAppPlusAliPayServiceImplTest { /** 创建保持原 App 扩展点、同时区分网页/移动与小程序客户端的测试服务。 */ private TestableThirdAppPlusAliPayService(AppPlusPayConfig appPlusPayConfig, ThirdPayOrderService thirdPayOrderService, - AlipayClient testAlipayClient, AlipayClient testMiniAlipayClient) { - super(appPlusPayConfig, thirdPayOrderService); + AlipayClientContext testAlipayClient, AlipayClientContext testMiniAlipayClient) { + super(appPlusPayConfig, thirdPayOrderService, new AlipayClientSupport(appPlusPayConfig)); this.testAlipayClient = testAlipayClient; this.testMiniAlipayClient = testMiniAlipayClient; this.testMiniAppId = appPlusPayConfig.getAliPayMiniConfig().getAppId(); } + /** 返回网页/App 应用上下文 Mock,保留默认应用路由断言。 */ @Override - protected AlipayClient getAlipayClient() { + protected AlipayClientContext getAlipayClient() { return testAlipayClient; } /** 按生产解析方法选出的 AppID 返回独立 Mock,保留对支付单应用路由的真实校验。 */ @Override - protected AlipayClient getAlipayClient(AppPlusPayConfig.AliPayConfig config) { + protected AlipayClientContext getAlipayClient(AppPlusPayConfig.AliPayConfig config) { return testMiniAppId.equals(config.getAppId()) ? testMiniAlipayClient : super.getAlipayClient(config); } diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdMchFzRelationServiceImplTest.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdMchFzRelationServiceImplTest.java index ed5f314d1..355981097 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdMchFzRelationServiceImplTest.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdMchFzRelationServiceImplTest.java @@ -1,7 +1,8 @@ package com.bsd.pjcloud.thirdparty.service.impl.pay; import cn.hutool.json.JSONUtil; -import com.alipay.api.AlipayClient; +import com.bsd.pjcloud.thirdparty.config.AlipayClientContext; +import com.bsd.pjcloud.thirdparty.config.AlipayClientSupport; import com.alipay.api.domain.AlipayTradeRoyaltyRelationBindModel; import com.alipay.api.domain.AntMerchantExpandIndirectZftModifyModel; import com.alipay.api.domain.RoyaltyEntity; @@ -76,9 +77,10 @@ class ThirdMchFzRelationServiceImplTest { @Mock private RemoteMchService remoteMchService; - /** 支付宝 SDK 客户端 Mock,用于验证网关成功与业务结果码的组合语义。 */ + /** 支付宝双模式上下文 Mock,用于验证网关成功与业务结果码的组合语义。 */ + /** 商户资料和分账关系使用的双模式上下文 Mock。 */ @Mock - private AlipayClient alipayClient; + private AlipayClientContext alipayClient; /** 被测统一分账关系服务。 */ private ThirdMchFzRelationServiceImpl service; @@ -790,8 +792,8 @@ class ThirdMchFzRelationServiceImplTest { /** 固定返回 Mock 支付宝客户端的关系服务测试实现。 */ private static final class TestableThirdMchFzRelationServiceImpl extends ThirdMchFzRelationServiceImpl { - /** 当前测试共享的支付宝 SDK 客户端 Mock。 */ - private final AlipayClient testAlipayClient; + /** 当前测试共享的支付宝双模式上下文 Mock。 */ + private final AlipayClientContext testAlipayClient; /** * 创建可替换支付宝客户端的关系服务。 @@ -801,21 +803,21 @@ class ThirdMchFzRelationServiceImplTest { * @param profitsharingService 微信分账 SDK Mock * @param appPlusPayConfig 平台支付配置 * @param remoteMchService 商户内部接口 Mock - * @param testAlipayClient 支付宝 SDK 客户端 Mock + * @param testAlipayClient 支付宝双模式上下文 Mock */ private TestableThirdMchFzRelationServiceImpl(ThirdMchFzRelationMapper relationMapper, ThirdPaymentTaskEnqueuer taskEnqueuer, ProfitsharingService profitsharingService, AppPlusPayConfig appPlusPayConfig, RemoteMchService remoteMchService, - AlipayClient testAlipayClient) { - super(relationMapper, taskEnqueuer, profitsharingService, appPlusPayConfig, remoteMchService); + AlipayClientContext testAlipayClient) { + super(relationMapper, taskEnqueuer, profitsharingService, appPlusPayConfig, remoteMchService, new AlipayClientSupport(appPlusPayConfig)); this.testAlipayClient = testAlipayClient; } - /** 返回测试注入的支付宝 SDK 客户端,避免发起真实渠道请求。 */ + /** 返回测试注入的支付宝双模式上下文,避免发起真实渠道请求。 */ @Override - protected AlipayClient client(AppPlusPayConfig.AliPayConfig config) { + protected AlipayClientContext client(AppPlusPayConfig.AliPayConfig config) { return testAlipayClient; } }