11
This commit is contained in:
1 parent
ba8e8894c5
commit
cf794ce6b6
9 files changed
+304
-77
No files matched your search
+27
-7
@@ -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;已确认终态的旧请求直接恢复结果页,不再下单。
|
||||
|
||||
+17
-4
@@ -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 配置时拒绝歧义
|
||||
|
||||
+23
-2
@@ -5,10 +5,14 @@ import cn.hutool.core.util.StrUtil;
|
||||
/**
|
||||
* 支付宝用户授权换取的付款人身份。
|
||||
*
|
||||
* <p>新应用优先使用 open_id,兼容尚未返回 open_id 的存量应用 user_id。</p>
|
||||
* <p>记录授权来源应用,避免将网页应用的 open_id 直接用于另一个支付应用。
|
||||
* 跨应用下单只使用平台实际返回且支付应用支持的 user_id。</p>
|
||||
*/
|
||||
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。
|
||||
*
|
||||
|
||||
+73
-24
@@ -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或完成官方支持的身份打通");
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建不含付款人标识和商户授权令牌的请求快照。
|
||||
*
|
||||
|
||||
+7
-7
@@ -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());
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+7
-3
@@ -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;
|
||||
}
|
||||
|
||||
+99
-15
@@ -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<AlipaySystemOauthTokenRequest> 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<AlipayTradeCreateRequest> 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<AlipayTradeCreateRequest> 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<AlipayTradeCreateRequest> 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<AlipayTradeCreateRequest> 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<String> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+24
-9
@@ -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."));
|
||||
}
|
||||
|
||||
+27
-6
@@ -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<String, String> 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);
|
||||
|
||||
Reference in new issue
Block a user