diff --git a/pjcloud-thirdparty/docs/aggregate-h5.md b/pjcloud-thirdparty/docs/aggregate-h5.md index 89b4d66c..219f6031 100644 --- a/pjcloud-thirdparty/docs/aggregate-h5.md +++ b/pjcloud-thirdparty/docs/aggregate-h5.md @@ -7,7 +7,7 @@ ```yaml app-plus: pay: - # 原配置键保留:网页/移动应用,供既有 App 支付、当面付及历史交易使用。 + # 网页/移动应用:H5 网页授权与授权码交换使用此组;原 App 支付继续使用此组。 ali-pay-config: app-id: ${ALIPAY_WEB_APP_ID} app-private-key: ${ALIPAY_WEB_APP_PRIVATE_KEY} @@ -15,7 +15,7 @@ app-plus: base-callback-url: ${THIRD_PUBLIC_BASE_URL} # 网关公网地址,包含实际 /api 子路径 pay-callback-url: /third/third-app-pay/app/pay-callback refund-callback-url: /third/third-app-pay/app/refund-callback - # 小程序应用:聚合 OAuth、身份交换和 JSAPI 下单必须使用同一组凭据。 + # 小程序支付应用:聚合交易创建、付款通知和后续交易处理使用此组。 ali-pay-mini-config: app-id: ${ALIPAY_MINI_APP_ID} app-private-key: ${ALIPAY_MINI_APP_PRIVATE_KEY} @@ -43,9 +43,9 @@ app-plus: `h5-base-url` 只接受 HTTPS 基础地址,不能带查询参数、Hash 或用户信息。可以包含实际部署子路径;H5 的路由 base 需对应配置。 -支付宝三类应用各自使用配套的 AppID、应用私钥和支付宝公钥;不同组不能填写重复 AppID,也不能只更换 AppID 而沿用另一应用的密钥。聚合支付缺少小程序组时明确拒绝,不回退到旧 `ali-pay-config`;只启用微信的商户不要求配置支付宝小程序组。应用客户端在服务进程中缓存,更新应用凭据后需重启 third 的全部实例。 +支付宝按网页/移动应用、小程序、第三方应用分类保存配套 AppID、应用私钥和支付宝公钥。H5 不新增应用类型或配置组:授权链接及 `alipay.system.oauth.token` 使用原 `ali-pay-config`,聚合 `alipay.trade.create` 使用 `ali-pay-mini-config`,两类客户端分别缓存。配置重复 AppID 会造成凭据选择歧义,使用前会明确拒绝。只启用微信的商户不要求支付宝配置;支付宝聚合入口则要求这两组均完整。更新凭据后需重启 third 的全部实例。 -新支付单在已有 `mch_pay_setting_json` 中保存非敏感 `alipayAppId`,查单、撤销、退款、退款查询及结算/分账按这个 AppID 选择原应用;支付与退款回调先按已登记 AppID 验签,再校验是否与支付单一致。旧快照没有该标识时继续使用原网页/移动应用,不能把所有历史聚合订单直接切换到新小程序应用。本次不增加数据库字段。 +新支付单在已有 `mch_pay_setting_json` 中保存非敏感 `alipayAppId`,查单、撤销、退款、退款查询及结算/分账按这个 AppID 选择原应用;支付与退款回调先按已登记 AppID 验签,再校验是否与支付单一致。旧快照没有该标识时继续使用原网页/移动应用,不能把历史网页/移动应用订单直接切换到小程序支付应用。本次不增加数据库字段。 仅在网关/反向代理**覆盖客户端传入的 X-Real-IP**时,将其直连地址加入 `trusted-proxy-addresses`。默认使用直连 IP 限流。Redis 业务键继续由项目基础设施附加租户前缀,不手工拼接租户编号。 @@ -57,14 +57,28 @@ H5 只使用项目根目录一个 `.env`,变量命名沿用参考 App:开发 - 微信沿用服务商统一公众号 OAuth 与 JSAPI 下单配置,确认公众号网页授权域名对应现有后端 OAuth 回调域名。 - 将新 H5 的实际页面目录核对加入微信支付 JSAPI 支付授权目录;Hash 部分不作为目录填写。当前根目录部署对应 `https://baac9a029ea84f36ba28c023fed4fc5f.hd.takin.cc/`。 -- 支付宝聚合入口使用 `ali-pay-mini-config` 的 AppID 与回调基础地址,固定 OAuth 回调仍为 `/third/aggregate-order-pay/oauth/alipay`,租户通过一次性 state 恢复;需在对应小程序应用核对授权回调配置。不要把独立 H5 地址替换为后端回调地址。 +- 支付宝网页授权入口读取 `ali-pay-config` 的 AppID 与回调基础地址。固定 OAuth 回调为 `/third/aggregate-order-pay/oauth/alipay`,租户通过一次性 state 恢复;在这个网页/移动应用下配置“授权回调地址”,与该组 `base-callback-url + public-route-prefix + /aggregate-order-pay/oauth/alipay` 一致。交易创建的异步支付及退款通知地址则读取小程序支付组,通知验签后还需匹配支付单保存的实际下单 AppID。 - 两种渠道继续使用商户既有支付配置、子商户和默认分佣关系快照,配置需已具备相应 OAuth/JSAPI 权限。仅域名能访问不代表上述平台配置已完成。 支付宝 H5 通过 `alipay.trade.create` 创建交易并返回 `tradeNO`,前端使用 `AlipayJSBridge.tradePay` 调起支付。直付通结算参数沿用现有 `ThirdAppPlusAliPayServiceImpl.appPay`:始终携带 `sub_merchant.merchant_id`,以及 `settle_info.settle_period_time=1d` 和一条 `defaultSettle` 结算明细;明细金额为交易全额,单位为元,`trans_in` 留空。关闭平台抽佣仍保留这组结算参数,不在创建交易时扣减平台分佣。 -产品码保持当前 JSAPI 场景的 `JSAPI_PAY`,商户授权 Token 继续按已有关系快照和抽佣规则传递。`1d` 账期只在商户签约账期结算模式时有效;补齐结算参数后仍需验证应用及商户权限,不能据此认定 `40006 / isv.insufficient-isv-permissions` 已解除。 +聚合交易创建使用小程序支付组的 `JSAPI_PAY`;历史聚合快照中的 `FACE_TO_FACE_PAYMENT` 仍参与严格会话绑定及回调校验,不改写历史流水。原 App 的产品码、SDK 签名下单及默认配置保持不变。支付宝应用类型与产品权限分别管理,不能仅按分类名称认定已获得 API、直付通或当前客户端场景的支付权限。 -此次拆分只改变应用凭据选择,不创建支付宝小程序页面,也不把 H5 转换为小程序。仍需由对应应用具备实际运行场景的 OAuth、`alipay.trade.create` 和支付调起能力,并在客户端联调确认。直付通关系管理仍归原应用;复用二级商户前需确认两应用属于同一服务商且渠道允许使用该 smid。已有 `app_auth_token` 也不能视为自动适用于新应用,开启代调用时应核对其应用授权范围;配置凭据不代表新增渠道授权。 +### 授权应用与支付应用的用户身份 + +网页/移动应用用自己的密钥兑换自己发起的授权码,服务端身份对象记录这个来源 AppID;小程序支付不能用其密钥直接兑换另一应用的网页授权码。 + +跨应用下单只使用 OAuth 实际返回的、2088 开头且共 16 位的 `user_id`,传入 `buyer_id`,并要求支付应用当前仍支持该标识模式。如果只返回网页应用的 `open_id`,后端会拒绝直接向另一个应用传入 `buyer_open_id`,日志标记为 `聚合付款缺少跨应用user_id`。仅在身份来源 AppID 与下单 AppID 相同的情况下,才允许直接使用其 `open_id`。兼容身份构造器未记录来源应用时,也不能据此跨应用使用 `open_id`。 + +这意味着两组密钥均已配置仍不等于身份可以互通:若网页授权仅返回 `open_id`,需核对应用的用户标识模式,或完成支付宝官方支持的应用关联及身份打通;本版本不伪造、推算或擅自转换用户标识,也不依靠前端提供付款人 ID。 + +### 直付通关系及渠道联调 + +直付通关系管理仍归原网页/移动应用;跨应用复用二级商户前,确认两应用属于相同服务商且渠道允许该小程序支付应用使用此 smid。关系中的 `app_auth_token` 也不能视为自动适用于另一个应用,开启代调用时应核对授权范围。本版本保留既有关系快照及抽佣规则,配置分工本身不会新增渠道权限。 + +前端仍在支付宝客户端的独立 H5 内通过 `AlipayJSBridge.tradePay` 调起交易;需以实际支付应用、产品签约、用户标识模式与客户端场景联合验证,不能把当前实现当作任意小程序产品与 H5 容器通用的证明。 + +参考:[支付宝网页授权与授权码交换](https://developer.alibaba.com/docs/doc.htm?articleId=105656&docType=1&treeId=346)、[支付宝官方聚合 H5/小程序流程对照](https://developer.alibaba.com/docs/doc.htm?articleId=109734&docType=1&treeId=598)。当前 SDK `AlipayTradeCreateModel.buyerId` 文档说明用户号格式及授权来源,身份模式与跨应用能力仍以当前应用实际权限为准。 ## 发布顺序与兼容 @@ -82,6 +96,12 @@ H5 只使用项目根目录一个 `.env`,变量命名沿用参考 App:开发 ## 接口与故障恢复 +### 支付宝 E004 授权回调白名单排查 + +出现 `AUTH_REDIRECT_URL_NOT_IN_WHITE` 时,先查看 third 的 `聚合收银支付宝网页授权` 日志。该日志只输出本次实际使用的配置组、`appId` 和静态 `redirectUri`,不输出一次性 state、授权码、付款人标识、Token 或密钥。按这两个实际值与同一应用的“授权回调地址”逐项核对,避免拿 Nacos 编辑页面或另一个 AppID 的白名单代替实际请求证据。 + +若 `base-callback-url` 为 `https://api.example.com/api`,微服务前缀为 `/third`,完整 OAuth 回调就是 `https://api.example.com/api/third/aggregate-order-pay/oauth/alipay`;不要把网关前缀重复写入基础地址,也不要将 H5 Hash 页面或支付异步通知路径填在授权回调处。确认线上所有 third 实例均已更新配置,并在开放平台修改生效后重新扫码。 + - 匿名查询:`GET /third/aggregate-merchant-pay/merchant?id=...&tenantId=...&v=1&sign=...`,返回商户 id/name/logoPic 和 `supportedPlatforms`(10 微信、20 支付宝),不返回支付配置。 - 线下创建:`POST /third/aggregate-merchant-pay/create`,提交相同签名参数、整数分 amount 和 UUID requestId。重试不能修改金额或生成新 requestId。 - 现有聚合入口:`/third/aggregate-order-pay/cashier/{tenantCode}/{token}`。两个客户端分别进入各自 OAuth;已确认终态的旧请求直接恢复结果页,不再下单。 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 7792d9d4..fbc3dfe5 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 @@ -22,14 +22,27 @@ public class AppPlusPayConfig { /** 网页/移动应用凭据;保留 ali-pay-config 配置键及原访问方法,兼容既有 App 和历史订单。 */ private AliPayConfig AliPayConfig; - /** 小程序应用凭据,配置键为 ali-pay-mini-config;聚合 JSAPI 不得回退到网页/移动应用。 */ + /** 小程序应用凭据,配置键为 ali-pay-mini-config;用于聚合交易下单,不用于 H5 网页授权。 */ private AliPayConfig aliPayMiniConfig; /** 第三方应用凭据,配置键为 ali-pay-third-party-config;独立预留,不自动替代支付应用。 */ private AliPayConfig aliPayThirdPartyConfig; /** - * 读取聚合 OAuth 和 JSAPI 共用的小程序应用,缺少任一支付必需项时拒绝发起收银。 + * 读取现有网页/移动应用供 H5 网页授权使用;授权码必须由发起授权的同一应用换取身份。 + * + * @return 默认网页/移动应用凭据及 OAuth 回调基础地址,不新增配置组 + */ + public AliPayConfig requireAliPayWebConfig() { + if (AliPayConfig == null || StrUtil.hasBlank(AliPayConfig.getAppId(), AliPayConfig.getAppPrivateKey(), + AliPayConfig.getAlipayPublicKey(), AliPayConfig.getBaseCallbackUrl())) { + throw new IllegalStateException("支付宝网页授权配置不完整,请配置网页/移动应用 app-plus.pay.ali-pay-config"); + } + return findAliPayConfig(AliPayConfig.getAppId()); + } + + /** + * 读取聚合下单使用的小程序应用完整凭据;不能代替网页/移动应用完成网页授权。 * * @return 完整的小程序应用凭据与支付回调地址 */ @@ -38,12 +51,12 @@ public class AppPlusPayConfig { aliPayMiniConfig.getAlipayPublicKey(), aliPayMiniConfig.getBaseCallbackUrl(), aliPayMiniConfig.getPayCallbackUrl())) { throw new IllegalStateException("支付宝小程序支付配置不完整,请配置 app-plus.pay.ali-pay-mini-config"); } - // 同一 AppID 出现在多类配置中会导致回调无法唯一选取公钥,创建支付前即拒绝该配置。 + // 同一 AppID 出现在多组配置中会导致回调无法唯一选取公钥,使用前即拒绝该配置。 return findAliPayConfig(aliPayMiniConfig.getAppId()); } /** - * 在服务端已登记的三类应用中按 AppID 精确选取凭据;未知或空 AppID 不使用默认应用。 + * 在服务端已登记的各组应用中按 AppID 精确选取凭据;未知或空 AppID 不使用默认应用。 * * @param appId 支付单快照或待验签通知中的应用标识;通知仍须验签并核对订单 * @return 唯一匹配的应用;未知时返回 null,重复 AppID 配置时拒绝歧义 diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/model/pay/ThirdAggregateAlipayOauthIdentity.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/model/pay/ThirdAggregateAlipayOauthIdentity.java index 043dbcf2..6ee0e371 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/model/pay/ThirdAggregateAlipayOauthIdentity.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/model/pay/ThirdAggregateAlipayOauthIdentity.java @@ -5,10 +5,14 @@ import cn.hutool.core.util.StrUtil; /** * 支付宝用户授权换取的付款人身份。 * - *

新应用优先使用 open_id,兼容尚未返回 open_id 的存量应用 user_id。

+ *

记录授权来源应用,避免将网页应用的 open_id 直接用于另一个支付应用。 + * 跨应用下单只使用平台实际返回且支付应用支持的 user_id。

*/ public class ThirdAggregateAlipayOauthIdentity { + /** 换取此身份的服务端应用 AppID;兼容构造器未传入时为空,不能据此跨应用使用 open_id。 */ + private final String oauthAppId; + /** * 支付宝开放平台用户 open_id;接口未返回时为空。 */ @@ -20,16 +24,33 @@ public class ThirdAggregateAlipayOauthIdentity { private final String userId; /** - * 创建支付宝付款人身份值对象。 + * 兼容未记录授权来源应用的内部调用;仅有 open_id 时不能用于跨应用下单。 * * @param openId 支付宝 open_id,允许为空 * @param userId 支付宝 user_id,允许为空 */ public ThirdAggregateAlipayOauthIdentity(String openId, String userId) { + this(null, openId, userId); + } + + /** + * 创建带可信授权来源的身份值对象,来源 AppID 必须由服务端 OAuth 客户端决定。 + * + * @param oauthAppId 实际换取身份的支付宝应用 AppID + * @param openId 该应用下的 open_id,接口未返回时为空 + * @param userId 平台返回的 2088 开头用户号,接口未返回时为空 + */ + public ThirdAggregateAlipayOauthIdentity(String oauthAppId, String openId, String userId) { + this.oauthAppId = oauthAppId; this.openId = openId; this.userId = userId; } + /** 返回实际执行授权码交换的应用标识;为空表示身份来源应用未记录。 */ + public String getOauthAppId() { + return oauthAppId; + } + /** * 获取支付宝 open_id。 * 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 3f85c6bd..9ca72c22 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 @@ -48,7 +48,7 @@ import java.util.List; import java.util.Map; /** - * 支付宝聚合订单码 OAuth 与 JSAPI 支付实现。 + * 支付宝聚合收银:网页/移动应用负责 OAuth,小程序应用负责交易创建及付款通知。 */ @Service @Validated @@ -62,7 +62,7 @@ public class ThirdAggregateAliPayServiceImpl implements ThirdAggregateAliPayServ private static final String TAG = "支付宝聚合JSAPI支付"; /** - * 支付宝 JSAPI 支付固定产品码。 + * 聚合支付下单使用小程序应用签约的 JSAPI 产品码,不改变原 App 支付产品。 */ private static final String PRODUCT_CODE = "JSAPI_PAY"; @@ -111,10 +111,13 @@ public class ThirdAggregateAliPayServiceImpl implements ThirdAggregateAliPayServ private ThirdMchFzRelationService mchFzRelationService; /** - * 当前应用配置对应的支付宝客户端,延迟初始化并在请求间复用。 + * 小程序支付应用的客户端,延迟初始化并在聚合下单请求间复用。 */ private volatile AlipayClient alipayClient; + /** 网页/移动应用的 OAuth 客户端,独立缓存,不能用支付应用密钥兑换网页授权码。 */ + private volatile AlipayClient oauthAlipayClient; + /** * 使用一次性 OAuth 授权码换取支付宝付款人身份,不保存授权令牌。 * @@ -129,14 +132,15 @@ public class ThirdAggregateAliPayServiceImpl implements ThirdAggregateAliPayServ AlipaySystemOauthTokenRequest request = new AlipaySystemOauthTokenRequest(); request.setGrantType(OAUTH_GRANT_TYPE); request.setCode(authCode); + String oauthAppId = appPlusPayConfig.requireAliPayWebConfig().getAppId(); try { - AlipaySystemOauthTokenResponse response = getAlipayClient().execute(request); + AlipaySystemOauthTokenResponse response = getOauthAlipayClient().execute(request); if (response == null || !response.isSuccess()) { String errorCode = response == null ? "EMPTY_RESPONSE" : StrUtil.firstNonBlank(response.getSubCode(), response.getCode(), "UNKNOWN"); log.warn("|{}|支付宝用户授权失败,errorCode:{}", TAG, errorCode); throw new I18nBizException(ErrorCodes.THIRD_ALIPAY_OAUTH_FAIL); } - ThirdAggregateAlipayOauthIdentity identity = new ThirdAggregateAlipayOauthIdentity(response.getOpenId(), response.getUserId()); + ThirdAggregateAlipayOauthIdentity identity = new ThirdAggregateAlipayOauthIdentity(oauthAppId, response.getOpenId(), response.getUserId()); if (StrUtil.isBlank(identity.getStableIdentity())) { throw new I18nBizException(ErrorCodes.THIRD_ALIPAY_OAUTH_NO_PAYER); } @@ -161,10 +165,12 @@ public class ThirdAggregateAliPayServiceImpl implements ThirdAggregateAliPayServ public ThirdAggregateCashierLaunch createPay(ThirdAggregatePayCreateContext context, MchPaySettingVo paySetting, ThirdAggregateAlipayOauthIdentity identity) { MchPaySettingEntity.AliPaySetting aliPaySetting = parseAliPaySetting(paySetting); + AppPlusPayConfig.AliPayConfig aliPayConfig = appPlusPayConfig.requireAliPayMiniConfig(); + AlipayTradeCreateModel model = new AlipayTradeCreateModel(); + applyBuyerIdentity(model, identity, aliPayConfig.getAppId()); validateFzRelation(context, paySetting); ThirdPayOrderEntity payOrder = buildPayOrder(context, paySetting); - AlipayTradeCreateModel model = new AlipayTradeCreateModel(); String totalAmount = fenToYuan(context.getAmount()); model.setOutTradeNo(context.getPayNo()); model.setTotalAmount(totalAmount); @@ -184,14 +190,7 @@ public class ThirdAggregateAliPayServiceImpl implements ThirdAggregateAliPayServ directPaySettleInfo.setSettlePeriodTime(DIRECT_PAY_SETTLE_PERIOD_TIME); directPaySettleInfo.setSettleDetailInfos(List.of(defaultSettleDetailInfo)); model.setSettleInfo(directPaySettleInfo); - if (StrUtil.isNotBlank(identity.getOpenId())) { - model.setBuyerOpenId(identity.getOpenId()); - } else { - model.setBuyerId(identity.getUserId()); - } - AlipayTradeCreateRequest request = new AlipayTradeCreateRequest(); - AppPlusPayConfig.AliPayConfig aliPayConfig = appPlusPayConfig.requireAliPayMiniConfig(); request.setNotifyUrl(aliPayConfig.getBaseCallbackUrl() + aliPayConfig.getPayCallbackUrl()); request.setBizModel(model); // 关闭分账不携带平台抽佣专属授权;开启分账且配置了代调用授权时才发送 token。 @@ -260,21 +259,12 @@ public class ThirdAggregateAliPayServiceImpl implements ThirdAggregateAliPayServ /** * 获取延迟初始化的支付宝客户端。 * - * @return 使用小程序应用的 AppID、应用私钥和支付宝公钥初始化的客户端 + * @return 使用小程序支付应用 AppID 与配套密钥初始化的客户端 */ protected synchronized AlipayClient getAlipayClient() { if (alipayClient == null) { try { - AppPlusPayConfig.AliPayConfig config = appPlusPayConfig.requireAliPayMiniConfig(); - 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"); - alipayClient = new DefaultAlipayClient(sdkConfig); + alipayClient = createAlipayClient(appPlusPayConfig.requireAliPayMiniConfig()); } catch (Exception exception) { throw new I18nBizException(ErrorCodes.THIRD_ALIPAY_CLIENT_INIT_FAIL); } @@ -282,6 +272,65 @@ public class ThirdAggregateAliPayServiceImpl implements ThirdAggregateAliPayServ return alipayClient; } + /** 获取网页/移动应用的 OAuth 客户端,始终与授权链接使用同一组凭据。 */ + protected synchronized AlipayClient getOauthAlipayClient() { + if (oauthAlipayClient == null) { + try { + oauthAlipayClient = createAlipayClient(appPlusPayConfig.requireAliPayWebConfig()); + } catch (Exception exception) { + throw new I18nBizException(ErrorCodes.THIRD_ALIPAY_CLIENT_INIT_FAIL); + } + } + return oauthAlipayClient; + } + + /** + * 仅以调用方明确指定的服务端配置创建 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); + } + + /** + * 绑定付款人:跨应用只使用平台真实返回的全局用户号,同应用才允许直接使用 open_id。 + * + * @param model 即将发送的交易创建模型 + * @param identity 服务端 OAuth 换取的付款人身份,不能由 H5 自行提供 + * @param payAppId 本次下单的小程序应用 AppID + */ + private void applyBuyerIdentity(AlipayTradeCreateModel model, ThirdAggregateAlipayOauthIdentity identity, String payAppId) { + if (identity == null) { + throw new I18nBizException(ErrorCodes.THIRD_ALIPAY_OAUTH_NO_PAYER); + } + if (StrUtil.isNotBlank(identity.getUserId())) { + if (!identity.getUserId().matches("2088[0-9]{12}")) { + throw new IllegalStateException("支付宝授权返回的user_id格式无效,不能作为buyer_id下单"); + } + model.setBuyerId(identity.getUserId()); + return; + } + if (StrUtil.isNotBlank(identity.getOpenId()) && payAppId.equals(identity.getOauthAppId())) { + model.setBuyerOpenId(identity.getOpenId()); + return; + } + // 不记录用户标识,仅说明是哪两个应用及身份模式不匹配,避免错误跨应用传入 buyer_open_id。 + log.warn("|{}|聚合付款缺少跨应用user_id,oauthAppId:{},payAppId:{},hasOpenId:{}", TAG, + identity.getOauthAppId(), payAppId, StrUtil.isNotBlank(identity.getOpenId())); + throw new IllegalStateException("支付宝网页授权仅返回应用级open_id,跨应用支付需要可用user_id或完成官方支持的身份打通"); + } + /** * 构建不含付款人标识和商户授权令牌的请求快照。 * diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateOrderPayServiceImpl.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateOrderPayServiceImpl.java index 5d38a661..9e6aec51 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateOrderPayServiceImpl.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateOrderPayServiceImpl.java @@ -391,7 +391,10 @@ public class ThirdAggregateOrderPayServiceImpl implements ThirdAggregateOrderPay + "&response_type=code&scope=" + WECHAT_OAUTH_SCOPE + "&state=" + encode(state) + "#wechat_redirect"; } - return ALIPAY_OAUTH_URL + "?app_id=" + encode(appPlusPayConfig.requireAliPayMiniConfig().getAppId()) + AppPlusPayConfig.AliPayConfig alipay = appPlusPayConfig.requireAliPayWebConfig(); + // 只记录本次实际使用的应用和静态回调地址,不记录包含一次性 state 的完整授权链接。 + log.info("聚合收银支付宝网页授权,configKey:ali-pay-config,appId:[{}],redirectUri:[{}]", alipay.getAppId(), callbackUrl); + return ALIPAY_OAUTH_URL + "?app_id=" + encode(alipay.getAppId()) + "&scope=" + ALIPAY_OAUTH_SCOPE + "&redirect_uri=" + encode(callbackUrl) + "&state=" + encode(state); @@ -1212,6 +1215,7 @@ public class ThirdAggregateOrderPayServiceImpl implements ThirdAggregateOrderPay */ private void validateApplicationConfig(AppPlusPlatformTypeEnum platform) { if (platform == AppPlusPlatformTypeEnum.ALI_PAY) { + appPlusPayConfig.requireAliPayWebConfig(); appPlusPayConfig.requireAliPayMiniConfig(); } AppPlusPayConfig.WechatConfig wechat = appPlusPayConfig.getWechatConfig(); @@ -1353,17 +1357,13 @@ public class ThirdAggregateOrderPayServiceImpl implements ThirdAggregateOrderPay /** * 获取聚合二维码和状态查询共用的公网基础地址。 * - * @return 优先保持旧入口基础地址;未配置旧应用时使用已配置的小程序或微信回调基础地址 + * @return 优先保持原网页/移动应用的入口地址;仅微信场景可使用微信回调基础地址 */ private String getPublicBaseUrl() { AppPlusPayConfig.AliPayConfig original = appPlusPayConfig.getAliPayConfig(); if (original != null && StrUtil.isNotBlank(original.getBaseCallbackUrl())) { return normalizeBaseUrl(original.getBaseCallbackUrl()); } - AppPlusPayConfig.AliPayConfig mini = appPlusPayConfig.getAliPayMiniConfig(); - if (mini != null && StrUtil.isNotBlank(mini.getBaseCallbackUrl())) { - return normalizeBaseUrl(mini.getBaseCallbackUrl()); - } return normalizeBaseUrl(appPlusPayConfig.getWechatConfig() == null ? null : appPlusPayConfig.getWechatConfig().getBaseCallbackUrl()); } @@ -1377,7 +1377,7 @@ public class ThirdAggregateOrderPayServiceImpl implements ThirdAggregateOrderPay if (ObjectUtil.equals(platform, AppPlusPlatformTypeEnum.WECHAT)) { return normalizeBaseUrl(appPlusPayConfig.getWechatConfig().getBaseCallbackUrl()); } - return normalizeBaseUrl(appPlusPayConfig.requireAliPayMiniConfig().getBaseCallbackUrl()); + return normalizeBaseUrl(appPlusPayConfig.requireAliPayWebConfig().getBaseCallbackUrl()); } /** 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 46b8eb90..56dc2636 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 @@ -107,6 +107,9 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService */ private static final String ORDER_CODE_PRODUCT_CODE = "QR_CODE_OFFLINE"; + /** 历史聚合单保存的当面付产品码,回调兼容时不得与原订单码产品码混用。 */ + private static final String LEGACY_AGGREGATE_FACE_PRODUCT_CODE = "FACE_TO_FACE_PAYMENT"; + /** * 订单码交易标题,固定安全文案避免把业务隐私传给第三方平台。 */ @@ -2048,10 +2051,10 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService } /** - * 判断内部订单请求快照是否明确标记为支付宝聚合 JSAPI 子订单。 + * 识别独立 H5 当面付及历史聚合 JSAPI 请求快照,后续仍须严格校验会话与订单绑定。 * * @param payOrder 回调商户订单号对应的内部支付订单 - * @return 请求快照同时包含聚合标记和 {@code JSAPI_PAY} 产品码时返回 {@code true} + * @return 请求快照包含聚合标记,且产品码为当面付或历史 {@code JSAPI_PAY} 时返回 {@code true} */ private boolean isAggregatePayRequest(ThirdPayOrderEntity payOrder) { if (payOrder == null || StrUtil.isBlank(payOrder.getPayRep())) { @@ -2060,7 +2063,8 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService try { JSONObject request = JSONUtil.parseObj(payOrder.getPayRep()); return Boolean.TRUE.equals(request.getBool("aggregatePay")) - && ObjectUtil.equals(request.getStr("productCode"), "JSAPI_PAY"); + && (ObjectUtil.equals(request.getStr("productCode"), LEGACY_AGGREGATE_FACE_PRODUCT_CODE) + || ObjectUtil.equals(request.getStr("productCode"), "JSAPI_PAY")); } catch (Exception exception) { return false; } 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 234264e2..9cccb43f 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 @@ -46,11 +46,15 @@ import static org.mockito.Mockito.when; class ThirdAggregateAliPayServiceImplTest { /** - * 支付宝 SDK 客户端 Mock,用于捕获 OAuth 和交易创建请求。 + * 小程序支付 SDK 客户端 Mock,仅用于捕获聚合交易创建请求。 */ @Mock private AlipayClient alipayClient; + /** 网页/移动应用 OAuth 客户端,必须与支付 SDK Mock 隔离以检查凭据选择。 */ + @Mock + private AlipayClient oauthAlipayClient; + /** * 第三方支付订单服务 Mock,用于验证子订单快照和保存失败补偿。 */ @@ -101,9 +105,12 @@ class ThirdAggregateAliPayServiceImplTest { AppPlusPayConfig.AliPayConfig webApplication = new AppPlusPayConfig.AliPayConfig(); webApplication.setAppId("2026000000000001"); webApplication.setBaseCallbackUrl("https://pay.example.com"); + webApplication.setAppPrivateKey("web-test-private-key"); + webApplication.setAlipayPublicKey("web-test-public-key"); + webApplication.setPayCallbackUrl("/third-app-pay/app/pay-callback"); appPlusPayConfig.setAliPayConfig(webApplication); service = new TestableThirdAggregateAliPayService(appPlusPayConfig, thirdPayOrderService, - thirdAppPlusAliPayService, alipayClient); + thirdAppPlusAliPayService, alipayClient, oauthAlipayClient); ReflectionTestUtils.setField(service, "mchFzRelationService", mchFzRelationService); context = new ThirdAggregatePayCreateContext(); @@ -132,20 +139,22 @@ class ThirdAggregateAliPayServiceImplTest { AlipaySystemOauthTokenResponse response = new AlipaySystemOauthTokenResponse(); response.setCode("10000"); response.setOpenId("alipay-open-id"); - when(alipayClient.execute(any(AlipaySystemOauthTokenRequest.class))).thenReturn(response); + when(oauthAlipayClient.execute(any(AlipaySystemOauthTokenRequest.class))).thenReturn(response); ThirdAggregateAlipayOauthIdentity identity = service.exchangeOauthCode("one-time-auth-code"); Assertions.assertEquals("alipay-open-id", identity.getStableIdentity()); ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(AlipaySystemOauthTokenRequest.class); - verify(alipayClient).execute(requestCaptor.capture()); + verify(oauthAlipayClient).execute(requestCaptor.capture()); + verify(alipayClient, never()).execute(any(AlipaySystemOauthTokenRequest.class)); + Assertions.assertEquals("2026000000000001", identity.getOauthAppId()); Assertions.assertEquals("authorization_code", requestCaptor.getValue().getGrantType()); Assertions.assertEquals("one-time-auth-code", requestCaptor.getValue().getCode()); } /** - * 验证 trade.create 使用 JSAPI_PAY、全额直付通结算、付款人 open_id 和授权 Token。 + * 验证 trade.create 使用 JSAPI_PAY、全额直付通结算、网页授权 user_id 和授权 Token。 * * @throws Exception Mock 支付宝客户端声明的受检异常 */ @@ -159,7 +168,7 @@ class ThirdAggregateAliPayServiceImplTest { when(thirdPayOrderService.save(any(ThirdPayOrderEntity.class))).thenReturn(true); ThirdAggregateCashierLaunch result = service.createPay(context, paySetting, - new ThirdAggregateAlipayOauthIdentity("alipay-open-id", null)); + webIdentity()); ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(AlipayTradeCreateRequest.class); verify(alipayClient).execute(requestCaptor.capture()); @@ -168,7 +177,8 @@ class ThirdAggregateAliPayServiceImplTest { Assertions.assertEquals("JSAPI_PAY", model.getProductCode()); Assertions.assertEquals("12.34", model.getTotalAmount()); Assertions.assertEquals("smid-1001", model.getSubMerchant().getMerchantId()); - Assertions.assertEquals("alipay-open-id", model.getBuyerOpenId()); + Assertions.assertEquals("2088000000000001", model.getBuyerId()); + Assertions.assertNull(model.getBuyerOpenId()); Assertions.assertEquals("merchant-auth-token", request.getTextParams().get("app_auth_token")); Assertions.assertEquals("2026000000000002", result.getPayParams().get("tradeNO")); Assertions.assertEquals("https://mini-pay.example.com/third-app-pay/app/pay-callback", request.getNotifyUrl()); @@ -185,7 +195,7 @@ class ThirdAggregateAliPayServiceImplTest { Assertions.assertFalse(snapshot.containsKey("appPrivateKey")); } - /** 缺少小程序凭据时禁止使用已配置的网页/移动应用替代 JSAPI 下单。 */ + /** 缺少小程序支付配置时禁止使用网页/移动应用代替聚合下单。 */ @Test void createPayShouldRejectMissingMiniApplication() throws Exception { appPlusPayConfig.setAliPayMiniConfig(null); @@ -197,6 +207,63 @@ class ThirdAggregateAliPayServiceImplTest { verify(thirdPayOrderService, never()).save(any(ThirdPayOrderEntity.class)); } + /** 网页应用只有 open_id 时,不允许将其直接交给另一小程序应用下单。 */ + @Test + void createPayShouldRejectCrossApplicationOpenIdWithoutUserId() throws Exception { + ThirdAggregateAlipayOauthIdentity identity = new ThirdAggregateAlipayOauthIdentity( + appPlusPayConfig.getAliPayConfig().getAppId(), "web-app-open-id", null); + + IllegalStateException exception = Assertions.assertThrows(IllegalStateException.class, + () -> service.createPay(context, paySetting, identity)); + + Assertions.assertTrue(exception.getMessage().contains("user_id")); + verify(alipayClient, never()).execute(any(AlipayTradeCreateRequest.class)); + verify(thirdPayOrderService, never()).save(any(ThirdPayOrderEntity.class)); + } + + /** 仅在身份确实来自本次下单应用时,才允许使用其 open_id 创建交易。 */ + @Test + void createPayShouldAllowOpenIdFromThePaymentApplication() throws Exception { + AlipayTradeCreateResponse response = new AlipayTradeCreateResponse(); + response.setCode("10000"); + response.setTradeNo("2026000000000088"); + when(alipayClient.execute(any(AlipayTradeCreateRequest.class))).thenReturn(response); + when(thirdPayOrderService.save(any(ThirdPayOrderEntity.class))).thenReturn(true); + ThirdAggregateAlipayOauthIdentity identity = new ThirdAggregateAlipayOauthIdentity( + appPlusPayConfig.getAliPayMiniConfig().getAppId(), "mini-app-open-id", null); + + service.createPay(context, paySetting, identity); + + ArgumentCaptor captor = ArgumentCaptor.forClass(AlipayTradeCreateRequest.class); + verify(alipayClient).execute(captor.capture()); + AlipayTradeCreateModel model = (AlipayTradeCreateModel) captor.getValue().getBizModel(); + Assertions.assertEquals("mini-app-open-id", model.getBuyerOpenId()); + Assertions.assertNull(model.getBuyerId()); + } + + /** 非 2088 开头的有效用户号不能伪装成全局 user_id,不能以错误身份发起真实下单。 */ + @Test + void createPayShouldRejectMalformedGlobalUserId() throws Exception { + ThirdAggregateAlipayOauthIdentity identity = new ThirdAggregateAlipayOauthIdentity( + appPlusPayConfig.getAliPayConfig().getAppId(), "web-app-open-id", "not-a-user-id"); + + Assertions.assertThrows(IllegalStateException.class, () -> service.createPay(context, paySetting, identity)); + + verify(alipayClient, never()).execute(any(AlipayTradeCreateRequest.class)); + verify(thirdPayOrderService, never()).save(any(ThirdPayOrderEntity.class)); + } + + /** 网页授权配置缺失时不尝试以小程序密钥兑换授权码。 */ + @Test + void exchangeOauthCodeShouldNeverFallBackToMiniCredentials() throws Exception { + appPlusPayConfig.setAliPayConfig(null); + + Assertions.assertThrows(IllegalStateException.class, () -> service.exchangeOauthCode("one-time-auth-code")); + + verify(oauthAlipayClient, never()).execute(any(AlipaySystemOauthTokenRequest.class)); + verify(alipayClient, never()).execute(any(AlipaySystemOauthTokenRequest.class)); + } + /** * 验证商户没有配置授权 Token 时请求中完全不发送 app_auth_token,且不降级为另一套支付流程。 * @@ -213,7 +280,7 @@ class ThirdAggregateAliPayServiceImplTest { when(alipayClient.execute(any(AlipayTradeCreateRequest.class))).thenReturn(response); when(thirdPayOrderService.save(any(ThirdPayOrderEntity.class))).thenReturn(true); - service.createPay(context, paySetting, new ThirdAggregateAlipayOauthIdentity("alipay-open-id", null)); + service.createPay(context, paySetting, webIdentity()); ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(AlipayTradeCreateRequest.class); verify(alipayClient).execute(requestCaptor.capture()); @@ -241,14 +308,14 @@ class ThirdAggregateAliPayServiceImplTest { when(alipayClient.execute(any(AlipayTradeCreateRequest.class))).thenReturn(response); when(thirdPayOrderService.save(any(ThirdPayOrderEntity.class))).thenReturn(true); - service.createPay(context, paySetting, new ThirdAggregateAlipayOauthIdentity(null, "alipay-user-id")); + service.createPay(context, paySetting, new ThirdAggregateAlipayOauthIdentity(null, "2088000000000001")); ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(AlipayTradeCreateRequest.class); verify(alipayClient).execute(requestCaptor.capture()); AlipayTradeCreateRequest request = requestCaptor.getValue(); AlipayTradeCreateModel model = (AlipayTradeCreateModel) request.getBizModel(); Assertions.assertEquals("smid-1001", model.getSubMerchant().getMerchantId()); - Assertions.assertEquals("alipay-user-id", model.getBuyerId()); + Assertions.assertEquals("2088000000000001", model.getBuyerId()); Assertions.assertFalse(request.getTextParams().containsKey("app_auth_token")); assertDirectPaySettlement(request, "0.01"); @@ -291,7 +358,7 @@ class ThirdAggregateAliPayServiceImplTest { RuntimeException exception = Assertions.assertThrows(RuntimeException.class, () -> service.createPay(context, paySetting, - new ThirdAggregateAlipayOauthIdentity("alipay-open-id", null))); + webIdentity())); Assertions.assertTrue(exception.getMessage().contains("分佣关系尚未绑定")); verify(alipayClient, never()).execute(any(AlipayTradeCreateRequest.class)); @@ -312,7 +379,7 @@ class ThirdAggregateAliPayServiceImplTest { when(thirdPayOrderService.save(any(ThirdPayOrderEntity.class))).thenReturn(false); Assertions.assertThrows(RuntimeException.class, () -> service.createPay(context, paySetting, - new ThirdAggregateAlipayOauthIdentity("alipay-open-id", null))); + webIdentity())); ArgumentCaptor snapshotCaptor = ArgumentCaptor.forClass(String.class); verify(thirdAppPlusAliPayService).cancel(eq(context.getPayNo()), snapshotCaptor.capture()); @@ -320,6 +387,12 @@ class ThirdAggregateAliPayServiceImplTest { .getStr(AppPlusPayConfig.ALIPAY_APP_ID_SNAPSHOT_KEY)); } + /** 网页授权与小程序下单跨应用时只携带平台真实返回的全局 user_id。 */ + private ThirdAggregateAlipayOauthIdentity webIdentity() { + return new ThirdAggregateAlipayOauthIdentity(appPlusPayConfig.getAliPayConfig().getAppId(), + "alipay-open-id", "2088000000000001"); + } + /** * 固定使用 Mock 支付宝客户端的可测试服务实现。 */ @@ -330,20 +403,25 @@ class ThirdAggregateAliPayServiceImplTest { */ private final AlipayClient testAlipayClient; + /** 网页授权专用客户端 Mock,与聚合下单客户端分离。 */ + private final AlipayClient testOauthAlipayClient; + /** * 创建可替换 SDK 客户端的支付宝聚合服务。 * * @param appPlusPayConfig 测试应用配置 * @param thirdPayOrderService 支付订单服务 Mock * @param thirdAppPlusAliPayService 旧支付宝查询撤销服务 Mock - * @param testAlipayClient 支付宝 SDK 客户端 Mock + * @param testAlipayClient 小程序支付客户端 Mock + * @param testOauthAlipayClient 网页/移动应用 OAuth 客户端 Mock */ private TestableThirdAggregateAliPayService(AppPlusPayConfig appPlusPayConfig, ThirdPayOrderService thirdPayOrderService, ThirdAppPlusAliPayService thirdAppPlusAliPayService, - AlipayClient testAlipayClient) { + AlipayClient testAlipayClient, AlipayClient testOauthAlipayClient) { super(appPlusPayConfig, thirdPayOrderService, thirdAppPlusAliPayService); this.testAlipayClient = testAlipayClient; + this.testOauthAlipayClient = testOauthAlipayClient; } /** @@ -355,5 +433,11 @@ class ThirdAggregateAliPayServiceImplTest { protected AlipayClient getAlipayClient() { return testAlipayClient; } + + /** 返回网页授权专用客户端,不允许由支付客户端兑换授权码。 */ + @Override + protected AlipayClient getOauthAlipayClient() { + return testOauthAlipayClient; + } } } diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateOrderPayServiceImplTest.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateOrderPayServiceImplTest.java index 3d6b94c9..22b60c42 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateOrderPayServiceImplTest.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateOrderPayServiceImplTest.java @@ -264,9 +264,9 @@ class ThirdAggregateOrderPayServiceImplTest { verify(aggregatePaySessionService, times(2)).save(any(ThirdAggregatePaySessionEntity.class)); } - /** 旧键与新增两类配置必须通过真实 Spring 属性绑定分别落到对应字段。 */ + /** 网页/移动应用、小程序和三方应用必须通过真实 Spring 属性绑定相互隔离。 */ @Test - void alipayConfigurationShouldBindThreeApplicationTypes() { + void alipayConfigurationShouldBindExistingApplicationTypes() { Binder binder = new Binder(new MapConfigurationPropertySource(Map.of( "app-plus.pay.ali-pay-config.app-id", "web-mobile-app", "app-plus.pay.ali-pay-mini-config.app-id", "mini-app", @@ -279,16 +279,16 @@ class ThirdAggregateOrderPayServiceImplTest { Assertions.assertEquals("third-party-app", config.getAliPayThirdPartyConfig().getAppId()); } - /** 应用组 AppID 重复会导致验签歧义,必须在进入小程序收银前拒绝。 */ + /** 应用组 AppID 重复会导致验签歧义,必须在进入 H5 收银前拒绝。 */ @Test - void duplicatedApplicationIdShouldRejectMiniCashier() { + void duplicatedApplicationIdShouldRejectCashier() { AppPlusPayConfig config = completeApplicationConfig(); config.getAliPayMiniConfig().setAppId(config.getAliPayConfig().getAppId()); Assertions.assertThrows(IllegalStateException.class, config::requireAliPayMiniConfig); } - /** 即使网页/移动应用配置完整,未配置小程序应用也不能创建支付宝聚合会话。 */ + /** 缺少小程序支付应用时不得使用网页/移动应用代替创建聚合会话。 */ @Test void createShouldRejectMissingMiniApplicationWithoutFallback() { AppPlusPayConfig config = (AppPlusPayConfig) ReflectionTestUtils.getField(service, "appPlusPayConfig"); @@ -303,6 +303,21 @@ class ThirdAggregateOrderPayServiceImplTest { verify(aggregatePaySessionService, never()).save(any(ThirdAggregatePaySessionEntity.class)); } + /** 小程序支付配置完整也不能代替缺失的网页/移动应用进行网页授权。 */ + @Test + void createShouldRejectMissingWebApplicationWithoutFallback() { + AppPlusPayConfig config = (AppPlusPayConfig) ReflectionTestUtils.getField(service, "appPlusPayConfig"); + config.setAliPayConfig(null); + when(aggregatePaySessionService.getLatestByBiz(request.getBizType(), request.getBizId())).thenReturn(null); + when(thirdPayOrderService.list(any(Wrapper.class))).thenReturn(List.of()); + when(remoteMchService.getById(request.getMchId())).thenReturn(R.ok(merchant)); + + IllegalStateException exception = Assertions.assertThrows(IllegalStateException.class, () -> service.create(request)); + + Assertions.assertTrue(exception.getMessage().contains("app-plus.pay.ali-pay-config")); + verify(aggregatePaySessionService, never()).save(any(ThirdAggregatePaySessionEntity.class)); + } + /** 两个渠道都停用时拒绝创建,避免产生没有付款入口的会话。 */ @Test void createShouldRejectMerchantWithoutEnabledPlatform() { @@ -327,7 +342,7 @@ class ThirdAggregateOrderPayServiceImplTest { verify(mchFzRelationService, never()).findBoundDefault(ali); } - /** 仅支付宝启用时校验小程序支付应用及既有直付通关系,微信应用缺省不影响签发。 */ + /** 仅支付宝启用时校验网页授权、小程序支付应用及既有直付通关系,微信应用缺省不影响签发。 */ @Test void merchantCodeShouldValidateOnlyEnabledAlipay() { MchPaySettingVo ali = merchant.getPaySettingList().get(0); @@ -365,9 +380,9 @@ class ThirdAggregateOrderPayServiceImplTest { String oauthUrl = service.getCashierOauthUrl("tenant-code", "cashier-access-token", "AlipayClient/10.6.80"); - Assertions.assertTrue(oauthUrl.contains("app_id=2026000000000002")); - Assertions.assertFalse(oauthUrl.contains("app_id=2026000000000001")); - Assertions.assertTrue(oauthUrl.contains("redirect_uri=https%3A%2F%2Fmini-pay.example.com%2Fthird%2Faggregate-order-pay%2Foauth%2Falipay")); + Assertions.assertTrue(oauthUrl.contains("app_id=2026000000000001")); + Assertions.assertFalse(oauthUrl.contains("app_id=2026000000000002")); + Assertions.assertTrue(oauthUrl.contains("redirect_uri=https%3A%2F%2Fpay.example.com%2Fthird%2Faggregate-order-pay%2Foauth%2Falipay")); Assertions.assertFalse(oauthUrl.contains("oauth%2Falipay%2Ftenant-code")); Assertions.assertTrue(oauthUrl.contains("state=dGVuYW50LWNvZGU.")); } 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 77090d58..d35da86e 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 @@ -120,7 +120,7 @@ class ThirdAppPlusAliPayServiceImplTest { private TestableThirdAppPlusAliPayService service; - /** 初始化两组独立应用凭据、SDK 客户端和原网页/移动应用的商户关系。 */ + /** 初始化网页/移动应用与小程序凭据及客户端,以及原 App 的直付通关系。 */ @BeforeEach void setUp() { appPlusPayConfig = new AppPlusPayConfig(); @@ -170,6 +170,8 @@ class ThirdAppPlusAliPayServiceImplTest { */ @Test void appPayShouldCreateDirectPayOrderWithoutMarkingPaymentSuccess() throws Exception { + // 原 App 下单不应依赖是否配置了小程序支付。 + appPlusPayConfig.setAliPayMiniConfig(null); AlipayTradeAppPayResponse response = new AlipayTradeAppPayResponse(); response.setBody("signed-alipay-order"); when(alipayClient.sdkExecute(any(AlipayTradeAppPayRequest.class))).thenReturn(response); @@ -281,7 +283,7 @@ class ThirdAppPlusAliPayServiceImplTest { verify(alipayClient, never()).execute(any(AlipayTradeSettleConfirmRequest.class)); } - /** 使用两组真实 RSA 测试密钥核对公钥选择,未知应用和跨应用签名均须被拒绝。 */ + /** 使用各应用真实 RSA 测试密钥核对公钥选择,未知应用和跨应用签名均须被拒绝。 */ @Test void callbackSignatureShouldUseRegisteredApplicationPublicKey() throws Exception { KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA"); @@ -917,10 +919,27 @@ class ThirdAppPlusAliPayServiceImplTest { */ @Test void payCallbackShouldAcceptBoundAggregateJsapiOrder() { + assertBoundAggregateCallback("JSAPI_PAY", miniApplicationSnapshot(), appPlusPayConfig.getAliPayMiniConfig().getAppId()); + } + + /** 历史当面付聚合子单仍需匹配应用、会话、业务订单及金额后才能确认成功。 */ + @Test + void payCallbackShouldAcceptLegacyFaceToFaceOrder() { + assertBoundAggregateCallback("FACE_TO_FACE_PAYMENT", miniApplicationSnapshot(), appPlusPayConfig.getAliPayMiniConfig().getAppId()); + } + + /** + * 对新旧聚合产品码复用完整支付回调约束,避免切换产品码后被错误拒绝或放宽会话校验。 + * + * @param productCode 支付创建时保存的当面付或历史 JSAPI 产品码 + * @param settingJson 含原应用 AppID 的支付单商户配置快照 + * @param appId 已验签通知中的应用标识,必须与支付单一致 + */ + private void assertBoundAggregateCallback(String productCode, String settingJson, String appId) { ThirdPayOrderEntity order = unpaidOrder(); - order.setMchPaySettingJson(miniApplicationSnapshot()); + order.setMchPaySettingJson(settingJson); order.setPayRep(JSONUtil.createObj().set("aggregatePay", true) - .set("productCode", "JSAPI_PAY").toString()); + .set("productCode", productCode).toString()); ThirdAggregatePaySessionEntity session = new ThirdAggregatePaySessionEntity(); session.setPayNo(order.getPayNo()); session.setSelectedPlatform(AppPlusPlatformTypeEnum.ALI_PAY.getType()); @@ -936,7 +955,7 @@ class ThirdAppPlusAliPayServiceImplTest { when(thirdPayOrderService.getById(order.getId())).thenReturn(paidOrder); Map params = successCallbackParams(); - params.put("app_id", appPlusPayConfig.getAliPayMiniConfig().getAppId()); + params.put("app_id", appId); ThirdAppPlusAliPayService.AliPayCallbackResult result = service.payCallback(params); Assertions.assertNotNull(result); @@ -966,6 +985,7 @@ class ThirdAppPlusAliPayServiceImplTest { appPlusPayConfig.getAliPayMiniConfig().getAppId()).toString(); } + private AlipayTradePrecreateResponse successPrecreateResponse(String qrCode) { AlipayTradePrecreateResponse response = new AlipayTradePrecreateResponse(); response.setCode("10000"); @@ -1123,9 +1143,10 @@ class ThirdAppPlusAliPayServiceImplTest { /** 被测小程序 AppID,仅用于为已解析的应用选择对应 Mock。 */ private final String testMiniAppId; + private boolean signatureValid = true; - /** 创建保持原 App 客户端扩展点、同时区分小程序客户端的测试服务。 */ + /** 创建保持原 App 扩展点、同时区分网页/移动与小程序客户端的测试服务。 */ private TestableThirdAppPlusAliPayService(AppPlusPayConfig appPlusPayConfig, ThirdPayOrderService thirdPayOrderService, AlipayClient testAlipayClient, AlipayClient testMiniAlipayClient) { super(appPlusPayConfig, thirdPayOrderService);