H5支付宝支付

This commit is contained in:
dzc committed 2026-10-28 18:49:22 +08:00
1 parent cf794ce6b6
commit 77ef3878af
21 files changed
+1259 -966

No files matched your search

+24 -29
View File
@@ -7,7 +7,7 @@
```yaml
app-plus:
pay:
# 网页/移动应用:H5 网页授权与授权码交换使用此组;原 App 支付继续使用此组。
# 网页/移动应用:聚合支付宝 WAP 签名及付款通知;原 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
# 小程序支付应用:聚合交易创建、付款通知和后续交易处理使用此组。
# 小程序及历史小程序交易凭据:当前 H5 新下单不读取此组;已有相关流水时保留原凭据。
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、应用私钥和支付宝公钥。H5 不新增应用类型或配置组:授权链接及 `alipay.system.oauth.token` 使用原 `ali-pay-config`,聚合 `alipay.trade.create` 使用 `ali-pay-mini-config`,两类客户端分别缓存。配置重复 AppID 会造成凭据选择歧义,使用前会明确拒绝。只启用微信的商户不要求支付宝配置;支付宝聚合入口则要求这两组均完整。更新凭据后需重启 third 的全部实例。
支付宝按网页/移动应用、小程序、第三方应用分类保存配套 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 验签,再校验是否与支付单一致。旧快照没有该标识时继续使用原网页/移动应用,不能把历史网页/移动应用订单直接切换到小程序支付应用。本次不增加数据库字段。
新支付单在已有 `mch_pay_setting_json` 中保存网页应用的非敏感 `alipayAppId`,查单、关单、退款、退款查询及结算/分账按这个 AppID 选择原应用;支付与退款回调先按已登记 AppID 验签,再校验是否与支付单一致。历史小程序流水仍按其快照选择原小程序凭据;旧快照没有该标识时继续使用原网页/移动应用。只启用微信的商户不要求支付宝配置。更新凭据或切换下单应用后需重启 third 的全部实例。本次不增加数据库字段,不改写历史订单。
仅在网关/反向代理**覆盖客户端传入的 X-Real-IP**时,将其直连地址加入 `trusted-proxy-addresses`。默认使用直连 IP 限流。Redis 业务键继续由项目基础设施附加租户前缀,不手工拼接租户编号。
@@ -57,28 +57,25 @@ H5 只使用项目根目录一个 `.env`,变量命名沿用参考 App:开发
- 微信沿用服务商统一公众号 OAuth 与 JSAPI 下单配置,确认公众号网页授权域名对应现有后端 OAuth 回调域名。
- 将新 H5 的实际页面目录核对加入微信支付 JSAPI 支付授权目录;Hash 部分不作为目录填写。当前根目录部署对应 `https://baac9a029ea84f36ba28c023fed4fc5f.hd.takin.cc/`。
- 支付宝网页授权入口读取 `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 权限。仅域名能访问不代表上述平台配置已完成。
- 支付宝新付款使用网页/移动应用组 `ali-pay-config` 的手机网站支付产品及直付通权限,异步支付和退款通知也读取此组。核对 `base-callback-url + pay-callback-url` 指向公网可达的原验签通知接口。
- WAP 支付完成或退出地址为服务端生成的 `base-callback-url + public-route-prefix + /aggregate-order-pay/return/alipay/{tenantCode}/{ticket}`。这一入口只做固定 302,回到同一 H5 票据并附加 `returned=1`;不能把它填作异步通知,更不能凭浏览器回跳参数判断付款成功。按当前应用平台要求配置实际网站域名及返回地址。
- 聚合支付宝不再提供 `/third/aggregate-order-pay/oauth/alipay` 授权回调,也不读取买家 OAuth 身份;微信 OAuth/JSAPI 保持原路径与语义。
- 两种渠道继续使用商户既有支付配置、二级商户和默认分佣关系快照,不因新增 WAP 路径改变收款方或结算归属。
支付宝 H5 通过 `alipay.trade.create` 创建交易并返回 `tradeNO`,前端使用 `AlipayJSBridge.tradePay` 调起支付。直付通结算参数沿用现有 `ThirdAppPlusAliPayServiceImpl.appPay`:始终携带 `sub_merchant.merchant_id`,以及 `settle_info.settle_period_time=1d` 和一条 `defaultSettle` 结算明细;明细金额为交易全额,单位为元,`trans_in` 留空。关闭平台抽佣仍保留这组结算参数,不在创建交易时扣减平台分佣。
### 支付宝手机网站支付流程
聚合交易创建使用小程序支付组的 `JSAPI_PAY`;历史聚合快照中的 `FACE_TO_FACE_PAYMENT` 仍参与严格会话绑定及回调校验,不改写历史流水。原 App 的产品码、SDK 签名下单及默认配置保持不变。支付宝应用类型与产品权限分别管理,不能仅按分类名称认定已获得 API、直付通或当前客户端场景的支付权限。
1. 用户在支付宝扫码后进入商户 H5,输入金额或查看订单固定金额。原聚合 `/cashier/{tenantCode}/{token}` 入口识别支付宝客户端,对新会话使用无 OAuth 的 WAP 平台绑定;同一会话的 CAS 只分配一个支付单号,不伪造付款人 ID。已绑定付款人摘要的历史支付宝会话只签发只读结果票据,不创建第二笔 WAP,也不再输出旧 tradeNO 调起参数。
2. 后端通过 SDK `pageExecute(request, "GET")` 本地签名 `alipay.trade.wap.pay`,先保存本地流水及请求/调起快照,再交付收银票据。完整签名地址使用现有统一加密器保存,仅在短期票据上下文中解密返回,避免 SQL 日志和后台流水明文泄漏。这一步不会远程创建支付宝交易,不返回业务 HTML。H5 的 `launchParams` 为 `{ mode: "ALIPAY_WAP", payUrl: "https://openapi.alipay.com/gateway.do?..." }`;微信调起字段保持兼容,支付宝不再接受 `tradeNO` 旧调起模式。
3. H5 首次进入票据页后只自动调起一次,严格校验官方网关的协议、主机、路径和 API,再导航支付宝收银台。签名 URL 只保存在内存,不写入二维码、H5 Hash 或浏览器恢复存储。手动继续会先重新读取上下文,确认原单仍可支付。
4. 支付完成、退出或浏览器返回后查询原单,返回提示只用于抑制重复自动导航;成功仍由验签通知或后端查单确认。网络失败、取消、刷新均保留金额和 requestId,不能新建第二个可支付请求。
### 授权应用与支付应用的用户身份
WAP 的直付通结算参数沿用原 App 路径:`sub_merchant.merchant_id`、`settle_info.settle_period_time=1d` 及交易全额的 `defaultSettle` 明细,金额为元,`trans_in` 留空;仅开启平台抽佣且商户配置了代调用授权时携带 `app_auth_token`。关闭抽佣也必须保留二级商户和全额结算参数。签名日志仅记录配置组、AppID、API、产品码和业务支付单号,不记录签名 URL、授权令牌或用户身份。
网页/移动应用用自己的密钥兑换自己发起的授权码,服务端身份对象记录这个来源 AppID;小程序支付不能用其密钥直接兑换另一应用的网页授权码。
会话和签名请求共用原两分钟绝对截止时间。WAP 使用 `time_expire`,不使用最小五分钟的 `timeout_express`;首次签名余期不足一分钟时拒绝请求,不能续期。有效期内查无此单只表示用户尚未访问网关,不能确认关闭。到期且支付宝明确返回 `ACQ.TRADE_NOT_EXIST` 才可按未建单结束;网络、权限及未知错误继续阻止新交易。已存在的 WAP 未支付交易使用 `alipay.trade.close`,关单失败再次查单以处理付款竞态,避免用 `cancel` 自动退款已成功交易。历史 JSAPI 的处理保持原逻辑。
跨应用下单只使用 OAuth 实际返回的、2088 开头且共 16 位的 `user_id`,传入 `buyer_id`,并要求支付应用当前仍支持该标识模式。如果只返回网页应用的 `open_id`,后端会拒绝直接向另一个应用传入 `buyer_open_id`,日志标记为 `聚合付款缺少跨应用user_id`。仅在身份来源 AppID 与下单 AppID 相同的情况下,才允许直接使用其 `open_id`。兼容身份构造器未记录来源应用时,也不能据此跨应用使用 `open_id`。
回调快照分类同时接受 `QUICK_WAP_WAY`、历史 `JSAPI_PAY` 和 `FACE_TO_FACE_PAYMENT`,仍严格验证原应用、会话、业务、商户和金额。WAP 按到期查无单本地关闭时,在原响应 JSON 中记录 `wapClosedWithoutTrade=true`;只有这一类本地关闭允许迟到的已验真成功通知原子恢复成功,渠道真实关闭、退款等终态不能据此恢复。原 App 不改用 WAP,退款和分账仍按原交易应用及商户关系处理。
这意味着两组密钥均已配置仍不等于身份可以互通:若网页授权仅返回 `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` 文档说明用户号格式及授权来源,身份模式与跨应用能力仍以当前应用实际权限为准。
参考:[手机网站支付接入流程](https://developer.alibaba.com/docs/doc.htm?articleId=105285&docType=1&treeId=193)、[手机网站支付产品及辅助接口](https://developer.alibaba.com/docs/doc.htm?articleId=105288&docType=1&treeId=203)。签名请求不代表已付款,实际权限、直付通结算及微信/支付宝真机行为仍须集中联调确认。
## 发布顺序与兼容
@@ -86,7 +83,7 @@ H5 只使用项目根目录一个 `.env`,变量命名沿用参考 App:开发
2. 新迁移仅新增两个 `aggregate_scene` 字段,先查字段是否存在,可重复执行;不包含存储过程、字典写入或历史流水回填。业务类型字典 `70 = 商户线下收款` 可通过管理端配置。
3. 配置 Nacos 的 H5 地址与随机签名密钥,配套发布 third、wallet 与 H5,再发布后台页面。完整测试与构建由集中验证指令执行。
4. 商户至少启用并配置完成微信或支付宝其中一个渠道后,即可通过原刷新二维码操作生成新的 URL 收款码。允许仅微信、仅支付宝或双渠道;只校验已启用渠道。新增商户仍先生成原 App Base64 码;没有可用渠道时不生成 H5 码。手动刷新失败会明确反馈并保留原图。
5. 消费机原 `AGGREGATE_ORDER_PAY` 类型、二维码路径及内部创建/状态接口保持兼容,OAuth 出口改为 302 到新 H5。原 HTML 渲染器文件保留但不再被 Controller 使用。
5. 消费机原 `AGGREGATE_ORDER_PAY` 类型、二维码路径及内部创建/状态接口保持兼容,OAuth/WAP 收银出口均为 302 到新 H5。已删除无人调用的旧 HTML 收银渲染器及其支付宝 JSAPI 专用测试,Controller 统一 302 到独立 H5。
数据库字段分别为:聚合会话 `aggregate_scene varchar(32) NOT NULL DEFAULT 'ORDER'`,支付流水 `aggregate_scene varchar(32) DEFAULT NULL`。线下业务编码是 `70`,收款请求号由商户和稳定 requestId 派生;不会创建硬件、商城或钱包充值业务订单。
@@ -96,18 +93,16 @@ 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 实例均已更新配置,并在开放平台修改生效后重新扫码。
移除旧下单入口不删除历史支付流水、应用快照或小程序凭据。`JSAPI_PAY`、`FACE_TO_FACE_PAYMENT` 等已有聚合流水仍参与严格回调验签、金额/商户/会话匹配,并按原应用进行查单、撤销、退款和分账。旧会话不会改为新 WAP 交易,也不会因删除创建接口而直接写成功或关闭。历史支付宝 OAuth 回调地址不再对外提供,新聚合付款无需网页授权。
- 匿名查询:`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;已确认终态的旧请求直接恢复结果页,不再下单。
- OAuth 回跳只携带短期 ticket,H5 用 `/context/{tenantCode}/{ticket}` 读取最小渠道参数,用 `/status/{tenantCode}/{ticket}` 确认结果。票据有效期沿用 5 分钟,付款会话沿用 2 分钟;本机倒计时不能代替服务端查单/关单结果。
- 现有聚合入口:`/third/aggregate-order-pay/cashier/{tenantCode}/{token}`。支付宝新会话进入 WAP,微信继续 OAuth,历史已绑定身份的支付宝会话仅查询原支付结果;已确认终态的旧请求直接恢复结果页,不再下单。
- 收银回跳只携带短期 ticket(WAP 返回额外带无状态语义的 `returned=1`),H5 用 `/context/{tenantCode}/{ticket}` 读取最小渠道参数,用 `/status/{tenantCode}/{ticket}` 确认结果。票据有效期沿用 5 分钟,付款会话沿用 2 分钟;本机倒计时不能代替服务端查单/关单结果。
- 取消付款可继续原单;客户端返回成功、未知或网络异常时仍等待服务端确认,不能自行写成功或新建另一笔交易。
- 缺少 H5 配置时不输出无签名链接;OAuth 入口返回无敏感正文的不可用响应。配置齐全后失败跳固定 H5 错误入口,不反射平台原始异常。
- H5 扫码客户端不在商户已启用渠道中时,展示专门的提示状态并引导换用已开通 App 扫码,隐藏金额和付款键盘;渠道全部停用时提示联系商户。付款创建和进入 OAuth 前再次检查渠道,前端展示不能替代后端校验。已有渠道子订单继续依据原绑定渠道和配置快照恢复。
- 缺少 H5 配置时不输出无签名链接;收银入口返回无敏感正文的不可用响应。配置齐全后失败跳固定 H5 错误入口,不反射平台原始异常。
- H5 扫码客户端不在商户已启用渠道中时,展示专门的提示状态并引导换用已开通 App 扫码,隐藏金额和付款键盘;渠道全部停用时提示联系商户。付款创建和进入渠道收银前再次检查渠道,前端展示不能替代后端校验。已有渠道子订单继续依据原绑定渠道和配置快照恢复。
本轮未执行实际迁移、修改平台配置或发起真实扣款。集中验证需覆盖微信/支付宝真机、退款和分账、回调验签与重复通知、延迟成功、限流、跨租户及旧消费机状态同步。
@@ -21,7 +21,7 @@ public class ThirdAggregateH5ContextVo {
private String status;
/** 渠道已确认成功时为 true,前端 JSBridge 结果不能替代该值。 */
private boolean paySuccess;
/** 尚可继续支付时提供的最小 JSBridge 参数,终态时为空。 */
/** 尚可继续支付时提供渠道参数;WAP 为 mode=ALIPAY_WAP、payUrl,其他模式沿用 JSBridge,终态为空。 */
private Map<String, Object> launchParams;
/** 当前会话的服务端截止时间。 */
private LocalDateTime expireTime;
@@ -22,37 +22,27 @@ public class AppPlusPayConfig {
/** 网页/移动应用凭据;保留 ali-pay-config 配置键及原访问方法,兼容既有 App 和历史订单。 */
private AliPayConfig AliPayConfig;
/** 小程序应用凭据,配置键为 ali-pay-mini-config;用于聚合交易下单,不用于 H5 网页授权。 */
/** 小程序及历史小程序交易凭据,配置键为 ali-pay-mini-config;当前独立 H5 收银使用网页应用。 */
private AliPayConfig aliPayMiniConfig;
/** 第三方应用凭据,配置键为 ali-pay-third-party-config;独立预留,不自动替代支付应用。 */
private AliPayConfig aliPayThirdPartyConfig;
/**
* 读取现有网页/移动应用供 H5 网页授权使用;授权码必须由发起授权的同一应用换取身份。
* 读取独立 H5 WAP 签名使用的网页应用,付款通知必须匹配实际支付单快照。
*
* @return 默认网页/移动应用凭据及 OAuth 回调基础地址,不新增配置组
* @return 已校验凭据、公网基础地址和支付通知路径的现有 ali-pay-config
*/
public AliPayConfig requireAliPayWebConfig() {
public AliPayConfig requireAliPayWebPayConfig() {
if (AliPayConfig == null || StrUtil.hasBlank(AliPayConfig.getAppId(), AliPayConfig.getAppPrivateKey(),
AliPayConfig.getAlipayPublicKey(), AliPayConfig.getBaseCallbackUrl())) {
throw new IllegalStateException("支付宝网页授权配置不完整,请配置网页/移动应用 app-plus.pay.ali-pay-config");
throw new IllegalStateException("支付宝手机网站支付配置不完整,请配置网页/移动应用 app-plus.pay.ali-pay-config");
}
return findAliPayConfig(AliPayConfig.getAppId());
}
/**
* 读取聚合下单使用的小程序应用完整凭据;不能代替网页/移动应用完成网页授权。
*
* @return 完整的小程序应用凭据与支付回调地址
*/
public AliPayConfig requireAliPayMiniConfig() {
if (aliPayMiniConfig == null || StrUtil.hasBlank(aliPayMiniConfig.getAppId(), aliPayMiniConfig.getAppPrivateKey(),
aliPayMiniConfig.getAlipayPublicKey(), aliPayMiniConfig.getBaseCallbackUrl(), aliPayMiniConfig.getPayCallbackUrl())) {
throw new IllegalStateException("支付宝小程序支付配置不完整,请配置 app-plus.pay.ali-pay-mini-config");
AliPayConfig config = findAliPayConfig(AliPayConfig.getAppId());
if (StrUtil.isBlank(config.getPayCallbackUrl())) {
throw new IllegalStateException("支付宝聚合支付回调配置不完整,请配置 app-plus.pay.ali-pay-config.pay-callback-url");
}
// 同一 AppID 出现在多组配置中会导致回调无法唯一选取公钥,使用前即拒绝该配置。
return findAliPayConfig(aliPayMiniConfig.getAppId());
return config;
}
/**
@@ -17,19 +17,19 @@ import org.springframework.web.bind.annotation.*;
import java.net.URI;
/** 聚合扫码与 OAuth 匿名入口;保留旧路径,收银展示统一通过 302 转入独立 H5。 */
/** 聚合扫码、WAP 返回与 OAuth 匿名入口;保留旧路径,收银展示统一通过 302 转入独立 H5。 */
@Slf4j
@RestController
@RequiredArgsConstructor
@Validated
@RequestMapping("/aggregate-order-pay")
public class ThirdAggregateOrderPayController {
/** 已有订单编排服务,负责 OAuth state、付款人互斥和渠道事实确认。 */
/** 已有订单编排服务,负责 WAP/OAuth 分流、平台互斥和渠道事实确认。 */
private final ThirdAggregateOrderPayService aggregateOrderPayService;
/** 固定 H5 地址与公开限流配置,不接受任意回跳目标。 */
private final ThirdAggregateH5Support h5Support;
/** 保留消费机二维码入口,按客户端进入对应的官方 OAuth。 */
/** 保留消费机二维码入口,支付宝新会话使用 WAP,历史支付宝仅查询结果,微信继续 OAuth。 */
@GetMapping("/cashier/{tenantCode}/{token}")
@Inner(value = false)
public ResponseEntity<Void> cashier(@NotBlank @PathVariable String tenantCode, @NotBlank @PathVariable String token,
@@ -63,18 +63,23 @@ public class ThirdAggregateOrderPayController {
}
}
/** 支付宝沿用固定回调地址,租户由一次性 state 恢复,不把授权码传入 H5。 */
@GetMapping("/oauth/alipay")
/**
* 手机网站支付完成或退出时仅返回固定 H5 票据页,不接收或采信渠道返回的付款结果。
*
* @param tenantCode 固定回跳路径中的租户编码,仅用于票据页定位且不能改变跳转域名
* @param ticket 后端签发的短期不透明收银票据;真实有效性仍由上下文和查单接口确认
* @return 带 returned 标记的固定 H5 导航,避免回到页面后再次自动打开支付宝收银台
*/
@GetMapping("/return/alipay/{tenantCode}/{ticket}")
@Inner(value = false)
public ResponseEntity<Void> alipayOauth(@RequestParam(value = "auth_code", required = false) String authCode,
@RequestParam(value = "state", required = false) String state) {
public ResponseEntity<Void> alipayReturn(@NotBlank @PathVariable String tenantCode, @NotBlank @PathVariable String ticket) {
if (tenantCode == null || tenantCode.isBlank() || tenantCode.length() > 128 || tenantCode.chars().anyMatch(Character::isISOControl)
|| ticket == null || !ticket.matches("[A-Za-z0-9_-]{32}")) {
return unavailable();
}
try {
h5Support.baseUrl();
ThirdAggregateCashierPageData data = aggregateOrderPayService.handleAlipayOauth(authCode, state);
return redirect(data.getH5Url());
return redirect(h5Support.cashierUrl(tenantCode, ticket) + "&returned=1");
} catch (RuntimeException exception) {
// 固定回调没有商户上下文,使用阶段日志定位,避免记录授权凭据。
log.error("聚合收银支付宝授权回调处理失败", exception);
return unavailable();
}
}
@@ -1,80 +0,0 @@
package com.bsd.pjcloud.thirdparty.model.pay;
import cn.hutool.core.util.StrUtil;
/**
* 支付宝用户授权换取的付款人身份。
*
* <p>记录授权来源应用,避免将网页应用的 open_id 直接用于另一个支付应用。
* 跨应用下单只使用平台实际返回且支付应用支持的 user_id。</p>
*/
public class ThirdAggregateAlipayOauthIdentity {
/** 换取此身份的服务端应用 AppID;兼容构造器未传入时为空,不能据此跨应用使用 open_id。 */
private final String oauthAppId;
/**
* 支付宝开放平台用户 open_id;接口未返回时为空。
*/
private final String openId;
/**
* 支付宝历史用户 user_id;新接口只返回 open_id 时可为空。
*/
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。
*
* @return open_id;未返回时为空
*/
public String getOpenId() {
return openId;
}
/**
* 获取支付宝历史 user_id。
*
* @return user_id;未返回时为空
*/
public String getUserId() {
return userId;
}
/**
* 获取用于会话付款人互斥的稳定身份值,优先选择 open_id。
*
* @return 非空支付宝付款人身份值
*/
public String getStableIdentity() {
return StrUtil.firstNonBlank(openId, userId);
}
}
@@ -7,7 +7,7 @@ import java.util.LinkedHashMap;
import java.util.Map;
/**
* 聚合收银页调起第三方 JSBridge 所需的最小参数。
* 聚合收银页调起平台 JSBridge 或导航支付宝 WAP 收银台所需的最小参数。
*/
public class ThirdAggregateCashierLaunch {
@@ -22,7 +22,7 @@ public class ThirdAggregateCashierLaunch {
private final String payNo;
/**
* 传给平台 JSBridge 的签名参数副本,不包含商户密钥或公众号 Secret。
* 平台调起参数副本;WAP 使用 mode=ALIPAY_WAP 与 payUrl,其他模式保留原 JSBridge 参数,不包含应用私钥。
*/
private final Map<String, Object> payParams;
@@ -31,7 +31,7 @@ public class ThirdAggregateCashierLaunch {
*
* @param platform 支付平台
* @param payNo 第三方商户订单号
* @param payParams JSBridge 调起参数
* @param payParams JSBridge 参数或 WAP 官方签名地址
*/
public ThirdAggregateCashierLaunch(AppPlusPlatformTypeEnum platform, String payNo, Map<String, Object> payParams) {
this.platform = platform;
@@ -58,7 +58,7 @@ public class ThirdAggregateCashierLaunch {
}
/**
* 获取不可变的 JSBridge 调起参数。
* 获取不可变的渠道调起参数;签名地址只能用于本次原交易,不得写入浏览器恢复存储。
*
* @return 不包含密钥的调起参数
*/
@@ -1,35 +1,35 @@
package com.bsd.pjcloud.thirdparty.service;
import com.alipay.api.response.AlipayTradeCancelResponse;
import com.alipay.api.response.AlipayTradeCloseResponse;
import com.alipay.api.response.AlipayTradeQueryResponse;
import com.bsd.pjcloud.thirdparty.model.pay.ThirdAggregateAlipayOauthIdentity;
import com.bsd.pjcloud.thirdparty.model.pay.ThirdAggregateCashierLaunch;
import com.bsd.pjcloud.thirdparty.model.pay.ThirdAggregatePayCreateContext;
import com.bsd.pjcloud.wallet.api.vo.mch.MchPaySettingVo;
/**
* 支付宝聚合订单码 OAuth 与 JSAPI 支付能力。
* 支付宝聚合收银的手机网站支付及渠道结果确认能力;历史交易仅保留查单、关单和撤销处理。
*/
public interface ThirdAggregateAliPayService {
/**
* 使用一次性授权码换取支付宝付款人身份。
* 用网页/移动应用签名手机网站支付请求,先持久化本地流水,再交付官方收银地址;不依赖 OAuth 买家身份。
*
* @param authCode 支付宝 OAuth 回调授权码
* @return 包含 open_id 或兼容 user_id 的付款人身份
* @param context 已原子绑定聚合会话的支付上下文,金额为整数分
* @param paySetting 商户当前支付宝直付通配置及分佣关系快照
* @param returnUrl 服务端配置派生的固定回跳地址,成功返回和退出均仅导航,不写付款结果
* @return mode 为 ALIPAY_WAP 的签名官方网关地址;生成地址不代表渠道已建单
*/
ThirdAggregateAlipayOauthIdentity exchangeOauthCode(String authCode);
ThirdAggregateCashierLaunch createWapPay(ThirdAggregatePayCreateContext context, MchPaySettingVo paySetting, String returnUrl);
/**
* 调用 {@code alipay.trade.create} 创建服务商模式 JSAPI 子订单并保存内部支付订单。
* 关闭 WAP 未支付交易,不使用可能自动退款的撤销接口。
*
* @param context 已绑定聚合会话的支付上下文
* @param paySetting 当前商户启用的支付宝授权配置
* @param identity 已通过 OAuth 获取的支付宝付款人身份
* @return 支付宝 JSBridge 调起参数
* @param payNo 已绑定的渠道商户订单号
* @param settingJson 原支付应用与商户配置快照
* @return 渠道关单响应,网络或 SDK 异常时为空;调用方仍须处理付款竞态
*/
ThirdAggregateCashierLaunch createPay(ThirdAggregatePayCreateContext context, MchPaySettingVo paySetting,
ThirdAggregateAlipayOauthIdentity identity);
AlipayTradeCloseResponse close(String payNo, String settingJson);
/**
* 查询聚合支付宝子订单状态。
@@ -1,176 +0,0 @@
package com.bsd.pjcloud.thirdparty.service;
import cn.hutool.json.JSONUtil;
import com.bsd.pjcloud.thirdparty.constant.miniplatform.AppPlusPlatformTypeEnum;
import com.bsd.pjcloud.thirdparty.model.pay.ThirdAggregateCashierPageData;
import org.springframework.stereotype.Component;
import java.security.SecureRandom;
/**
* 聚合支付固定收银 HTML 渲染器。
*
* <p>页面不加载外部脚本或前端框架,只调用支付宝或微信客户端内置 JSBridge。</p>
*/
@Component
public class ThirdAggregateCashierHtmlRenderer {
/**
* CSP nonce 随机字节数,每次响应重新生成。
*/
private static final int NONCE_BYTES = 18;
/**
* 支付宝 JSBridge 自动调用后显示人工重试入口的等待时间,单位毫秒。
*
* <p>部分支付宝 WebView 会限制无用户手势的支付调起,等待后展示按钮可让付款人通过真实点击再次调用。</p>
*/
private static final int ALIPAY_LAUNCH_FALLBACK_MILLIS = 1500;
/**
* 支付宝 JSBridge 注入等待上限,单位毫秒;超时后页面提示付款人确认扫码客户端并允许重试。
*/
private static final int ALIPAY_BRIDGE_READY_TIMEOUT_MILLIS = 3000;
/**
* 进程级强随机数生成器,用于每个收银页响应的 CSP nonce。
*/
private final SecureRandom secureRandom = new SecureRandom();
/**
* 渲染调用支付宝或微信 JSBridge 的固定收银页。
*
* @param pageData 平台调起参数和短期状态查询地址
* @param nonce 当前响应 CSP nonce
* @return 完整 UTF-8 HTML 文本
*/
public String renderPayPage(ThirdAggregateCashierPageData pageData, String nonce) {
String payParams = safeScriptJson(pageData.getLaunch().getPayParams());
String statusUrl = safeScriptJson(pageData.getStatusUrl());
boolean wechat = AppPlusPlatformTypeEnum.WECHAT.equals(pageData.getLaunch().getPlatform());
String invokeScript = wechat
? "function invokePay(){WeixinJSBridge.invoke('getBrandWCPayRequest',payParams,function(){startPolling();});}"
+ "if(typeof WeixinJSBridge==='undefined'){document.addEventListener('WeixinJSBridgeReady',invokePay,false);}else{invokePay();}"
: alipayInvokeScript();
return "<!doctype html><html lang=\"zh-CN\"><head><meta charset=\"utf-8\">"
+ "<meta name=\"viewport\" content=\"width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no\">"
+ "<title>订单支付</title><style nonce=\"" + nonce + "\">"
+ baseStyles() + "</style></head><body><main><div class=\"spinner\" aria-hidden=\"true\"></div>"
+ "<h1 id=\"title\">正在调起支付</h1><p id=\"message\">请在当前应用内完成付款</p>"
+ "<button id=\"retryButton\" type=\"button\" hidden>重新调起支付</button></main>"
+ "<script nonce=\"" + nonce + "\">const payParams=" + payParams + ";const statusUrl=" + statusUrl + ";"
+ "let polling=false;function setState(title,message){document.getElementById('title').textContent=title;"
+ "document.getElementById('message').textContent=message;}"
+ "function startPolling(){if(polling)return;polling=true;checkStatus();}"
+ "function checkStatus(){fetch(statusUrl,{cache:'no-store',credentials:'omit'}).then(r=>r.json()).then(body=>{"
+ "const data=body&&body.data;if(data&&data.paySuccess){setState('支付成功','本页可以关闭');return;}"
+ "if(data&&['40','50','60'].includes(data.status)){setState('支付未完成','二维码已关闭或失效');return;}"
+ "setTimeout(checkStatus,1500);}).catch(()=>setTimeout(checkStatus,2000));}"
+ invokeScript + "setTimeout(startPolling,1000);</script></body></html>";
}
/**
* 构造支付宝内置浏览器的收银台调起脚本。
*
* <p>严格按支付宝文档传递 {@code tradeNO},并处理官方结果码。自动调起被 WebView 限制、桥接尚未注入或同步抛错时,
* 页面提供真实点击重试入口;支付结果仍由服务端状态轮询最终确认,避免仅凭客户端结果认定成功。</p>
*
* @return 不包含动态业务数据的固定支付宝 JSBridge 脚本
*/
private String alipayInvokeScript() {
return "const retryButton=document.getElementById('retryButton');const spinner=document.querySelector('.spinner');"
+ "let alipayBridgeInvoked=false;let alipayResultReceived=false;"
+ "function showAlipayRetry(title,message){setState(title,message);spinner.hidden=true;retryButton.hidden=false;}"
+ "function hideAlipayRetry(){spinner.hidden=false;retryButton.hidden=true;}"
+ "function handleAlipayResult(result){alipayResultReceived=true;const resultCode=String(result&&result.resultCode||'');"
+ "if(resultCode==='9000'){hideAlipayRetry();setState('支付结果确认中','正在确认服务端支付结果');startPolling();return;}"
+ "if(resultCode==='8000'||resultCode==='6004'){hideAlipayRetry();setState('支付结果确认中','请稍候,请勿重复付款');startPolling();return;}"
+ "if(resultCode==='6001'){showAlipayRetry('支付已取消','如需继续付款,请重新调起支付宝收银台');startPolling();return;}"
+ "if(resultCode==='6002'){showAlipayRetry('网络连接异常','请检查网络后重新调起支付');startPolling();return;}"
+ "if(resultCode==='4000'){showAlipayRetry('支付未完成','请重新调起支付或重新扫码');startPolling();return;}"
+ "showAlipayRetry('支付未能调起','请重新调起支付宝收银台');startPolling();}"
+ "function invokePay(){const tradeNO=payParams&&payParams.tradeNO;"
+ "if(typeof tradeNO!=='string'||!tradeNO.trim()){showAlipayRetry('支付参数无效','请关闭页面后重新扫码');return;}"
+ "const bridge=window.AlipayJSBridge;if(!bridge||typeof bridge.call!=='function'){"
+ "showAlipayRetry('支付组件未就绪','请确认当前页面在支付宝内打开后重试');return;}"
+ "alipayBridgeInvoked=true;alipayResultReceived=false;hideAlipayRetry();setState('正在调起支付','请在支付宝内完成付款');"
+ "try{bridge.call('tradePay',{tradeNO:tradeNO},handleAlipayResult);"
+ "setTimeout(function(){if(!alipayResultReceived){showAlipayRetry('等待打开支付宝收银台',"
+ "'未自动打开时,请点击下方按钮继续支付');}}," + ALIPAY_LAUNCH_FALLBACK_MILLIS + ");}"
+ "catch(error){alipayBridgeInvoked=false;showAlipayRetry('支付组件调用失败','请重新调起支付或重新扫码');}}"
+ "retryButton.addEventListener('click',invokePay);"
+ "if(window.AlipayJSBridge&&typeof window.AlipayJSBridge.call==='function'){invokePay();}else{"
+ "document.addEventListener('AlipayJSBridgeReady',invokePay,false);"
+ "setTimeout(function(){if(!alipayBridgeInvoked){showAlipayRetry('支付组件未就绪',"
+ "'请确认当前页面在支付宝内打开后重试');}}," + ALIPAY_BRIDGE_READY_TIMEOUT_MILLIS + ");}";
}
/**
* 渲染不包含脚本的固定提示页,供普通浏览器、自有 App、过期和非法请求使用。
*
* @param message 面向付款人的短提示,服务端会执行 HTML 转义
* @param nonce 当前响应 CSP nonce
* @return 完整 UTF-8 HTML 文本
*/
public String renderMessagePage(String message, String nonce) {
return "<!doctype html><html lang=\"zh-CN\"><head><meta charset=\"utf-8\">"
+ "<meta name=\"viewport\" content=\"width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no\">"
+ "<title>订单支付</title><style nonce=\"" + nonce + "\">" + baseStyles()
+ "</style></head><body><main><h1>无法继续支付</h1><p>" + escapeHtml(message) + "</p></main></body></html>";
}
/**
* 生成当前响应使用的不可预测 CSP nonce。
*
* @return URL 安全且不含填充字符的随机 nonce
*/
public String createNonce() {
byte[] bytes = new byte[NONCE_BYTES];
secureRandom.nextBytes(bytes);
return java.util.Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
}
/**
* 返回收银页固定内联样式,不接受任何外部输入。
*
* @return 适配移动端安全区的 CSS 文本
*/
private String baseStyles() {
return "*{box-sizing:border-box}body{margin:0;min-height:100vh;background:#f5f7fa;color:#1f2937;"
+ "font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif}main{min-height:100vh;display:flex;"
+ "flex-direction:column;align-items:center;justify-content:center;padding:24px;padding-bottom:env(safe-area-inset-bottom)}"
+ "h1{margin:18px 0 8px;font-size:22px;font-weight:600;letter-spacing:0}p{margin:0;color:#6b7280;font-size:15px;"
+ "line-height:1.6;text-align:center}.spinner{width:36px;height:36px;border:3px solid #d1d5db;border-top-color:#1677ff;"
+ "border-radius:50%;animation:spin 1s linear infinite}.spinner[hidden]{display:none}button{margin-top:24px;min-width:152px;"
+ "height:44px;padding:0 20px;border:0;border-radius:6px;background:#1677ff;color:#fff;font-size:16px;font-weight:600}"
+ "button[hidden]{display:none}@keyframes spin{to{transform:rotate(360deg)}}";
}
/**
* 将 Java 对象序列化为可安全嵌入 script 文本的 JSON,阻断闭合标签和 HTML 实体注入。
*
* <p>Hutool 对顶层字符串调用 {@code toJsonStr} 不会补 JSON 引号,必须单独使用 {@code quote};否则 HTTPS
* 状态地址会被渲染为非法 JavaScript,并导致包括 JSBridge 调起在内的整段脚本无法执行。</p>
*
* @param value 仅包含平台调起参数或服务端固定 URL 的对象
* @return 已转义小于号、大于号和与号的 JSON 文本
*/
private String safeScriptJson(Object value) {
String json = value instanceof CharSequence ? JSONUtil.quote(value.toString()) : JSONUtil.toJsonStr(value);
return json.replace("<", "\\u003c").replace(">", "\\u003e").replace("&", "\\u0026");
}
/**
* 对提示文本执行最小 HTML 转义,避免业务异常文本进入页面结构。
*
* @param value 原始提示文本,允许为空
* @return 可安全放入文本节点的内容
*/
private String escapeHtml(String value) {
if (value == null) {
return "支付请求无效";
}
return value.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
.replace("\"", "&quot;").replace("'", "&#39;");
}
}
@@ -24,7 +24,7 @@ public interface ThirdAggregateOrderPayService {
/** 创建已验签的线下收款会话,金额与请求编号由线下入口服务校验后固定。 */
ThirdAggregateOrderCodePayVo createOffline(ThirdAggregateOrderCodePayDto dto);
/** 使用租户编码和短期票据读取固定金额、商户及尚可支付的 JSBridge 参数。 */
/** 使用租户编码和短期票据读取固定金额、商户及尚可支付的 WAP/微信参数;历史支付宝会话不再提供调起参数。 */
ThirdAggregateH5ContextVo getContextByTicket(String tenantCode, String ticket);
/** 至少开通一个渠道,仅校验已启用渠道的应用、商户参数及默认关系,不创建渠道订单。 */
@@ -47,12 +47,12 @@ public interface ThirdAggregateOrderPayService {
void assertNoBlockingAggregateSession(ThirdPayOrderBizTypeEnum bizType, String bizId);
/**
* 校验扫码客户端和访问令牌,并创建一次性 OAuth state 后返回平台授权地址。
* 校验扫码客户端和访问令牌,返回支付宝 WAP 收银入口或微信的一次性 OAuth 授权地址,历史支付宝会话仅返回只读票据。
*
* @param tenantCode 聚合 URL 中的租户唯一编码
* @param token 聚合 URL 随机访问令牌
* @param userAgent 扫码客户端 User-Agent
* @return 支付宝或微信官方 OAuth 授权地址
* @return 支付宝新会话的 WAP 票据、历史支付宝只读票据或微信 OAuth 授权地址
*/
String getCashierOauthUrl(String tenantCode, String token, String userAgent);
@@ -66,20 +66,11 @@ public interface ThirdAggregateOrderPayService {
*/
ThirdAggregateCashierPageData handleWechatOauth(String tenantCode, String authCode, String state);
/**
* 处理固定地址的支付宝 OAuth 回调,并从一次性 state 恢复租户上下文。
*
* @param authCode 支付宝一次性授权码
* @param state 包含租户编码且由服务端创建的一次性 OAuth state
* @return 支付宝 JSBridge 调起数据和短期状态查询地址
*/
ThirdAggregateCashierPageData handleAlipayOauth(String authCode, String state);
/**
* 使用短期 ticket 查询当前租户聚合支付状态。
*
* @param tenantCode 聚合 URL 中的租户唯一编码
* @param ticket OAuth 回调收银页取得的短期状态票据
* @param ticket WAP 或微信收银页取得的短期状态票据
* @return 不含敏感字段的聚合支付状态
*/
ThirdAggregateOrderCodePayStatusVo getStatusByTicket(String tenantCode, String ticket);
@@ -95,7 +86,7 @@ public interface ThirdAggregateOrderPayService {
* 判断支付回调订单是否属于聚合会话;期限内同步终态,期限外关闭会话并阻止旧业务回调链执行。
*
* @param payOrder 已通过平台验签和关键字段校验的最新第三方支付订单
* @return 属于聚合 JSAPI 子订单时返回 {@code true}
* @return 支付流水绑定聚合会话时返回 {@code true},包括历史支付宝流水
*/
boolean syncAggregateChildOrder(ThirdPayOrderEntity payOrder);
}
@@ -56,6 +56,15 @@ public interface ThirdAggregatePaySessionService extends IService<ThirdAggregate
*/
boolean selectPlatform(Long sessionId, AppPlusPlatformTypeEnum platform, String payNo, String payerIdentityHash);
/**
* 为支付宝手机网站收银原子绑定平台和预分配子订单,不在 OAuth 前伪造付款人身份。
*
* @param sessionId 当前租户下已验访问令牌的聚合会话主键
* @param payNo 预分配且重试时必须复用的第三方子订单号
* @return 仅未过期且尚未选定平台的会话首次推进成功时返回 {@code true};付款人摘要保持空值
*/
boolean selectWapPlatform(Long sessionId, String payNo);
/**
* 将已创建第三方子订单的会话原子推进为支付中。
*
@@ -3,6 +3,7 @@ package com.bsd.pjcloud.thirdparty.service;
import com.alipay.api.response.AlipayTradeQueryResponse;
import com.alipay.api.response.AlipayTradeCancelResponse;
import com.alipay.api.response.AlipayTradeCloseResponse;
import com.alipay.api.response.AlipayTradeFastpayRefundQueryResponse;
import com.bsd.pjcloud.thirdparty.dto.appplus.pay.wechat.ThirdAppPlusPayDto;
import com.bsd.pjcloud.thirdparty.dto.appplus.pay.wechat.ThirdMiniWechatRefundDto;
@@ -16,7 +17,7 @@ import java.time.LocalDateTime;
import java.util.Map;
/**
* 支付宝支付能力,封装应用支付、订单码预创建、查询、撤销及回调验真。
* 支付宝支付能力,封装应用支付、订单码预创建、查询、关闭、撤销及回调验真。
*/
public interface ThirdAppPlusAliPayService {
@@ -44,6 +45,15 @@ public interface ThirdAppPlusAliPayService {
*/
AlipayTradeCancelResponse cancel(String payNo, String settingJson);
/**
* 按支付单的原应用和商户授权快照关闭未付款交易,不撤销或退款已成功的交易。
*
* @param payNo 第三方支付服务生成的商户订单号
* @param settingJson 创建订单时保存的商户支付配置快照,不能用当前商户配置替换
* @return 支付宝原始关单响应;解析、网络或 SDK 异常返回 {@code null},由上层继续确认渠道事实
*/
AlipayTradeCloseResponse close(String payNo, String settingJson);
/**
* app支付
*/
@@ -11,15 +11,14 @@ import cn.hutool.json.JSONUtil;
import com.alipay.api.AlipayClient;
import com.alipay.api.AlipayConfig;
import com.alipay.api.DefaultAlipayClient;
import com.alipay.api.domain.AlipayTradeCreateModel;
import com.alipay.api.domain.AlipayTradeWapPayModel;
import com.alipay.api.domain.SettleDetailInfo;
import com.alipay.api.domain.SettleInfo;
import com.alipay.api.domain.SubMerchant;
import com.alipay.api.request.AlipaySystemOauthTokenRequest;
import com.alipay.api.request.AlipayTradeCreateRequest;
import com.alipay.api.response.AlipaySystemOauthTokenResponse;
import com.alipay.api.request.AlipayTradeWapPayRequest;
import com.alipay.api.response.AlipayTradeCancelResponse;
import com.alipay.api.response.AlipayTradeCreateResponse;
import com.alipay.api.response.AlipayTradeWapPayResponse;
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;
@@ -27,7 +26,6 @@ import com.bsd.pjcloud.thirdparty.constant.miniplatform.AppPlusPlatformTypeEnum;
import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPayOrderStatusEnum;
import com.bsd.pjcloud.thirdparty.entity.pay.ThirdPayOrderEntity;
import com.bsd.pjcloud.thirdparty.service.ThirdMchFzRelationService;
import com.bsd.pjcloud.thirdparty.model.pay.ThirdAggregateAlipayOauthIdentity;
import com.bsd.pjcloud.thirdparty.model.pay.ThirdAggregateCashierLaunch;
import com.bsd.pjcloud.thirdparty.model.pay.ThirdAggregatePayCreateContext;
import com.bsd.pjcloud.thirdparty.service.ThirdAggregateAliPayService;
@@ -38,17 +36,20 @@ import com.bsd.pjcloud.wallet.api.vo.mch.MchPaySettingVo;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.jasypt.encryption.StringEncryptor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.validation.annotation.Validated;
import java.math.BigDecimal;
import java.net.URI;
import java.time.LocalDateTime;
import java.math.RoundingMode;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 支付宝聚合收银:网页/移动应用负责 OAuth,小程序应用负责交易创建及付款通知。
* 支付宝聚合收银:网页/移动应用仅签名 WAP 请求,按原交易应用处理结果确认和历史交易。
*/
@Service
@Validated
@@ -59,12 +60,13 @@ public class ThirdAggregateAliPayServiceImpl implements ThirdAggregateAliPayServ
/**
* 支付宝聚合支付日志标签,不包含授权码、用户标识或商户授权令牌。
*/
private static final String TAG = "支付宝聚合JSAPI支付";
private static final String TAG = "支付宝聚合支付";
/**
* 聚合支付下单使用小程序应用签约的 JSAPI 产品码,不改变原 App 支付产品。
*/
private static final String PRODUCT_CODE = "JSAPI_PAY";
/** 手机网站支付专用产品码,回调据此执行严格聚合会话绑定检查。 */
private static final String WAP_PRODUCT_CODE = "QUICK_WAP_WAY";
/** 手机网站收银模式,H5 使用支付宝官方网关导航。 */
private static final String WAP_MODE = "ALIPAY_WAP";
/**
* 沿用现有 App 直付通的自动确认结算账期;仅在商户签约账期结算模式时生效。
@@ -81,11 +83,6 @@ public class ThirdAggregateAliPayServiceImpl implements ThirdAggregateAliPayServ
*/
private static final String SUBJECT = "资安信消费支付";
/**
* 支付宝 OAuth 授权码交换固定 grant_type。
*/
private static final String OAUTH_GRANT_TYPE = "authorization_code";
/**
* 支付宝服务商模式商户授权令牌公共参数名。
*/
@@ -97,7 +94,7 @@ public class ThirdAggregateAliPayServiceImpl implements ThirdAggregateAliPayServ
private final AppPlusPayConfig appPlusPayConfig;
/**
* 第三方支付订单服务,用于保存 JSAPI 子订单及精简平台快照。
* 第三方支付订单服务,用于保存 WAP 流水及精简平台快照。
*/
private final ThirdPayOrderService thirdPayOrderService;
@@ -110,126 +107,90 @@ public class ThirdAggregateAliPayServiceImpl implements ThirdAggregateAliPayServ
@Autowired(required = false)
private ThirdMchFzRelationService mchFzRelationService;
/** 使用项目统一加密器保存签名地址,避免 SQL 日志及后台流水接口暴露付款凭据。 */
@Autowired
private StringEncryptor stringEncryptor;
/**
* 小程序支付应用的客户端,延迟初始化并在聚合下单请求间复用。
* 网页/移动应用的支付客户端,延迟初始化并在聚合下单请求间复用。
*/
private volatile AlipayClient alipayClient;
/** 网页/移动应用的 OAuth 客户端,独立缓存,不能用支付应用密钥兑换网页授权码。 */
private volatile AlipayClient oauthAlipayClient;
/**
* 使用一次性 OAuth 授权码换取支付宝付款人身份,不保存授权令牌。
*
* @param authCode 支付宝 OAuth 回调授权码
* @return 包含 open_id 或兼容 user_id 的付款人身份
* 签名 WAP GET 请求并先落本地待支付流水;真正的渠道建单在浏览器访问官方网关时发生。
* 复用直付通全额结算快照,不要求或伪造 buyer_id/open_id;生成地址不能当作支付宝已受理。
*/
@Override
public ThirdAggregateAlipayOauthIdentity exchangeOauthCode(String authCode) {
if (StrUtil.isBlank(authCode)) {
throw new I18nBizException(ErrorCodes.THIRD_ALIPAY_AUTH_CODE_EMPTY);
}
AlipaySystemOauthTokenRequest request = new AlipaySystemOauthTokenRequest();
request.setGrantType(OAUTH_GRANT_TYPE);
request.setCode(authCode);
String oauthAppId = appPlusPayConfig.requireAliPayWebConfig().getAppId();
try {
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(oauthAppId, response.getOpenId(), response.getUserId());
if (StrUtil.isBlank(identity.getStableIdentity())) {
throw new I18nBizException(ErrorCodes.THIRD_ALIPAY_OAUTH_NO_PAYER);
}
return identity;
} catch (RuntimeException exception) {
throw exception;
} catch (Exception exception) {
log.warn("|{}|支付宝用户授权异常,exceptionType:{}", TAG, exception.getClass().getSimpleName());
throw new I18nBizException(ErrorCodes.THIRD_ALIPAY_OAUTH_FAIL);
}
}
/**
* 创建支付宝服务商 JSAPI 子订单,并仅返回 AlipayJSBridge 所需 tradeNO。
*
* @param context 已绑定聚合会话的支付上下文
* @param paySetting 当前商户启用的支付宝授权配置
* @param identity 已通过 OAuth 获取的支付宝付款人身份
* @return 支付宝 JSBridge 调起参数
*/
@Override
public ThirdAggregateCashierLaunch createPay(ThirdAggregatePayCreateContext context, MchPaySettingVo paySetting,
ThirdAggregateAlipayOauthIdentity identity) {
public ThirdAggregateCashierLaunch createWapPay(ThirdAggregatePayCreateContext context, MchPaySettingVo paySetting, String returnUrl) {
MchPaySettingEntity.AliPaySetting aliPaySetting = parseAliPaySetting(paySetting);
AppPlusPayConfig.AliPayConfig aliPayConfig = appPlusPayConfig.requireAliPayMiniConfig();
AlipayTradeCreateModel model = new AlipayTradeCreateModel();
applyBuyerIdentity(model, identity, aliPayConfig.getAppId());
AppPlusPayConfig.AliPayConfig config = appPlusPayConfig.requireAliPayWebPayConfig();
validateFzRelation(context, paySetting);
// SDK 要求绝对截止时间至少在一分钟后,保持原会话期限,不能通过延时签名续期。
if (context.getExpireTime() == null || !context.getExpireTime().isAfter(LocalDateTime.now().plusMinutes(1))) {
throw new IllegalArgumentException("付款入口剩余有效期不足,请重新扫码");
}
ThirdPayOrderEntity payOrder = buildPayOrder(context, paySetting);
String totalAmount = fenToYuan(context.getAmount());
AlipayTradeWapPayModel model = new AlipayTradeWapPayModel();
model.setOutTradeNo(context.getPayNo());
model.setTotalAmount(totalAmount);
model.setSubject(SUBJECT);
model.setProductCode(PRODUCT_CODE);
model.setProductCode(WAP_PRODUCT_CODE);
model.setTimeExpire(LocalDateTimeUtil.format(context.getExpireTime(), DatePattern.NORM_DATETIME_PATTERN));
boolean enableFz = context.getFzRelationId() != null;
// fzRelationId 只控制平台抽佣,直付通无论是否分账都必须携带二级商户标识。
model.setQuitUrl(returnUrl);
SubMerchant subMerchant = new SubMerchant();
subMerchant.setMerchantId(aliPaySetting.getSubMchid());
model.setSubMerchant(subMerchant);
// 与 App 直付通保持相同结算模型,关闭平台抽佣也必须保留二级商户的全额结算明细。
SettleDetailInfo defaultSettleDetailInfo = new SettleDetailInfo();
defaultSettleDetailInfo.setTransInType(DEFAULT_SETTLE_TRANS_IN_TYPE);
defaultSettleDetailInfo.setAmount(totalAmount);
SettleInfo directPaySettleInfo = new SettleInfo();
directPaySettleInfo.setSettlePeriodTime(DIRECT_PAY_SETTLE_PERIOD_TIME);
directPaySettleInfo.setSettleDetailInfos(List.of(defaultSettleDetailInfo));
model.setSettleInfo(directPaySettleInfo);
AlipayTradeCreateRequest request = new AlipayTradeCreateRequest();
request.setNotifyUrl(aliPayConfig.getBaseCallbackUrl() + aliPayConfig.getPayCallbackUrl());
SettleDetailInfo detail = new SettleDetailInfo();
detail.setTransInType(DEFAULT_SETTLE_TRANS_IN_TYPE);
detail.setAmount(totalAmount);
SettleInfo settleInfo = new SettleInfo();
settleInfo.setSettlePeriodTime(DIRECT_PAY_SETTLE_PERIOD_TIME);
settleInfo.setSettleDetailInfos(List.of(detail));
model.setSettleInfo(settleInfo);
AlipayTradeWapPayRequest request = new AlipayTradeWapPayRequest();
request.setNotifyUrl(config.getBaseCallbackUrl() + config.getPayCallbackUrl());
request.setReturnUrl(returnUrl);
request.setBizModel(model);
// 关闭分账不携带平台抽佣专属授权;开启分账且配置了代调用授权时才发送 token。
if (enableFz && StrUtil.isNotBlank(aliPaySetting.getAppAuthToken())) {
if (context.getFzRelationId() != null && StrUtil.isNotBlank(aliPaySetting.getAppAuthToken())) {
request.putOtherTextParam(APP_AUTH_TOKEN_PARAM, aliPaySetting.getAppAuthToken());
}
payOrder.setPayRep(buildRequestSnapshot(context).toString());
AlipayTradeCreateResponse response;
String payUrl;
try {
response = getAlipayClient().execute(request);
} catch (Exception exception) {
saveFailedOrder(payOrder, "SDK_EXCEPTION", exception.getClass().getSimpleName());
log.warn("|{}|支付宝JSAPI子订单创建异常,payNo:{},exceptionType:{}", TAG, context.getPayNo(), exception.getClass().getSimpleName());
throw new I18nBizException(ErrorCodes.THIRD_ALIPAY_JSAPI_CREATE_FAIL);
}
if (response == null || !response.isSuccess() || StrUtil.isBlank(response.getTradeNo())) {
String errorCode = response == null ? "EMPTY_RESPONSE" : StrUtil.firstNonBlank(response.getSubCode(), response.getCode(), "EMPTY_TRADE_NO");
saveFailedOrder(payOrder, errorCode, null);
log.warn("|{}|支付宝JSAPI子订单创建失败,payNo:{},errorCode:{}", TAG, context.getPayNo(), errorCode);
throw new I18nBizException(ErrorCodes.THIRD_ALIPAY_JSAPI_CREATE_FAIL);
}
Map<String, Object> payParams = new LinkedHashMap<>();
payParams.put("tradeNO", response.getTradeNo());
payOrder.setPayRes(JSONUtil.createObj()
.set("aggregatePay", true)
.set("tradeNo", response.getTradeNo())
.set("launchParams", payParams).toString());
try {
if (!thirdPayOrderService.save(payOrder)) {
throw new I18nBizException(ErrorCodes.THIRD_AGGREGATE_SUB_ORDER_SAVE_FAIL);
// pageExecute(GET) 只在本地签名生成 URL;不返回 HTML,也不会在这里访问支付宝创建交易。
AlipayTradeWapPayResponse response = getAlipayClient().pageExecute(request, "GET");
payUrl = response == null ? null : response.getBody();
URI uri = StrUtil.isBlank(payUrl) ? null : URI.create(payUrl);
if (uri == null || !"https".equals(uri.getScheme()) || !"openapi.alipay.com".equals(uri.getHost())
|| !"/gateway.do".equals(uri.getPath()) || uri.getUserInfo() != null || uri.getFragment() != null
|| (uri.getPort() != -1 && uri.getPort() != 443)) {
throw new IllegalStateException("支付宝手机网站收银地址无效");
}
} catch (RuntimeException exception) {
thirdAppPlusAliPayService.cancel(context.getPayNo(), payOrder.getMchPaySettingJson());
log.error("|{}|支付宝JSAPI子订单保存失败,已执行最佳努力撤销,payNo:{}", TAG, context.getPayNo());
throw new I18nBizException(ErrorCodes.THIRD_ALIPAY_JSAPI_CREATE_FAIL);
} catch (Exception exception) {
saveFailedOrder(payOrder, "WAP_SIGN_FAILED", exception.getClass().getSimpleName());
log.warn("|{}|支付宝WAP签名失败,payNo:{},exceptionType:{}", TAG, context.getPayNo(), exception.getClass().getSimpleName());
throw new IllegalStateException("支付宝手机网站付款暂不可用");
}
return new ThirdAggregateCashierLaunch(AppPlusPlatformTypeEnum.ALI_PAY, context.getPayNo(), payParams);
Map<String, Object> params = new LinkedHashMap<>();
params.put("mode", WAP_MODE);
params.put("payUrl", payUrl);
payOrder.setPayRes(JSONUtil.createObj().set("aggregatePay", true)
.set("launchParams", JSONUtil.createObj().set("mode", WAP_MODE))
.set("payUrlCiphertext", stringEncryptor.encrypt(payUrl)).toString());
// 尚未交付 URL 时无渠道交易,无需调用撤销;保存失败直接停止交付,不能生成可支付但无流水的请求。
if (!thirdPayOrderService.save(payOrder)) {
throw new I18nBizException(ErrorCodes.THIRD_AGGREGATE_SUB_ORDER_SAVE_FAIL);
}
log.info("|{}|支付宝WAP请求已签名,configKey:ali-pay-config,appId:[{}],api:{},productCode:{},payNo:{}",
TAG, config.getAppId(), request.getApiMethodName(), WAP_PRODUCT_CODE, context.getPayNo());
return new ThirdAggregateCashierLaunch(AppPlusPlatformTypeEnum.ALI_PAY, context.getPayNo(), params);
}
/** WAP 使用原交易应用关闭未支付订单,渠道成功或未知时由编排层继续确认。 */
@Override
public AlipayTradeCloseResponse close(String payNo, String settingJson) {
return thirdAppPlusAliPayService.close(payNo, settingJson);
}
/**
@@ -259,12 +220,12 @@ public class ThirdAggregateAliPayServiceImpl implements ThirdAggregateAliPayServ
/**
* 获取延迟初始化的支付宝客户端。
*
* @return 使用小程序支付应用 AppID 与配套密钥初始化的客户端
* @return 使用现有网页/移动应用 AppID 与配套密钥初始化的支付客户端
*/
protected synchronized AlipayClient getAlipayClient() {
if (alipayClient == null) {
try {
alipayClient = createAlipayClient(appPlusPayConfig.requireAliPayMiniConfig());
alipayClient = createAlipayClient(appPlusPayConfig.requireAliPayWebPayConfig());
} catch (Exception exception) {
throw new I18nBizException(ErrorCodes.THIRD_ALIPAY_CLIENT_INIT_FAIL);
}
@@ -272,18 +233,6 @@ 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 客户端,不在两类应用之间自动降级。
*
@@ -303,34 +252,6 @@ public class ThirdAggregateAliPayServiceImpl implements ThirdAggregateAliPayServ
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或完成官方支持的身份打通");
}
/**
* 构建不含付款人标识和商户授权令牌的请求快照。
*
@@ -340,7 +261,7 @@ public class ThirdAggregateAliPayServiceImpl implements ThirdAggregateAliPayServ
private JSONObject buildRequestSnapshot(ThirdAggregatePayCreateContext context) {
return JSONUtil.createObj()
.set("aggregatePay", true)
.set("productCode", PRODUCT_CODE)
.set("productCode", WAP_PRODUCT_CODE)
.set("outTradeNo", context.getPayNo())
.set("amount", context.getAmount())
.set("expireTime", context.getExpireTime());
@@ -370,7 +291,7 @@ public class ThirdAggregateAliPayServiceImpl implements ThirdAggregateAliPayServ
payOrder.setExpireTime(context.getExpireTime());
// 使用现有 JSON 保存实际下单 AppID,后续查单、退款及回调均按原应用处理。
payOrder.setMchPaySettingJson(JSONUtil.parseObj(JSONUtil.toJsonStr(paySetting))
.set(AppPlusPayConfig.ALIPAY_APP_ID_SNAPSHOT_KEY, appPlusPayConfig.requireAliPayMiniConfig().getAppId()).toString());
.set(AppPlusPayConfig.ALIPAY_APP_ID_SNAPSHOT_KEY, appPlusPayConfig.requireAliPayWebPayConfig().getAppId()).toString());
return payOrder;
}
@@ -6,6 +6,7 @@ import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import com.alipay.api.response.AlipayTradeCancelResponse;
import com.alipay.api.response.AlipayTradeCloseResponse;
import com.alipay.api.response.AlipayTradeQueryResponse;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
@@ -28,7 +29,6 @@ import com.bsd.pjcloud.thirdparty.dto.appplus.ThirdAggregateOrderCodePayDto;
import com.bsd.pjcloud.thirdparty.entity.pay.ThirdAggregatePaySessionEntity;
import com.bsd.pjcloud.thirdparty.entity.pay.ThirdMchFzRelationEntity;
import com.bsd.pjcloud.thirdparty.entity.pay.ThirdPayOrderEntity;
import com.bsd.pjcloud.thirdparty.model.pay.ThirdAggregateAlipayOauthIdentity;
import com.bsd.pjcloud.thirdparty.model.pay.ThirdAggregateCashierLaunch;
import com.bsd.pjcloud.thirdparty.model.pay.ThirdAggregateCashierPageData;
import com.bsd.pjcloud.thirdparty.model.pay.ThirdAggregatePayCreateContext;
@@ -56,7 +56,6 @@ import java.security.SecureRandom;
import java.time.Duration;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.Base64;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
@@ -100,7 +99,7 @@ public class ThirdAggregateOrderPayServiceImpl implements ThirdAggregateOrderPay
private static final String CREATE_LOCK_PREFIX = "thirdparty:order-code-pay:lock:";
/**
* OAuth state Redis 键前缀,租户前缀由 Redis 基础设施自动附加。
* 微信 OAuth state Redis 键前缀,租户前缀由 Redis 基础设施自动附加。
*/
private static final String OAUTH_STATE_PREFIX = "thirdparty:aggregate-order-pay:oauth-state:";
@@ -129,36 +128,16 @@ public class ThirdAggregateOrderPayServiceImpl implements ThirdAggregateOrderPay
*/
private static final int ACCESS_TOKEN_BYTES = 32;
/**
* 支付宝用户授权入口。
*/
private static final String ALIPAY_OAUTH_URL = "https://openauth.alipay.com/oauth2/publicAppAuthorize.htm";
/**
* 微信公众号网页授权入口。
*/
private static final String WECHAT_OAUTH_URL = "https://open.weixin.qq.com/connect/oauth2/authorize";
/**
* 支付宝 OAuth 最小静默授权范围。
*/
private static final String ALIPAY_OAUTH_SCOPE = "auth_base";
/**
* 微信公众号 OAuth 最小静默授权范围。
*/
private static final String WECHAT_OAUTH_SCOPE = "snsapi_base";
/**
* 支付宝固定 OAuth 回调路径,避免租户编码进入回调 URL 后要求逐租户配置平台白名单。
*/
private static final String ALIPAY_OAUTH_CALLBACK_PATH = "/aggregate-order-pay/oauth/alipay";
/**
* 支付宝 OAuth state 中租户编码与随机 state 的分隔符。
*/
private static final String OAUTH_STATE_TENANT_SEPARATOR = ".";
/**
* 支付宝撤销接口要求稍后重试的标记值。
*/
@@ -176,7 +155,7 @@ public class ThirdAggregateOrderPayServiceImpl implements ThirdAggregateOrderPay
private final ThirdAggregatePaySessionService aggregatePaySessionService;
/**
* 支付宝 OAuth、JSAPI 下单、查单和撤销服务。
* 支付宝 WAP 签名、查单、关单及历史撤销服务。
*/
private final ThirdAggregateAliPayService aggregateAliPayService;
@@ -349,12 +328,12 @@ public class ThirdAggregateOrderPayServiceImpl implements ThirdAggregateOrderPay
}
/**
* 在租户上下文中校验聚合访问令牌和扫码客户端,然后创建一次性 OAuth state。
* 校验访问令牌和扫码客户端:支付宝只使用 WAP,历史支付宝只查询原单,微信保留 OAuth。
*
* @param tenantCode 聚合 URL 中的租户唯一编码
* @param token 聚合 URL 随机访问令牌
* @param userAgent 扫码客户端 User-Agent
* @return 支付宝或微信官方 OAuth 授权地址
* @return 新 WAP 收银 H5 地址、历史支付宝只读票据,或微信官方 OAuth 授权地址
*/
@Override
public String getCashierOauthUrl(String tenantCode, String token, String userAgent) {
@@ -378,26 +357,20 @@ public class ThirdAggregateOrderPayServiceImpl implements ThirdAggregateOrderPay
.noneMatch(setting -> platform.getType().equals(setting.getPlatformType()))) {
return h5Support.cashierUrl(tenantCode, createStatusTicket(session.getAggregateNo()));
}
String storedState = createOauthState(session.getId(), platform);
String state = ObjectUtil.equals(platform, AppPlusPlatformTypeEnum.ALI_PAY)
? buildAlipayOauthState(tenantCode, storedState) : storedState;
String callbackPath = ObjectUtil.equals(platform, AppPlusPlatformTypeEnum.ALI_PAY)
? ALIPAY_OAUTH_CALLBACK_PATH
: "/aggregate-order-pay/oauth/" + platformPath(platform) + "/" + tenantCode;
String callbackUrl = buildPublicUrl(getPlatformBaseUrl(platform), callbackPath);
if (ObjectUtil.equals(platform, AppPlusPlatformTypeEnum.WECHAT)) {
return WECHAT_OAUTH_URL + "?appid=" + encode(appPlusPayConfig.getWechatConfig().getGzhAppId())
+ "&redirect_uri=" + encode(callbackUrl)
+ "&response_type=code&scope=" + WECHAT_OAUTH_SCOPE
+ "&state=" + encode(state) + "#wechat_redirect";
if (platform == AppPlusPlatformTypeEnum.ALI_PAY) {
// 已有旧渠道子单只能查询和结算,不能切换产品后为同一会话再创建一笔可支付 WAP 请求。
if (isHistoricalAlipaySession(session)) {
return h5Support.cashierUrl(tenantCode, createStatusTicket(session.getAggregateNo()));
}
return enterAlipayWapCashier(tenantCode, session);
}
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
String state = createOauthState(session.getId(), AppPlusPlatformTypeEnum.WECHAT);
String callbackUrl = buildPublicUrl(getPlatformBaseUrl(AppPlusPlatformTypeEnum.WECHAT),
"/aggregate-order-pay/oauth/wechat/" + tenantCode);
return WECHAT_OAUTH_URL + "?appid=" + encode(appPlusPayConfig.getWechatConfig().getGzhAppId())
+ "&redirect_uri=" + encode(callbackUrl)
+ "&state=" + encode(state);
+ "&response_type=code&scope=" + WECHAT_OAUTH_SCOPE
+ "&state=" + encode(state) + "#wechat_redirect";
});
}
@@ -415,41 +388,7 @@ public class ThirdAggregateOrderPayServiceImpl implements ThirdAggregateOrderPay
return TenantBroker.applyAs(tenant.getId(), ignored -> {
ThirdAggregatePaySessionEntity session = consumeOauthState(state, AppPlusPlatformTypeEnum.WECHAT);
String spOpenId = aggregateWechatPayService.exchangeSpOpenId(authCode);
ThirdAggregateCashierLaunch launch = createOrReuseChildOrder(session, AppPlusPlatformTypeEnum.WECHAT,
spOpenId, null);
return buildCashierPageData(tenantCode, session.getAggregateNo(), launch);
});
}
/**
* 处理固定支付宝 OAuth 回调,从一次性 state 中恢复租户编码后进入对应租户上下文。
*
* @param authCode 支付宝一次性授权码
* @param state 包含租户编码和 Redis 一次性随机值的 OAuth state
* @return 支付宝 JSBridge 调起数据和短期状态查询地址
*/
@Override
public ThirdAggregateCashierPageData handleAlipayOauth(String authCode, String state) {
String[] stateParts = parseAlipayOauthState(state);
return handleAlipayOauthInTenant(stateParts[0], authCode, stateParts[1]);
}
/**
* 在指定租户上下文中消费支付宝 OAuth state 并创建或复用 JSAPI 子订单。
*
* @param tenantCode 已由固定回调 state 确认的租户唯一编码
* @param authCode 支付宝一次性授权码
* @param storedState Redis 中保存的一次性随机 state
* @return 支付宝 JSBridge 调起数据和短期状态查询地址
*/
private ThirdAggregateCashierPageData handleAlipayOauthInTenant(String tenantCode, String authCode,
String storedState) {
SysTenant tenant = resolveTenant(tenantCode);
return TenantBroker.applyAs(tenant.getId(), ignored -> {
ThirdAggregatePaySessionEntity session = consumeOauthState(storedState, AppPlusPlatformTypeEnum.ALI_PAY);
ThirdAggregateAlipayOauthIdentity identity = aggregateAliPayService.exchangeOauthCode(authCode);
ThirdAggregateCashierLaunch launch = createOrReuseChildOrder(session, AppPlusPlatformTypeEnum.ALI_PAY,
identity.getStableIdentity(), identity);
ThirdAggregateCashierLaunch launch = createOrReuseWechatChildOrder(session, spOpenId);
return buildCashierPageData(tenantCode, session.getAggregateNo(), launch);
});
}
@@ -458,7 +397,7 @@ public class ThirdAggregateOrderPayServiceImpl implements ThirdAggregateOrderPay
* 在 URL 指定租户上下文中使用短期 ticket 查询聚合支付状态。
*
* @param tenantCode 聚合 URL 中的租户唯一编码
* @param ticket OAuth 回调收银页取得的短期状态票据
* @param ticket WAP 或微信收银页取得的短期状态票据
* @return 不含敏感字段的聚合支付状态
*/
@Override
@@ -508,7 +447,8 @@ public class ThirdAggregateOrderPayServiceImpl implements ThirdAggregateOrderPay
context.setPaySuccess(Boolean.TRUE.equals(status.getPaySuccess()));
context.setExpireTime(session.getExpireTime());
if (ThirdAggregatePaySessionStatusEnum.PAYING.getType().equals(session.getStatus()) && !isExpired(session)) {
context.setLaunchParams(restoreLaunch(session).getPayParams());
ThirdAggregateCashierLaunch launch = restoreLaunch(session);
if (launch != null) context.setLaunchParams(launch.getPayParams());
}
return context;
});
@@ -561,7 +501,7 @@ public class ThirdAggregateOrderPayServiceImpl implements ThirdAggregateOrderPay
}
/**
* 识别共享回调中的聚合 JSAPI 子订单并在自动处理期限内同步聚合会话。
* 识别共享回调中的聚合渠道流水并同步聚合会话,历史支付宝流水仍按原绑定处理。
*
* <p>已验签的延迟成功回调仍以渠道事实为准,即使会话已经进入 EXPIRED,也允许 CAS 恢复为支付成功;
* 只有没有绑定渠道子订单的历史会话才可以本地终止。</p>
@@ -644,20 +584,17 @@ public class ThirdAggregateOrderPayServiceImpl implements ThirdAggregateOrderPay
}
/**
* 原子选定平台后创建第三方子订单,或为同平台同付款人恢复已存在的调起参数。
* 仅为微信 OAuth 原子绑定付款人并创建子订单,或恢复同一微信付款人的原单参数。
*
* @param originalSession OAuth state 绑定的会话快照
* @param platform 当前 OAuth 平台
* @param payerIdentity 付款人原始身份,仅用于摘要比较
* @param alipayIdentity 支付宝 OAuth 身份;微信平台时为空
* @param spOpenId 微信公众号授权确认的付款人身份,仅用于摘要比较及微信下单
* @return 当前付款人的 JSBridge 调起参数
*/
private ThirdAggregateCashierLaunch createOrReuseChildOrder(ThirdAggregatePaySessionEntity originalSession,
AppPlusPlatformTypeEnum platform, String payerIdentity,
ThirdAggregateAlipayOauthIdentity alipayIdentity) {
private ThirdAggregateCashierLaunch createOrReuseWechatChildOrder(ThirdAggregatePaySessionEntity originalSession, String spOpenId) {
AppPlusPlatformTypeEnum platform = AppPlusPlatformTypeEnum.WECHAT;
ThirdAggregatePaySessionEntity session = aggregatePaySessionService.getById(originalSession.getId());
validateCashierSession(session, platform);
String payerHash = sha256(payerIdentity);
String payerHash = sha256(spOpenId);
if (StrUtil.isNotBlank(session.getSelectedPlatform())) {
validateSelectedPayer(session, platform, payerHash);
if (ObjectUtil.equals(session.getStatus(), ThirdAggregatePaySessionStatusEnum.PAYING.getType())) {
@@ -683,16 +620,9 @@ public class ThirdAggregateOrderPayServiceImpl implements ThirdAggregateOrderPay
ThirdAggregatePayCreateContext context = buildCreateContext(session, payNo);
try {
ThirdAggregateCashierLaunch launch;
if (ObjectUtil.equals(platform, AppPlusPlatformTypeEnum.ALI_PAY)) {
MchPaySettingVo setting = getRequiredAliPaySetting(merchant);
setting = applyDefaultPaymentRelation(setting, context);
launch = aggregateAliPayService.createPay(context, setting, alipayIdentity);
} else {
MchPaySettingVo setting = getRequiredWechatPaySetting(merchant);
setting = applyDefaultPaymentRelation(setting, context);
launch = aggregateWechatPayService.createPay(context, setting, payerIdentity);
}
MchPaySettingVo setting = getRequiredWechatPaySetting(merchant);
setting = applyDefaultPaymentRelation(setting, context);
ThirdAggregateCashierLaunch launch = aggregateWechatPayService.createPay(context, setting, spOpenId);
if (!aggregatePaySessionService.markPaying(session.getId(), payNo)) {
ThirdPayOrderEntity latestPayOrder = thirdPayOrderService.getByPayNo(payNo);
ThirdAggregatePaySessionEntity latestSession = aggregatePaySessionService.getById(session.getId());
@@ -703,7 +633,7 @@ public class ThirdAggregateOrderPayServiceImpl implements ThirdAggregateOrderPay
aggregatePaySessionService.syncFromPayOrder(latestPayOrder);
return launch;
}
closeOrCancelChildOrder(latestPayOrder);
closeWechatChildOrder(latestPayOrder);
throw new RuntimeException("聚合支付会话状态已变化,请重新创建二维码");
}
return launch;
@@ -713,6 +643,142 @@ public class ThirdAggregateOrderPayServiceImpl implements ThirdAggregateOrderPay
}
}
/**
* 识别已经绑定旧支付宝渠道流水的会话;升级只停止旧调起能力,不重新创建交易或改写历史身份。
*
* @param session 当前租户通过随机访问令牌取得的支付宝会话
* @return 存在历史付款人摘要或已绑定非 WAP 流水时为真,仅允许签发只读收银票据
*/
private boolean isHistoricalAlipaySession(ThirdAggregatePaySessionEntity session) {
if (!AppPlusPlatformTypeEnum.ALI_PAY.getType().equals(session.getSelectedPlatform())) return false;
if (StrUtil.isNotBlank(session.getPayerIdentityHash())) return true;
ThirdPayOrderEntity payOrder = thirdPayOrderService.getByPayNo(session.getPayNo());
return payOrder != null && !isWapOrder(payOrder);
}
/**
* 不依赖 OAuth 创建 WAP 付款请求。随机访问令牌已由入口校验,付款人由支付宝官方网关识别。
* 同一会话仍只绑定一个 payNo;恢复时必须确认为无身份摘要的 WAP 流水,不能绕过历史 JSAPI 买家绑定。
*/
private String enterAlipayWapCashier(String tenantCode, ThirdAggregatePaySessionEntity originalSession) {
ThirdAggregatePaySessionEntity session = aggregatePaySessionService.getById(originalSession.getId());
validateCashierSession(session, AppPlusPlatformTypeEnum.ALI_PAY);
if (StrUtil.isNotBlank(session.getSelectedPlatform())) {
validateWapResume(session);
return h5Support.cashierUrl(tenantCode, createStatusTicket(session.getAggregateNo()));
}
MchManageVo merchant = getMerchant(session.getMchId());
MchPaySettingVo setting = getRequiredAliPaySetting(merchant);
// 在平台绑定前验证短期限及直付通关系,避免明显无效请求占用扫码会话。
if (!session.getExpireTime().isAfter(LocalDateTime.now().plusMinutes(1))) {
throw new IllegalArgumentException("付款入口剩余有效期不足,请重新扫码");
}
String payNo = createPayNo();
ThirdAggregatePayCreateContext context = buildCreateContext(session, payNo);
setting = applyDefaultPaymentRelation(setting, context);
if (!aggregatePaySessionService.selectWapPlatform(session.getId(), payNo)) {
ThirdAggregatePaySessionEntity latest = aggregatePaySessionService.getById(session.getId());
validateCashierSession(latest, AppPlusPlatformTypeEnum.ALI_PAY);
validateWapResume(latest);
return h5Support.cashierUrl(tenantCode, createStatusTicket(latest.getAggregateNo()));
}
try {
String ticket = createStatusTicket(session.getAggregateNo());
String returnUrl = buildPublicUrl(getPlatformBaseUrl(AppPlusPlatformTypeEnum.ALI_PAY),
"/aggregate-order-pay/return/alipay/" + encode(tenantCode) + "/" + ticket);
aggregateAliPayService.createWapPay(context, setting, returnUrl);
if (!aggregatePaySessionService.markPaying(session.getId(), payNo)) {
// 签名地址尚未交付浏览器,不能当作已支付;保留绑定流水供后台补偿确认。
throw new IllegalStateException("付款会话状态已变化,请稍后查询");
}
return h5Support.cashierUrl(tenantCode, ticket);
} catch (RuntimeException exception) {
// 保存后的状态推进失败不能终止补偿;数据库提交结果未知时同样保留原会话。
try {
ThirdPayOrderEntity saved = thirdPayOrderService.getByPayNo(payNo);
if (saved == null || ThirdPayOrderStatusEnum.PAY_FAILED.getType().equals(saved.getStatus())) {
aggregatePaySessionService.markCreateFailed(session.getId(), payNo);
}
} catch (RuntimeException readException) {
log.warn("|{}|WAP创建结果待恢复,payNo:{}", TAG, payNo);
}
throw exception;
}
}
/** 只恢复同一无 OAuth 会话的 WAP 待支付流水;已落单的创建中状态可补偿,历史 JSAPI 不能放行。 */
private void validateWapResume(ThirdAggregatePaySessionEntity session) {
ThirdPayOrderEntity payOrder = session == null ? null : thirdPayOrderService.getByPayNo(session.getPayNo());
if (session != null && isWapOrder(payOrder)
&& ThirdPayOrderStatusEnum.PAY_UNPAID.getType().equals(payOrder.getStatus())
&& StrUtil.isBlank(session.getPayerIdentityHash())
&& AppPlusPlatformTypeEnum.ALI_PAY.getType().equals(session.getSelectedPlatform())
&& ThirdAggregatePaySessionStatusEnum.PLATFORM_CREATING.getType().equals(session.getStatus())) {
// 上次落单成功但状态推进中断时,只恢复同一 payNo,不签第二笔请求。
aggregatePaySessionService.markPaying(session.getId(), session.getPayNo());
session = aggregatePaySessionService.getById(session.getId());
}
if (session == null || StrUtil.isNotBlank(session.getPayerIdentityHash())
|| !AppPlusPlatformTypeEnum.ALI_PAY.getType().equals(session.getSelectedPlatform())
|| !ThirdAggregatePaySessionStatusEnum.PAYING.getType().equals(session.getStatus())
|| !isWapOrder(payOrder)) {
throw new IllegalStateException("付款订单正在确认,请稍后重试");
}
}
/** 从已有请求快照识别 WAP 产品,不按客户端传来的模式或 URL 判定关单方式。 */
private boolean isWapOrder(ThirdPayOrderEntity payOrder) {
if (payOrder == null || StrUtil.isBlank(payOrder.getPayRep())) return false;
try {
JSONObject request = JSONUtil.parseObj(payOrder.getPayRep());
return Boolean.TRUE.equals(request.getBool("aggregatePay")) && "QUICK_WAP_WAY".equals(request.getStr("productCode"));
} catch (RuntimeException exception) {
return false;
}
}
/** 只接受渠道明确的不存在响应;网络、权限及签名异常都不能用于释放原付款意图。 */
private boolean isAlipayTradeMissing(AlipayTradeQueryResponse response) {
return response != null && !response.isSuccess() && "ACQ.TRADE_NOT_EXIST".equals(response.getSubCode());
}
/**
* WAP 的签名地址在过期前仍能创建渠道单;到期后明确查无此单才可结束本地请求。
* 已存在的交易使用 close,付款竞态失败后重新查单,避免 cancel 自动退款已成功的交易。
*/
private void closeExpiredWapOrder(ThirdAggregatePaySessionEntity session, ThirdPayOrderEntity payOrder, AlipayTradeQueryResponse response) {
if (payOrder.getExpireTime() == null || payOrder.getExpireTime().isAfter(LocalDateTime.now())) return;
if (isAlipayTradeMissing(response)) {
ThirdPayOrderEntity update = new ThirdPayOrderEntity();
update.setStatus(ThirdPayOrderStatusEnum.PAY_CLOSED.getType());
// 区分“本地到期且未建渠道单”和渠道真实关单,只有前者允许迟到的验真成功补偿。
JSONObject snapshot = StrUtil.isBlank(payOrder.getPayRes()) ? JSONUtil.createObj() : JSONUtil.parseObj(payOrder.getPayRes());
update.setPayRes(snapshot.set("wapClosedWithoutTrade", true).toString());
thirdPayOrderService.update(update, new LambdaUpdateWrapper<ThirdPayOrderEntity>()
.eq(ThirdPayOrderEntity::getId, payOrder.getId())
.eq(ThirdPayOrderEntity::getStatus, ThirdPayOrderStatusEnum.PAY_UNPAID.getType()));
syncSessionFromChildOrder(session);
return;
}
if (response == null || !response.isSuccess() || !"WAIT_BUYER_PAY".equals(response.getTradeStatus())) return;
AlipayTradeCloseResponse closed = aggregateAliPayService.close(payOrder.getPayNo(), payOrder.getMchPaySettingJson());
if (closed != null && closed.isSuccess()) {
updateChildOrderClosed(payOrder.getId());
syncSessionFromChildOrder(session);
return;
}
AlipayTradeQueryResponse latest = aggregateAliPayService.query(payOrder.getPayNo(), payOrder.getMchPaySettingJson());
if (latest != null && latest.isSuccess()) {
if ("TRADE_SUCCESS".equals(latest.getTradeStatus()) || "TRADE_FINISHED".equals(latest.getTradeStatus())) {
updateChildOrderSuccess(payOrder, latest.getTradeNo());
syncSessionFromChildOrder(session);
} else if ("TRADE_CLOSED".equals(latest.getTradeStatus())) {
updateChildOrderClosed(payOrder.getId());
syncSessionFromChildOrder(session);
}
}
}
/**
* 查询支付宝子订单并根据交易状态原子同步成功、关闭或到期撤销结果。
*
@@ -737,6 +803,10 @@ public class ThirdAggregateOrderPayServiceImpl implements ThirdAggregateOrderPay
if (!isExpired(session)) {
return;
}
if (isWapOrder(payOrder)) {
closeExpiredWapOrder(session, payOrder, response);
return;
}
AlipayTradeCancelResponse cancelResponse = aggregateAliPayService.cancel(payOrder.getPayNo(), payOrder.getMchPaySettingJson());
if (cancelResponse != null && cancelResponse.isSuccess()
&& ObjectUtil.notEqual(cancelResponse.getRetryFlag(), ALIPAY_CANCEL_RETRY)
@@ -868,34 +938,30 @@ public class ThirdAggregateOrderPayServiceImpl implements ThirdAggregateOrderPay
}
/**
* 对已创建但会话绑定失败的子订单执行最佳努力撤销或关单。
* 微信子订单已创建但会话绑定失败时尝试关单,未知结果继续保留原支付流水等待补偿。
*
* @param payOrder 需要关闭的第三方子订单,允许为空
* @param payOrder 创建中的微信子订单;为空、其他平台或已经结束时不处理
*/
private void closeOrCancelChildOrder(ThirdPayOrderEntity payOrder) {
if (payOrder == null || ObjectUtil.notEqual(payOrder.getStatus(), ThirdPayOrderStatusEnum.PAY_UNPAID.getType())) {
return;
}
boolean closed = false;
if (ObjectUtil.equals(payOrder.getPayPlatformType(), AppPlusPlatformTypeEnum.WECHAT.getType())) {
closed = aggregateWechatPayService.close(payOrder.getPayNo(), payOrder.getMchPaySettingJson());
} else {
AlipayTradeCancelResponse response = aggregateAliPayService.cancel(payOrder.getPayNo(), payOrder.getMchPaySettingJson());
closed = response != null && response.isSuccess() && ObjectUtil.notEqual(response.getRetryFlag(), ALIPAY_CANCEL_RETRY);
}
if (closed) {
private void closeWechatChildOrder(ThirdPayOrderEntity payOrder) {
if (payOrder == null || !AppPlusPlatformTypeEnum.WECHAT.getType().equals(payOrder.getPayPlatformType())
|| !ThirdPayOrderStatusEnum.PAY_UNPAID.getType().equals(payOrder.getStatus())) return;
if (aggregateWechatPayService.close(payOrder.getPayNo(), payOrder.getMchPaySettingJson())) {
updateChildOrderClosed(payOrder.getId());
}
}
/**
* 从已支付中会话绑定的子订单响应快照恢复 JSBridge 调起参数。
* 从支付中会话绑定的子订单快照恢复渠道参数,WAP 签名地址仅在内存中解密。
*
* @param session 已选定平台且状态为支付中的聚合会话
* @return 原平台 JSBridge 调起参数
* @return 微信 JSBridge 参数或 WAP 官方签名地址;历史支付宝返回 null,保持只读
*/
private ThirdAggregateCashierLaunch restoreLaunch(ThirdAggregatePaySessionEntity session) {
ThirdPayOrderEntity payOrder = thirdPayOrderService.getByPayNo(session.getPayNo());
// 历史支付宝流水只提供结果查询;升级后不得再向 H5 输出 tradeNO 或重新签名第二笔付款。
if (AppPlusPlatformTypeEnum.ALI_PAY.getType().equals(session.getSelectedPlatform()) && !isWapOrder(payOrder)) {
return null;
}
if (payOrder == null || StrUtil.isBlank(payOrder.getPayRes())) {
throw new RuntimeException("支付订单调起参数不存在,请重新创建二维码");
}
@@ -906,6 +972,14 @@ public class ThirdAggregateOrderPayServiceImpl implements ThirdAggregateOrderPay
}
Map<String, Object> params = new LinkedHashMap<>();
launchObject.forEach(params::put);
if (isWapOrder(payOrder)) {
String ciphertext = JSONUtil.parseObj(payOrder.getPayRes()).getStr("payUrlCiphertext");
if (!"ALIPAY_WAP".equals(params.get("mode")) || StrUtil.isBlank(ciphertext)) {
throw new IllegalStateException("手机网站付款参数不可用,请稍后查询");
}
// 仅票据上下文在内存解密,数据库与普通后台流水接口不保存明文签名地址。
params.put("payUrl", stringEncryptor.decrypt(ciphertext));
}
return new ThirdAggregateCashierLaunch(AppPlusPlatformTypeEnum.valueOfByType(session.getSelectedPlatform()),
session.getPayNo(), params);
} catch (RuntimeException exception) {
@@ -976,49 +1050,6 @@ public class ThirdAggregateOrderPayServiceImpl implements ThirdAggregateOrderPay
return state;
}
/**
* 将租户编码绑定到支付宝 OAuth state,租户编码仅用于回调恢复上下文,随机部分仍由 Redis 一次性消费。
*
* @param tenantCode 聚合二维码中的租户唯一编码
* @param storedState 已保存到当前租户 Redis 命名空间的一次性随机 state
* @return 可安全放入 OAuth URL 的租户绑定 state
*/
private String buildAlipayOauthState(String tenantCode, String storedState) {
if (StrUtil.isBlank(tenantCode) || StrUtil.isBlank(storedState)) {
throw new RuntimeException("支付宝授权状态生成失败");
}
String encodedTenantCode = Base64.getUrlEncoder().withoutPadding()
.encodeToString(tenantCode.getBytes(StandardCharsets.UTF_8));
return encodedTenantCode + OAUTH_STATE_TENANT_SEPARATOR + storedState;
}
/**
* 解析固定支付宝回调携带的租户绑定 state,并拒绝缺段、非法编码或空值。
*
* @param state 支付宝原样回传的 OAuth state
* @return 长度为二的数组,依次为租户唯一编码和 Redis 一次性随机 state
*/
private String[] parseAlipayOauthState(String state) {
if (StrUtil.isBlank(state)) {
throw new RuntimeException("支付授权状态不能为空");
}
int separatorIndex = state.indexOf(OAUTH_STATE_TENANT_SEPARATOR);
if (separatorIndex <= 0 || separatorIndex >= state.length() - OAUTH_STATE_TENANT_SEPARATOR.length()) {
throw new RuntimeException("支付宝授权状态格式错误,请重新扫码");
}
String encodedTenantCode = state.substring(0, separatorIndex);
String storedState = state.substring(separatorIndex + OAUTH_STATE_TENANT_SEPARATOR.length());
try {
String tenantCode = new String(Base64.getUrlDecoder().decode(encodedTenantCode), StandardCharsets.UTF_8);
if (StrUtil.isBlank(tenantCode) || StrUtil.isBlank(storedState)) {
throw new RuntimeException("支付宝授权状态格式错误,请重新扫码");
}
return new String[]{tenantCode, storedState};
} catch (IllegalArgumentException exception) {
throw new RuntimeException("支付宝授权状态格式错误,请重新扫码");
}
}
/**
* 校验聚合会话可继续扫码,并阻止另一平台在首个平台已选定后继续授权。
*
@@ -1215,8 +1246,7 @@ public class ThirdAggregateOrderPayServiceImpl implements ThirdAggregateOrderPay
*/
private void validateApplicationConfig(AppPlusPlatformTypeEnum platform) {
if (platform == AppPlusPlatformTypeEnum.ALI_PAY) {
appPlusPayConfig.requireAliPayWebConfig();
appPlusPayConfig.requireAliPayMiniConfig();
appPlusPayConfig.requireAliPayWebPayConfig();
}
AppPlusPayConfig.WechatConfig wechat = appPlusPayConfig.getWechatConfig();
if (platform == AppPlusPlatformTypeEnum.WECHAT && (wechat == null || StrUtil.hasBlank(wechat.getGzhAppId(), wechat.getGzhAppSecret(), wechat.getMerchantId(), wechat.getMchPrivatePath(),
@@ -1368,7 +1398,7 @@ public class ThirdAggregateOrderPayServiceImpl implements ThirdAggregateOrderPay
}
/**
* 获取指定平台 OAuth 回调使用的现有公网基础地址。
* 获取微信 OAuth 或支付宝 WAP 返回使用的现有公网基础地址。
*
* @param platform 支付宝或微信平台
* @return 对应现有回调基础地址
@@ -1377,7 +1407,7 @@ public class ThirdAggregateOrderPayServiceImpl implements ThirdAggregateOrderPay
if (ObjectUtil.equals(platform, AppPlusPlatformTypeEnum.WECHAT)) {
return normalizeBaseUrl(appPlusPayConfig.getWechatConfig().getBaseCallbackUrl());
}
return normalizeBaseUrl(appPlusPayConfig.requireAliPayWebConfig().getBaseCallbackUrl());
return normalizeBaseUrl(appPlusPayConfig.requireAliPayWebPayConfig().getBaseCallbackUrl());
}
/**
@@ -1408,16 +1438,6 @@ public class ThirdAggregateOrderPayServiceImpl implements ThirdAggregateOrderPay
return normalizeBaseUrl(baseUrl) + h5Support.publicRoutePrefix() + path;
}
/**
* 获取 OAuth 回调路径中的平台固定片段。
*
* @param platform 支付宝或微信平台
* @return {@code alipay} 或 {@code wechat}
*/
private String platformPath(AppPlusPlatformTypeEnum platform) {
return ObjectUtil.equals(platform, AppPlusPlatformTypeEnum.WECHAT) ? "wechat" : "alipay";
}
/**
* 使用 UTF-8 对 OAuth URL 查询参数进行百分号编码。
*
@@ -13,6 +13,7 @@ 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;
import com.alipay.api.domain.AlipayTradePrecreateModel;
import com.alipay.api.domain.AlipayTradeQueryModel;
import com.alipay.api.domain.AlipayTradeRefundModel;
@@ -31,6 +32,7 @@ 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;
import com.alipay.api.request.AlipayTradePrecreateRequest;
import com.alipay.api.request.AlipayTradeQueryRequest;
import com.alipay.api.request.AlipayTradeRefundRequest;
@@ -40,6 +42,7 @@ import com.alipay.api.request.AlipayTradeOrderSettleRequest;
import com.alipay.api.request.AlipayTradeOrderSettleQueryRequest;
import com.alipay.api.response.AlipayTradeAppPayResponse;
import com.alipay.api.response.AlipayTradeCancelResponse;
import com.alipay.api.response.AlipayTradeCloseResponse;
import com.alipay.api.response.AlipayTradePrecreateResponse;
import com.alipay.api.response.AlipayTradeQueryResponse;
import com.alipay.api.response.AlipayTradeRefundResponse;
@@ -110,6 +113,9 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService
/** 历史聚合单保存的当面付产品码,回调兼容时不得与原订单码产品码混用。 */
private static final String LEGACY_AGGREGATE_FACE_PRODUCT_CODE = "FACE_TO_FACE_PAYMENT";
/** 手机网站聚合收银产品码;与历史 JSAPI、当面付一样必须具有严格的聚合会话绑定。 */
private static final String AGGREGATE_WAP_PRODUCT_CODE = "QUICK_WAP_WAY";
/**
* 订单码交易标题,固定安全文案避免把业务隐私传给第三方平台。
*/
@@ -498,6 +504,29 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService
}
}
/**
* 使用支付单原应用和商户授权关闭未付款交易,避免支付与到期处理竞争时撤销已成功的 WAP 付款。
*
* @param payNo 第三方支付服务生成的商户订单号
* @param settingJson 创建订单时保存的商户配置及 AppID 快照
* @return 支付宝原始关单响应;异常返回 {@code null},不在本方法推断或更新本地交易终态
*/
@Override
public AlipayTradeCloseResponse close(String payNo, String settingJson) {
try {
MchPaySettingEntity.AliPaySetting aliPaySetting = parseAliPaySettingSnapshot(settingJson);
AlipayTradeCloseRequest request = new AlipayTradeCloseRequest();
AlipayTradeCloseModel model = new AlipayTradeCloseModel();
model.setOutTradeNo(payNo);
request.setBizModel(model);
putAppAuthToken(request::putOtherTextParam, aliPaySetting);
return getOrderAlipayClient(settingJson).execute(request);
} catch (Exception exception) {
log.error("|{}|关闭支付宝订单失败,payNo:{},exceptionType:{}", TAG, payNo, exception.getClass().getSimpleName());
return null;
}
}
/**
* 创建支付宝 App 支付订单。
*
@@ -1699,10 +1728,14 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService
if (ObjectUtil.equals(payOrderEntity.getStatus(), ThirdPayOrderStatusEnum.PAY_SUCCESS.getType())) {
return new AliPayCallbackResult(payOrderEntity, true, false);
}
if (ObjectUtil.equals(payOrderEntity.getStatus(), ThirdPayOrderStatusEnum.PAY_CLOSED.getType())) {
// WAP 在到期查无渠道单后可能已本地结束,已验签且完整绑定的迟到成功仍以真实扣款为准。
// 仅补偿带服务端未建单标记的本地关闭,不恢复渠道真实关闭、退款等终态,也不改变历史支付规则。
boolean wapSuccess = paySuccess && isRecoverableWapClosure(payOrderEntity);
if (ObjectUtil.equals(payOrderEntity.getStatus(), ThirdPayOrderStatusEnum.PAY_CLOSED.getType()) && !wapSuccess) {
return new AliPayCallbackResult(payOrderEntity, false, false);
}
if (ObjectUtil.notEqual(payOrderEntity.getStatus(), ThirdPayOrderStatusEnum.PAY_UNPAID.getType())) {
if (ObjectUtil.notEqual(payOrderEntity.getStatus(), ThirdPayOrderStatusEnum.PAY_UNPAID.getType())
&& !(wapSuccess && ObjectUtil.equals(payOrderEntity.getStatus(), ThirdPayOrderStatusEnum.PAY_CLOSED.getType()))) {
return null;
}
@@ -1715,8 +1748,15 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService
}
boolean stateChanged = thirdPayOrderService.update(updateObj, new LambdaUpdateWrapper<ThirdPayOrderEntity>()
.eq(ThirdPayOrderEntity::getId, payOrderEntity.getId())
.eq(ThirdPayOrderEntity::getStatus, ThirdPayOrderStatusEnum.PAY_UNPAID.getType()));
.eq(ThirdPayOrderEntity::getStatus, payOrderEntity.getStatus()));
ThirdPayOrderEntity latestOrder = thirdPayOrderService.getById(payOrderEntity.getId());
if (!stateChanged && paySuccess && isRecoverableWapClosure(latestOrder)) {
// 首次 CAS 若被“到期查无单”抢先,仅对带服务端标记的同一 WAP 关闭单再补偿一次。
stateChanged = thirdPayOrderService.update(updateObj, new LambdaUpdateWrapper<ThirdPayOrderEntity>()
.eq(ThirdPayOrderEntity::getId, payOrderEntity.getId())
.eq(ThirdPayOrderEntity::getStatus, ThirdPayOrderStatusEnum.PAY_CLOSED.getType()));
latestOrder = thirdPayOrderService.getById(payOrderEntity.getId());
}
if (latestOrder == null) {
return null;
}
@@ -2051,10 +2091,29 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService
}
/**
* 识别独立 H5 当面付及历史聚合 JSAPI 请求快照,后续仍须严格校验会话与订单绑定。
* 仅恢复 WAP 到期查无渠道单产生的本地关闭,渠道真实关单、退款和历史支付不参与补偿。
*
* @param payOrder 当前支付单及仅由服务端写入的请求、响应快照
* @return 关闭来源标记和聚合 WAP 产品均匹配时为真;异常快照拒绝恢复
*/
private boolean isRecoverableWapClosure(ThirdPayOrderEntity payOrder) {
if (payOrder == null || !ThirdPayOrderStatusEnum.PAY_CLOSED.getType().equals(payOrder.getStatus())
|| StrUtil.hasBlank(payOrder.getPayRep(), payOrder.getPayRes())) return false;
try {
JSONObject request = JSONUtil.parseObj(payOrder.getPayRep());
return Boolean.TRUE.equals(request.getBool("aggregatePay"))
&& AGGREGATE_WAP_PRODUCT_CODE.equals(request.getStr("productCode"))
&& Boolean.TRUE.equals(JSONUtil.parseObj(payOrder.getPayRes()).getBool("wapClosedWithoutTrade"));
} catch (RuntimeException exception) {
return false;
}
}
/**
* 识别独立 H5 WAP、历史当面付及聚合 JSAPI 请求快照,后续仍须严格校验会话与订单绑定。
*
* @param payOrder 回调商户订单号对应的内部支付订单
* @return 请求快照包含聚合标记,且产品码为当面付或历史 {@code JSAPI_PAY} 时返回 {@code true}
* @return 请求快照包含聚合标记,且产品码为 WAP、当面付或历史 {@code JSAPI_PAY} 时返回 {@code true}
*/
private boolean isAggregatePayRequest(ThirdPayOrderEntity payOrder) {
if (payOrder == null || StrUtil.isBlank(payOrder.getPayRep())) {
@@ -2064,7 +2123,8 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService
JSONObject request = JSONUtil.parseObj(payOrder.getPayRep());
return Boolean.TRUE.equals(request.getBool("aggregatePay"))
&& (ObjectUtil.equals(request.getStr("productCode"), LEGACY_AGGREGATE_FACE_PRODUCT_CODE)
|| ObjectUtil.equals(request.getStr("productCode"), "JSAPI_PAY"));
|| ObjectUtil.equals(request.getStr("productCode"), "JSAPI_PAY")
|| ObjectUtil.equals(request.getStr("productCode"), AGGREGATE_WAP_PRODUCT_CODE));
} catch (Exception exception) {
return false;
}
@@ -116,6 +116,31 @@ public class ThirdAggregatePaySessionServiceImpl extends ServiceImpl<ThirdAggreg
.isNull(ThirdAggregatePaySessionEntity::getPayNo));
}
/**
* 手机网站支付由支付宝收银台识别买家,仅对未过期、未绑定的平台执行一次 CAS。
*
* @param sessionId 已通过随机访问令牌确认的当前租户会话主键
* @param payNo 预分配第三方子订单号,签名地址重复调起必须复用此单号
* @return 原子绑定成功时返回 {@code true};不写入虚假的付款人身份摘要
*/
@Override
public boolean selectWapPlatform(Long sessionId, String payNo) {
if (sessionId == null || StrUtil.isBlank(payNo)) {
return false;
}
ThirdAggregatePaySessionEntity update = new ThirdAggregatePaySessionEntity();
update.setStatus(ThirdAggregatePaySessionStatusEnum.PLATFORM_CREATING.getType());
update.setSelectedPlatform(AppPlusPlatformTypeEnum.ALI_PAY.getType());
update.setPayNo(payNo);
return update(update, new LambdaUpdateWrapper<ThirdAggregatePaySessionEntity>()
.eq(ThirdAggregatePaySessionEntity::getId, sessionId)
.eq(ThirdAggregatePaySessionEntity::getStatus, ThirdAggregatePaySessionStatusEnum.WAIT_SCAN.getType())
.gt(ThirdAggregatePaySessionEntity::getExpireTime, LocalDateTime.now())
.isNull(ThirdAggregatePaySessionEntity::getSelectedPlatform)
.isNull(ThirdAggregatePaySessionEntity::getPayNo)
.isNull(ThirdAggregatePaySessionEntity::getPayerIdentityHash));
}
/**
* 在第三方子订单保存成功后把会话从创建中推进为支付中。
*
@@ -10,17 +10,17 @@ import org.springframework.http.ResponseEntity;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
/** OAuth 收银出口必须通过 302 导航,不能继续输出 HTML 或把异常带到公网 URL。 */
/** 微信 OAuth 与支付宝 WAP 返回都使用固定 302,不输出 HTML 或把异常带到公网 URL。 */
class ThirdAggregateOrderPayControllerTest {
/** 正常回调只输出 Location,正文不包含渠道参数。 */
/** 微信授权回调继续只输出 Location,正文不包含渠道参数。 */
@Test
void oauthShouldRedirectToH5WithoutHtmlBody() {
void wechatOauthShouldRedirectToH5WithoutHtmlBody() {
ThirdAggregateOrderPayService service = mock(ThirdAggregateOrderPayService.class);
AggregatePayProperties config = new AggregatePayProperties(); config.setH5BaseUrl("https://pay.example.com");
ThirdAggregateH5Support support = new ThirdAggregateH5Support(config, mock(StringRedisTemplate.class));
String target = support.cashierUrl("tenant", "a".repeat(32));
when(service.handleAlipayOauth("code", "state")).thenReturn(new ThirdAggregateCashierPageData(null, null, target));
ResponseEntity<Void> response = new ThirdAggregateOrderPayController(service, support).alipayOauth("code", "state");
when(service.handleWechatOauth("tenant", "code", "state")).thenReturn(new ThirdAggregateCashierPageData(null, null, target));
ResponseEntity<Void> response = new ThirdAggregateOrderPayController(service, support).wechatOauth("tenant", "code", "state");
assertEquals(302, response.getStatusCode().value());
assertEquals(target, response.getHeaders().getLocation().toString());
assertNull(response.getBody());
@@ -29,7 +29,7 @@ class ThirdAggregateOrderPayControllerTest {
/** 授权失败跳转固定提示入口,不反射渠道原始异常或用户参数。 */
@Test
void oauthFailureShouldNotLeakException() {
void wechatOauthFailureShouldNotLeakException() {
ThirdAggregateOrderPayService service = mock(ThirdAggregateOrderPayService.class);
AggregatePayProperties config = new AggregatePayProperties(); config.setH5BaseUrl("https://pay.example.com");
when(service.handleWechatOauth("tenant", "code", "state")).thenThrow(new RuntimeException("private-message"));
@@ -38,4 +38,55 @@ class ThirdAggregateOrderPayControllerTest {
assertEquals("https://pay.example.com/#/pages/pay/index?error=UNAVAILABLE", response.getHeaders().getLocation().toString());
assertNull(response.getBody());
}
/** 手机网站回跳仅恢复同一票据并标记禁止自动重跳,不能借此更新或查询支付状态。 */
@Test
void wapReturnShouldRestoreTicketWithoutTrustingPaymentResult() {
ThirdAggregateOrderPayService service = mock(ThirdAggregateOrderPayService.class);
AggregatePayProperties config = new AggregatePayProperties(); config.setH5BaseUrl("https://pay.example.com");
ThirdAggregateH5Support support = new ThirdAggregateH5Support(config, mock(StringRedisTemplate.class));
String ticket = "a".repeat(32);
ResponseEntity<Void> response = new ThirdAggregateOrderPayController(service, support).alipayReturn("tenant", ticket);
assertEquals(302, response.getStatusCode().value());
assertEquals(support.cashierUrl("tenant", ticket) + "&returned=1", response.getHeaders().getLocation().toString());
assertEquals("no-store", response.getHeaders().getCacheControl());
assertEquals("no-referrer", response.getHeaders().getFirst("Referrer-Policy"));
assertNull(response.getBody());
verifyNoInteractions(service);
}
/** 控制字符和无效票据必须跳固定错误页,不能用于恢复任何收银上下文。 */
@Test
void wapReturnShouldRejectMalformedPath() {
ThirdAggregateOrderPayService service = mock(ThirdAggregateOrderPayService.class);
AggregatePayProperties config = new AggregatePayProperties(); config.setH5BaseUrl("https://pay.example.com");
ThirdAggregateH5Support support = new ThirdAggregateH5Support(config, mock(StringRedisTemplate.class));
ThirdAggregateOrderPayController controller = new ThirdAggregateOrderPayController(service, support);
ResponseEntity<Void> controlCharacter = controller.alipayReturn("tenant\n", "a".repeat(32));
ResponseEntity<Void> badTicket = controller.alipayReturn("tenant", "invalid-ticket");
assertEquals(support.errorUrl(), controlCharacter.getHeaders().getLocation().toString());
assertEquals(support.errorUrl(), badTicket.getHeaders().getLocation().toString());
verifyNoInteractions(service);
}
/** 原有点号、中文等租户编码仍安全编码在固定 H5 参数中,不能改变回跳来源。 */
@Test
void wapReturnShouldEncodeExistingTenantCodesWithoutChangingOrigin() {
ThirdAggregateOrderPayService service = mock(ThirdAggregateOrderPayService.class);
AggregatePayProperties config = new AggregatePayProperties(); config.setH5BaseUrl("https://pay.example.com");
ThirdAggregateH5Support support = new ThirdAggregateH5Support(config, mock(StringRedisTemplate.class));
String tenantCode = "tenant.商户";
String ticket = "a".repeat(32);
ResponseEntity<Void> response = new ThirdAggregateOrderPayController(service, support).alipayReturn(tenantCode, ticket);
assertEquals("pay.example.com", response.getHeaders().getLocation().getHost());
assertEquals(support.cashierUrl(tenantCode, ticket) + "&returned=1", response.getHeaders().getLocation().toString());
assertTrue(response.getHeaders().getLocation().toString().contains("tenant.%E5%95%86%E6%88%B7"));
verifyNoInteractions(service);
}
}
@@ -1,63 +0,0 @@
package com.bsd.pjcloud.thirdparty.service;
import com.bsd.pjcloud.thirdparty.constant.miniplatform.AppPlusPlatformTypeEnum;
import com.bsd.pjcloud.thirdparty.model.pay.ThirdAggregateCashierLaunch;
import com.bsd.pjcloud.thirdparty.model.pay.ThirdAggregateCashierPageData;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import java.util.Map;
/**
* 聚合收银 HTML 渲染器的支付宝 JSBridge 调起脚本测试。
*/
class ThirdAggregateCashierHtmlRendererTest {
/**
* 被测固定 HTML 渲染器,不依赖 Spring 容器。
*/
private final ThirdAggregateCashierHtmlRenderer renderer = new ThirdAggregateCashierHtmlRenderer();
/**
* 验证支付宝收银页严格提取 {@code tradeNO},等待桥接注入并保留真实点击重试入口。
*/
@Test
void renderAlipayPageShouldInvokeBridgeWithTradeNoAndProvideRetry() {
String html = renderer.renderPayPage(alipayPageData(), "test-nonce");
Assertions.assertTrue(html.contains("nonce=\"test-nonce\""));
Assertions.assertTrue(html.contains("id=\"retryButton\""));
Assertions.assertTrue(html.contains("window.AlipayJSBridge"));
Assertions.assertTrue(html.contains("bridge.call('tradePay',{tradeNO:tradeNO},handleAlipayResult)"));
Assertions.assertTrue(html.contains("document.addEventListener('AlipayJSBridgeReady',invokePay,false)"));
Assertions.assertTrue(html.contains("const statusUrl=\"https://pay.example.com/third/aggregate-order-pay/status/tenant-code/test-ticket\";"));
Assertions.assertFalse(html.contains("AlipayJSBridge.call('tradePay',payParams"));
}
/**
* 验证支付宝官方成功、处理中、网络异常、取消和失败结果码均有明确分支,且不会直接认定服务端支付成功。
*/
@Test
void renderAlipayPageShouldHandleOfficialResultCodesAndKeepPolling() {
String html = renderer.renderPayPage(alipayPageData(), "test-nonce");
Assertions.assertTrue(html.contains("resultCode==='9000'"));
Assertions.assertTrue(html.contains("resultCode==='8000'||resultCode==='6004'"));
Assertions.assertTrue(html.contains("resultCode==='6001'"));
Assertions.assertTrue(html.contains("resultCode==='6002'"));
Assertions.assertTrue(html.contains("resultCode==='4000'"));
Assertions.assertTrue(html.contains("setTimeout(startPolling,1000)"));
}
/**
* 构建只含脱敏测试交易号和本地状态地址的支付宝收银页数据。
*
* @return 支付宝聚合收银页测试数据
*/
private ThirdAggregateCashierPageData alipayPageData() {
ThirdAggregateCashierLaunch launch = new ThirdAggregateCashierLaunch(AppPlusPlatformTypeEnum.ALI_PAY,
"test-pay-no", Map.of("tradeNO", "test-alipay-trade-no"));
return new ThirdAggregateCashierPageData(launch,
"https://pay.example.com/third/aggregate-order-pay/status/tenant-code/test-ticket");
}
}
@@ -1,20 +1,19 @@
package com.bsd.pjcloud.thirdparty.service.impl.appplus;
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.alipay.api.domain.AlipayTradeCreateModel;
import com.alipay.api.domain.AlipayTradeWapPayModel;
import com.alipay.api.internal.util.json.JSONWriter;
import com.alipay.api.request.AlipaySystemOauthTokenRequest;
import com.alipay.api.request.AlipayTradeCreateRequest;
import com.alipay.api.response.AlipaySystemOauthTokenResponse;
import com.alipay.api.response.AlipayTradeCreateResponse;
import com.alipay.api.request.AlipayTradeWapPayRequest;
import com.alipay.api.response.AlipayTradeWapPayResponse;
import com.bsd.pjcloud.common.core.constant.enums.YesNoEnum;
import com.bsd.pjcloud.thirdparty.config.AppPlusPayConfig;
import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPayOrderBizTypeEnum;
import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPayOrderStatusEnum;
import com.bsd.pjcloud.thirdparty.entity.pay.ThirdPayOrderEntity;
import com.bsd.pjcloud.thirdparty.model.pay.ThirdAggregateAlipayOauthIdentity;
import com.bsd.pjcloud.thirdparty.model.pay.ThirdAggregateCashierLaunch;
import com.bsd.pjcloud.thirdparty.model.pay.ThirdAggregatePayCreateContext;
import com.bsd.pjcloud.thirdparty.service.ThirdAppPlusAliPayService;
@@ -22,6 +21,7 @@ import com.bsd.pjcloud.thirdparty.service.ThirdPayOrderService;
import com.bsd.pjcloud.thirdparty.service.ThirdMchFzRelationService;
import com.bsd.pjcloud.wallet.api.entity.mch.MchPaySettingEntity;
import com.bsd.pjcloud.wallet.api.vo.mch.MchPaySettingVo;
import org.jasypt.encryption.StringEncryptor;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -37,24 +37,21 @@ import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
/**
* 支付宝聚合 OAuth 与服务商 JSAPI 子订单参数测试。
* 支付宝聚合 WAP 签名、直付通参数、原应用快照与本地持久化边界测试。
*/
@ExtendWith(MockitoExtension.class)
class ThirdAggregateAliPayServiceImplTest {
/**
* 小程序支付 SDK 客户端 Mock,仅用于捕获聚合交易创建请求。
* 网页/移动应用支付 SDK 客户端 Mock,仅用于捕获聚合交易创建请求。
*/
@Mock
private AlipayClient alipayClient;
/** 网页/移动应用 OAuth 客户端,必须与支付 SDK Mock 隔离以检查凭据选择。 */
@Mock
private AlipayClient oauthAlipayClient;
/**
* 第三方支付订单服务 Mock,用于验证子订单快照和保存失败补偿。
*/
@@ -62,7 +59,7 @@ class ThirdAggregateAliPayServiceImplTest {
private ThirdPayOrderService thirdPayOrderService;
/**
* 旧支付宝服务 Mock,用于验证聚合子订单保存失败后的最佳努力撤销。
* 原应用查单关单服务 Mock,WAP 仅签名和本地保存期间不应发起撤销或退款。
*/
@Mock
private ThirdAppPlusAliPayService thirdAppPlusAliPayService;
@@ -71,6 +68,10 @@ class ThirdAggregateAliPayServiceImplTest {
@Mock
private ThirdMchFzRelationService mchFzRelationService;
/** 项目统一加密器 Mock,确保可付款签名链接仅以密文保存在流水快照中。 */
@Mock
private StringEncryptor stringEncryptor;
/**
* 被测支付宝聚合服务,通过子类固定返回 Mock SDK 客户端。
*/
@@ -86,7 +87,7 @@ class ThirdAggregateAliPayServiceImplTest {
*/
private MchPaySettingVo paySetting;
/** 相互隔离的网页/移动与小程序应用配置,用于验证不发生凭据回退。 */
/** 相互隔离的网页/移动与小程序配置,用于验证聚合新下单固定选择网页应用。 */
private AppPlusPayConfig appPlusPayConfig;
/**
@@ -110,8 +111,9 @@ class ThirdAggregateAliPayServiceImplTest {
webApplication.setPayCallbackUrl("/third-app-pay/app/pay-callback");
appPlusPayConfig.setAliPayConfig(webApplication);
service = new TestableThirdAggregateAliPayService(appPlusPayConfig, thirdPayOrderService,
thirdAppPlusAliPayService, alipayClient, oauthAlipayClient);
thirdAppPlusAliPayService, alipayClient);
ReflectionTestUtils.setField(service, "mchFzRelationService", mchFzRelationService);
ReflectionTestUtils.setField(service, "stringEncryptor", stringEncryptor);
context = new ThirdAggregatePayCreateContext();
context.setPayNo("AGP202608050001");
@@ -130,162 +132,144 @@ class ThirdAggregateAliPayServiceImplTest {
}
/**
* 验证静默 OAuth 授权码只换取稳定付款人身份,不持久化访问令牌。
* WAP 只在本地生成 GET 签名链接;原会话金额、全额直付通结算、回跳及应用快照必须完整保留。
*
* @throws Exception Mock 支付宝客户端声明的受检异常
*/
@Test
void exchangeOauthCodeShouldReturnOpenId() throws Exception {
AlipaySystemOauthTokenResponse response = new AlipaySystemOauthTokenResponse();
response.setCode("10000");
response.setOpenId("alipay-open-id");
when(oauthAlipayClient.execute(any(AlipaySystemOauthTokenRequest.class))).thenReturn(response);
void createWapPayShouldSignGetUrlAndSaveDirectPaySnapshot() throws Exception {
context.setFzRelationId(9001L);
String returnUrl = "https://pay.example.com/third/aggregate-order-pay/return/alipay/tenant-code/status-ticket";
String signedUrl = "https://openapi.alipay.com/gateway.do?method=alipay.trade.wap.pay&sign=test-signature";
AlipayTradeWapPayResponse response = new AlipayTradeWapPayResponse();
response.setBody(signedUrl);
when(alipayClient.pageExecute(any(AlipayTradeWapPayRequest.class), eq("GET"))).thenReturn(response);
when(stringEncryptor.encrypt(signedUrl)).thenReturn("encrypted-wap-pay-url");
when(thirdPayOrderService.save(any(ThirdPayOrderEntity.class))).thenReturn(true);
ThirdAggregateAlipayOauthIdentity identity = service.exchangeOauthCode("one-time-auth-code");
ThirdAggregateCashierLaunch launch = service.createWapPay(context, paySetting, returnUrl);
Assertions.assertEquals("alipay-open-id", identity.getStableIdentity());
ArgumentCaptor<AlipaySystemOauthTokenRequest> requestCaptor =
ArgumentCaptor.forClass(AlipaySystemOauthTokenRequest.class);
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());
ArgumentCaptor<AlipayTradeWapPayRequest> requestCaptor = ArgumentCaptor.forClass(AlipayTradeWapPayRequest.class);
verify(alipayClient).pageExecute(requestCaptor.capture(), eq("GET"));
AlipayTradeWapPayRequest request = requestCaptor.getValue();
AlipayTradeWapPayModel model = (AlipayTradeWapPayModel) request.getBizModel();
Assertions.assertEquals("QUICK_WAP_WAY", model.getProductCode());
Assertions.assertEquals(context.getPayNo(), model.getOutTradeNo());
Assertions.assertEquals("12.34", model.getTotalAmount());
Assertions.assertEquals("smid-1001", model.getSubMerchant().getMerchantId());
Assertions.assertEquals(returnUrl, model.getQuitUrl());
Assertions.assertEquals(returnUrl, request.getReturnUrl());
Assertions.assertEquals("https://pay.example.com/third-app-pay/app/pay-callback", request.getNotifyUrl());
Assertions.assertEquals(LocalDateTimeUtil.format(context.getExpireTime(), DatePattern.NORM_DATETIME_PATTERN), model.getTimeExpire());
Assertions.assertNull(model.getTimeoutExpress());
Assertions.assertEquals("merchant-auth-token", request.getTextParams().get("app_auth_token"));
JSONObject content = JSONUtil.parseObj(new JSONWriter().write(model, true));
Assertions.assertFalse(content.containsKey("buyer_id"));
Assertions.assertFalse(content.containsKey("buyer_open_id"));
JSONObject settle = content.getJSONObject("settle_info");
Assertions.assertEquals("1d", settle.getStr("settle_period_time"));
Assertions.assertEquals(1, settle.getJSONArray("settle_detail_infos").size());
JSONObject detail = settle.getJSONArray("settle_detail_infos").getJSONObject(0);
Assertions.assertEquals("defaultSettle", detail.getStr("trans_in_type"));
Assertions.assertEquals("12.34", detail.getStr("amount"));
Assertions.assertFalse(detail.containsKey("trans_in"));
Assertions.assertEquals("ALIPAY_WAP", launch.getPayParams().get("mode"));
Assertions.assertEquals(signedUrl, launch.getPayParams().get("payUrl"));
Assertions.assertFalse(launch.getPayParams().containsKey("tradeNO"));
ArgumentCaptor<ThirdPayOrderEntity> orderCaptor = ArgumentCaptor.forClass(ThirdPayOrderEntity.class);
verify(thirdPayOrderService).save(orderCaptor.capture());
ThirdPayOrderEntity saved = orderCaptor.getValue();
Assertions.assertEquals(ThirdPayOrderStatusEnum.PAY_UNPAID.getType(), saved.getStatus());
Assertions.assertEquals(context.getExpireTime(), saved.getExpireTime());
Assertions.assertEquals("QUICK_WAP_WAY", JSONUtil.parseObj(saved.getPayRep()).getStr("productCode"));
Assertions.assertEquals("2026000000000001", JSONUtil.parseObj(saved.getMchPaySettingJson())
.getStr(AppPlusPayConfig.ALIPAY_APP_ID_SNAPSHOT_KEY));
Assertions.assertFalse(saved.getMchPaySettingJson().contains("web-test-private-key"));
Assertions.assertFalse(saved.getPayRep().contains("merchant-auth-token"));
JSONObject savedResponse = JSONUtil.parseObj(saved.getPayRes());
Assertions.assertEquals("encrypted-wap-pay-url", savedResponse.getStr("payUrlCiphertext"));
Assertions.assertFalse(savedResponse.getJSONObject("launchParams").containsKey("payUrl"));
Assertions.assertFalse(saved.getPayRes().contains(signedUrl));
Assertions.assertFalse(saved.getPayRes().contains("test-signature"));
verify(alipayClient, never()).execute(any(AlipayTradeWapPayRequest.class));
verifyNoInteractions(thirdAppPlusAliPayService);
}
/**
* 验证 trade.create 使用 JSAPI_PAY、全额直付通结算、网页授权 user_id 和授权 Token。
* 未持久化的签名地址不能交付浏览器;本地签名尚未创建渠道单,也不能调用撤销制造退款副作用。
*
* @throws Exception Mock 支付宝客户端声明的受检异常
*/
@Test
void createPayShouldSetJsapiParametersAndSaveUnpaidOrder() throws Exception {
context.setFzRelationId(9001L);
AlipayTradeCreateResponse response = new AlipayTradeCreateResponse();
response.setCode("10000");
response.setTradeNo("2026000000000002");
when(alipayClient.execute(any(AlipayTradeCreateRequest.class))).thenReturn(response);
when(thirdPayOrderService.save(any(ThirdPayOrderEntity.class))).thenReturn(true);
void createWapPayShouldNotDeliverUrlWhenPersistenceFails() throws Exception {
AlipayTradeWapPayResponse response = new AlipayTradeWapPayResponse();
response.setBody("https://openapi.alipay.com/gateway.do?method=alipay.trade.wap.pay&sign=test-signature");
when(alipayClient.pageExecute(any(AlipayTradeWapPayRequest.class), eq("GET"))).thenReturn(response);
when(stringEncryptor.encrypt(response.getBody())).thenReturn("encrypted-wap-pay-url");
when(thirdPayOrderService.save(any(ThirdPayOrderEntity.class))).thenReturn(false);
ThirdAggregateCashierLaunch result = service.createPay(context, paySetting,
webIdentity());
Assertions.assertThrows(RuntimeException.class, () -> service.createWapPay(context, paySetting,
"https://pay.example.com/third/aggregate-order-pay/return/alipay/tenant-code/status-ticket"));
ArgumentCaptor<AlipayTradeCreateRequest> requestCaptor = ArgumentCaptor.forClass(AlipayTradeCreateRequest.class);
verify(alipayClient).execute(requestCaptor.capture());
AlipayTradeCreateRequest request = requestCaptor.getValue();
AlipayTradeCreateModel model = (AlipayTradeCreateModel) request.getBizModel();
Assertions.assertEquals("JSAPI_PAY", model.getProductCode());
Assertions.assertEquals("12.34", model.getTotalAmount());
Assertions.assertEquals("smid-1001", model.getSubMerchant().getMerchantId());
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());
assertDirectPaySettlement(request, "12.34");
ArgumentCaptor<ThirdPayOrderEntity> orderCaptor = ArgumentCaptor.forClass(ThirdPayOrderEntity.class);
verify(thirdPayOrderService).save(orderCaptor.capture());
Assertions.assertEquals(ThirdPayOrderStatusEnum.PAY_UNPAID.getType(), orderCaptor.getValue().getStatus());
Assertions.assertEquals(context.getExpireTime(), orderCaptor.getValue().getExpireTime());
Assertions.assertFalse(orderCaptor.getValue().getPayRep().contains("merchant-auth-token"));
Assertions.assertFalse(orderCaptor.getValue().getPayRep().contains("alipay-open-id"));
JSONObject snapshot = JSONUtil.parseObj(orderCaptor.getValue().getMchPaySettingJson());
Assertions.assertEquals("2026000000000002", snapshot.getStr(AppPlusPayConfig.ALIPAY_APP_ID_SNAPSHOT_KEY));
Assertions.assertFalse(snapshot.containsKey("appPrivateKey"));
verify(thirdPayOrderService).save(any(ThirdPayOrderEntity.class));
verify(alipayClient, never()).execute(any(AlipayTradeWapPayRequest.class));
verifyNoInteractions(thirdAppPlusAliPayService);
}
/** 缺少小程序支付配置时禁止使用网页/移动应用代替聚合下单。 */
/**
* 余期不足 SDK 要求的一分钟时拒绝签名,不延长原会话,也不生成可交付 URL 或支付流水。
*
* @throws Exception Mock 支付宝客户端声明的受检异常
*/
@Test
void createPayShouldRejectMissingMiniApplication() throws Exception {
appPlusPayConfig.setAliPayMiniConfig(null);
void createWapPayShouldRejectInsufficientRemainingLifetime() throws Exception {
context.setExpireTime(LocalDateTime.now().plusSeconds(30));
Assertions.assertThrows(IllegalStateException.class,
() -> service.createPay(context, paySetting, new ThirdAggregateAlipayOauthIdentity("payer", null)));
IllegalArgumentException exception = Assertions.assertThrows(IllegalArgumentException.class,
() -> service.createWapPay(context, paySetting,
"https://pay.example.com/third/aggregate-order-pay/return/alipay/tenant-code/status-ticket"));
verify(alipayClient, never()).execute(any(AlipayTradeCreateRequest.class));
Assertions.assertTrue(exception.getMessage().contains("剩余有效期不足"));
verify(alipayClient, never()).pageExecute(any(AlipayTradeWapPayRequest.class), any(String.class));
verify(thirdPayOrderService, never()).save(any(ThirdPayOrderEntity.class));
verifyNoInteractions(thirdAppPlusAliPayService);
}
/** 网页应用只有 open_id 时,不允许将其直接交给另一小程序应用下单。 */
/** 网页应用缺少支付通知地址时,在渠道请求和支付流水写入前拒绝,不能误用小程序通知地址。 */
@Test
void createPayShouldRejectCrossApplicationOpenIdWithoutUserId() throws Exception {
ThirdAggregateAlipayOauthIdentity identity = new ThirdAggregateAlipayOauthIdentity(
appPlusPayConfig.getAliPayConfig().getAppId(), "web-app-open-id", null);
void createWapPayShouldRejectMissingWebPaymentCallback() throws Exception {
appPlusPayConfig.getAliPayConfig().setPayCallbackUrl(null);
IllegalStateException exception = Assertions.assertThrows(IllegalStateException.class,
() -> service.createPay(context, paySetting, identity));
() -> service.createWapPay(context, paySetting, "https://pay.example.com/third/aggregate-order-pay/return/alipay/tenant-code/status-ticket"));
Assertions.assertTrue(exception.getMessage().contains("user_id"));
verify(alipayClient, never()).execute(any(AlipayTradeCreateRequest.class));
Assertions.assertTrue(exception.getMessage().contains("ali-pay-config.pay-callback-url"));
verifyNoInteractions(alipayClient, stringEncryptor, thirdAppPlusAliPayService);
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,且不降级为另一套支付流程。
*
* @throws Exception Mock 支付宝客户端声明的受检异常
*/
@Test
void createPayShouldOmitEmptyAppAuthToken() throws Exception {
void createWapPayShouldOmitEmptyAppAuthToken() throws Exception {
MchPaySettingEntity.AliPaySetting aliPaySetting = new MchPaySettingEntity.AliPaySetting();
aliPaySetting.setSubMchid("smid-1001");
paySetting.setSettingJson(JSONUtil.toJsonStr(aliPaySetting));
AlipayTradeCreateResponse response = new AlipayTradeCreateResponse();
response.setCode("10000");
response.setTradeNo("2026000000000003");
when(alipayClient.execute(any(AlipayTradeCreateRequest.class))).thenReturn(response);
AlipayTradeWapPayResponse response = new AlipayTradeWapPayResponse();
response.setBody("https://openapi.alipay.com/gateway.do?method=alipay.trade.wap.pay&sign=test-signature");
when(alipayClient.pageExecute(any(AlipayTradeWapPayRequest.class), eq("GET"))).thenReturn(response);
when(stringEncryptor.encrypt(response.getBody())).thenReturn("encrypted-wap-pay-url");
when(thirdPayOrderService.save(any(ThirdPayOrderEntity.class))).thenReturn(true);
service.createPay(context, paySetting, webIdentity());
service.createWapPay(context, paySetting, "https://pay.example.com/third/aggregate-order-pay/return/alipay/tenant-code/status-ticket");
ArgumentCaptor<AlipayTradeCreateRequest> requestCaptor = ArgumentCaptor.forClass(AlipayTradeCreateRequest.class);
verify(alipayClient).execute(requestCaptor.capture());
AlipayTradeCreateRequest request = requestCaptor.getValue();
AlipayTradeCreateModel model = (AlipayTradeCreateModel) request.getBizModel();
ArgumentCaptor<AlipayTradeWapPayRequest> requestCaptor = ArgumentCaptor.forClass(AlipayTradeWapPayRequest.class);
verify(alipayClient).pageExecute(requestCaptor.capture(), eq("GET"));
AlipayTradeWapPayRequest request = requestCaptor.getValue();
AlipayTradeWapPayModel model = (AlipayTradeWapPayModel) request.getBizModel();
Assertions.assertEquals("smid-1001", model.getSubMerchant().getMerchantId());
Assertions.assertFalse(request.getTextParams().containsKey("app_auth_token"));
assertDirectPaySettlement(request, "12.34");
@@ -302,20 +286,19 @@ class ThirdAggregateAliPayServiceImplTest {
context.setBizId("OFFLINE:88:12345678-1234-4234-8234-123456789abc");
context.setAggregateScene("OFFLINE");
context.setAmount(1);
AlipayTradeCreateResponse response = new AlipayTradeCreateResponse();
response.setCode("10000");
response.setTradeNo("2026000000000004");
when(alipayClient.execute(any(AlipayTradeCreateRequest.class))).thenReturn(response);
AlipayTradeWapPayResponse response = new AlipayTradeWapPayResponse();
response.setBody("https://openapi.alipay.com/gateway.do?method=alipay.trade.wap.pay&sign=test-signature");
when(alipayClient.pageExecute(any(AlipayTradeWapPayRequest.class), eq("GET"))).thenReturn(response);
when(stringEncryptor.encrypt(response.getBody())).thenReturn("encrypted-wap-pay-url");
when(thirdPayOrderService.save(any(ThirdPayOrderEntity.class))).thenReturn(true);
service.createPay(context, paySetting, new ThirdAggregateAlipayOauthIdentity(null, "2088000000000001"));
service.createWapPay(context, paySetting, "https://pay.example.com/third/aggregate-order-pay/return/alipay/tenant-code/status-ticket");
ArgumentCaptor<AlipayTradeCreateRequest> requestCaptor = ArgumentCaptor.forClass(AlipayTradeCreateRequest.class);
verify(alipayClient).execute(requestCaptor.capture());
AlipayTradeCreateRequest request = requestCaptor.getValue();
AlipayTradeCreateModel model = (AlipayTradeCreateModel) request.getBizModel();
ArgumentCaptor<AlipayTradeWapPayRequest> requestCaptor = ArgumentCaptor.forClass(AlipayTradeWapPayRequest.class);
verify(alipayClient).pageExecute(requestCaptor.capture(), eq("GET"));
AlipayTradeWapPayRequest request = requestCaptor.getValue();
AlipayTradeWapPayModel model = (AlipayTradeWapPayModel) request.getBizModel();
Assertions.assertEquals("smid-1001", model.getSubMerchant().getMerchantId());
Assertions.assertEquals("2088000000000001", model.getBuyerId());
Assertions.assertFalse(request.getTextParams().containsKey("app_auth_token"));
assertDirectPaySettlement(request, "0.01");
@@ -332,7 +315,7 @@ class ThirdAggregateAliPayServiceImplTest {
* @param request 已捕获且尚未实际发送的支付宝 SDK 请求
* @param amount 人民币元字符串,必须与 total_amount 及结算明细同时一致
*/
private void assertDirectPaySettlement(AlipayTradeCreateRequest request, String amount) {
private void assertDirectPaySettlement(AlipayTradeWapPayRequest request, String amount) {
JSONObject content = JSONUtil.parseObj(new JSONWriter().write(request.getBizModel(), true));
JSONObject settleInfo = content.getJSONObject("settle_info");
Assertions.assertEquals(amount, content.getStr("total_amount"));
@@ -351,48 +334,20 @@ class ThirdAggregateAliPayServiceImplTest {
* @throws Exception Mockito 校验的支付宝 SDK 方法声明受检异常
*/
@Test
void createPayShouldRejectUnboundCommissionRelation() throws Exception {
void createWapPayShouldRejectUnboundCommissionRelation() throws Exception {
context.setFzRelationId(9001L);
when(mchFzRelationService.requirePaymentRelation(9001L, paySetting))
.thenThrow(new IllegalStateException("支付宝直付通平台分佣关系尚未绑定"));
RuntimeException exception = Assertions.assertThrows(RuntimeException.class,
() -> service.createPay(context, paySetting,
webIdentity()));
() -> service.createWapPay(context, paySetting,
"https://pay.example.com/third/aggregate-order-pay/return/alipay/tenant-code/status-ticket"));
Assertions.assertTrue(exception.getMessage().contains("分佣关系尚未绑定"));
verify(alipayClient, never()).execute(any(AlipayTradeCreateRequest.class));
verifyNoInteractions(alipayClient, stringEncryptor, thirdAppPlusAliPayService);
verify(thirdPayOrderService, never()).save(any(ThirdPayOrderEntity.class));
}
/**
* 验证平台下单成功但本地子订单保存失败时立即发起撤销,避免形成孤立可支付订单。
*
* @throws Exception Mock 支付宝客户端声明的受检异常
*/
@Test
void createPayShouldCancelWhenOrderPersistenceFails() throws Exception {
AlipayTradeCreateResponse response = new AlipayTradeCreateResponse();
response.setCode("10000");
response.setTradeNo("2026000000000002");
when(alipayClient.execute(any(AlipayTradeCreateRequest.class))).thenReturn(response);
when(thirdPayOrderService.save(any(ThirdPayOrderEntity.class))).thenReturn(false);
Assertions.assertThrows(RuntimeException.class, () -> service.createPay(context, paySetting,
webIdentity()));
ArgumentCaptor<String> snapshotCaptor = ArgumentCaptor.forClass(String.class);
verify(thirdAppPlusAliPayService).cancel(eq(context.getPayNo()), snapshotCaptor.capture());
Assertions.assertEquals("2026000000000002", JSONUtil.parseObj(snapshotCaptor.getValue())
.getStr(AppPlusPayConfig.ALIPAY_APP_ID_SNAPSHOT_KEY));
}
/** 网页授权与小程序下单跨应用时只携带平台真实返回的全局 user_id。 */
private ThirdAggregateAlipayOauthIdentity webIdentity() {
return new ThirdAggregateAlipayOauthIdentity(appPlusPayConfig.getAliPayConfig().getAppId(),
"alipay-open-id", "2088000000000001");
}
/**
* 固定使用 Mock 支付宝客户端的可测试服务实现。
*/
@@ -403,25 +358,20 @@ class ThirdAggregateAliPayServiceImplTest {
*/
private final AlipayClient testAlipayClient;
/** 网页授权专用客户端 Mock,与聚合下单客户端分离。 */
private final AlipayClient testOauthAlipayClient;
/**
* 创建可替换 SDK 客户端的支付宝聚合服务。
*
* @param appPlusPayConfig 测试应用配置
* @param thirdPayOrderService 支付订单服务 Mock
* @param thirdAppPlusAliPayService 旧支付宝查询撤销服务 Mock
* @param testAlipayClient 小程序支付客户端 Mock
* @param testOauthAlipayClient 网页/移动应用 OAuth 客户端 Mock
* @param testAlipayClient 网页/移动应用支付客户端 Mock
*/
private TestableThirdAggregateAliPayService(AppPlusPayConfig appPlusPayConfig,
ThirdPayOrderService thirdPayOrderService,
ThirdAppPlusAliPayService thirdAppPlusAliPayService,
AlipayClient testAlipayClient, AlipayClient testOauthAlipayClient) {
AlipayClient testAlipayClient) {
super(appPlusPayConfig, thirdPayOrderService, thirdAppPlusAliPayService);
this.testAlipayClient = testAlipayClient;
this.testOauthAlipayClient = testOauthAlipayClient;
}
/**
@@ -433,11 +383,5 @@ class ThirdAggregateAliPayServiceImplTest {
protected AlipayClient getAlipayClient() {
return testAlipayClient;
}
/** 返回网页授权专用客户端,不允许由支付客户端兑换授权码。 */
@Override
protected AlipayClient getOauthAlipayClient() {
return testOauthAlipayClient;
}
}
}
@@ -19,6 +19,7 @@ import com.bsd.pjcloud.thirdparty.dto.appplus.ThirdAggregateOrderCodePayDto;
import com.bsd.pjcloud.thirdparty.entity.pay.ThirdAggregatePaySessionEntity;
import com.bsd.pjcloud.thirdparty.entity.pay.ThirdMchFzRelationEntity;
import com.bsd.pjcloud.thirdparty.entity.pay.ThirdPayOrderEntity;
import com.bsd.pjcloud.thirdparty.model.pay.ThirdAggregatePayCreateContext;
import com.bsd.pjcloud.thirdparty.service.ThirdAggregateAliPayService;
import com.bsd.pjcloud.thirdparty.service.ThirdAggregatePaySessionService;
import com.bsd.pjcloud.thirdparty.service.ThirdAggregateWechatPayService;
@@ -26,11 +27,13 @@ import com.bsd.pjcloud.thirdparty.service.ThirdPayOrderService;
import com.bsd.pjcloud.thirdparty.service.ThirdPayPollingCoordinator;
import com.bsd.pjcloud.thirdparty.service.ThirdMchFzRelationService;
import com.bsd.pjcloud.thirdparty.vo.appplus.pay.ThirdAggregateOrderCodePayVo;
import com.bsd.pjcloud.thirdparty.vo.appplus.pay.ThirdAggregateH5ContextVo;
import com.bsd.pjcloud.wallet.api.entity.mch.MchPaySettingEntity;
import com.bsd.pjcloud.wallet.api.feign.RemoteMchService;
import com.bsd.pjcloud.wallet.api.vo.MchManageVo;
import com.bsd.pjcloud.wallet.api.vo.mch.MchPaySettingVo;
import com.alipay.api.response.AlipayTradeCancelResponse;
import com.alipay.api.response.AlipayTradeCloseResponse;
import com.alipay.api.response.AlipayTradeQueryResponse;
import org.jasypt.encryption.StringEncryptor;
import org.junit.jupiter.api.Assertions;
@@ -285,21 +288,37 @@ class ThirdAggregateOrderPayServiceImplTest {
AppPlusPayConfig config = completeApplicationConfig();
config.getAliPayMiniConfig().setAppId(config.getAliPayConfig().getAppId());
Assertions.assertThrows(IllegalStateException.class, config::requireAliPayMiniConfig);
Assertions.assertThrows(IllegalStateException.class, config::requireAliPayWebPayConfig);
}
/** 缺少小程序支付应用时不得使用网页/移动应用代替创建聚合会话。 */
/** 聚合会话只依赖完整网页应用,小程序凭据缺省不再阻断 H5 收银。 */
@Test
void createShouldRejectMissingMiniApplicationWithoutFallback() {
void createShouldAllowWebApplicationWithoutMiniConfiguration() {
AppPlusPayConfig config = (AppPlusPayConfig) ReflectionTestUtils.getField(service, "appPlusPayConfig");
config.setAliPayMiniConfig(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));
when(stringEncryptor.encrypt(anyString())).thenReturn("encrypted-access-token");
when(aggregatePaySessionService.save(any(ThirdAggregatePaySessionEntity.class))).thenReturn(true);
Assertions.assertNotNull(service.create(request));
verify(aggregatePaySessionService).save(any(ThirdAggregatePaySessionEntity.class));
}
/** 网页应用支付通知路径缺失时,必须在生成可扫码会话前反馈具体配置项。 */
@Test
void createShouldRejectMissingWebPaymentCallback() {
AppPlusPayConfig config = (AppPlusPayConfig) ReflectionTestUtils.getField(service, "appPlusPayConfig");
config.getAliPayConfig().setPayCallbackUrl(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("ali-pay-mini-config"));
Assertions.assertTrue(exception.getMessage().contains("ali-pay-config.pay-callback-url"));
verify(aggregatePaySessionService, never()).save(any(ThirdAggregatePaySessionEntity.class));
}
@@ -342,13 +361,14 @@ class ThirdAggregateOrderPayServiceImplTest {
verify(mchFzRelationService, never()).findBoundDefault(ali);
}
/** 仅支付宝启用时校验网页授权、小程序支付应用及既有直付通关系,微信应用缺省不影响签发。 */
/** 仅支付宝启用时只校验网页应用及直付通关系,微信和小程序配置缺省均不影响签发。 */
@Test
void merchantCodeShouldValidateOnlyEnabledAlipay() {
MchPaySettingVo ali = merchant.getPaySettingList().get(0);
merchant.setPaySettingList(List.of(ali));
AppPlusPayConfig config = (AppPlusPayConfig) ReflectionTestUtils.getField(service, "appPlusPayConfig");
config.setWechatConfig(null);
config.setAliPayMiniConfig(null);
ThirdMchFzRelationEntity relation = new ThirdMchFzRelationEntity();
when(remoteMchService.getById(88L)).thenReturn(R.ok(merchant));
when(mchFzRelationService.findBoundDefault(ali)).thenReturn(relation);
@@ -369,22 +389,258 @@ class ThirdAggregateOrderPayServiceImplTest {
verify(aggregatePaySessionService, never()).selectPlatform(any(), any(), anyString(), anyString());
}
/**
* 验证支付宝扫码授权统一使用不含租户编码的固定回调地址,租户信息由一次性 state 承载。
*/
/** 新支付宝会话直接创建 WAP 请求;固定返回路由绑定票据,金额和期限只从服务端会话读取。 */
@Test
void alipayOauthShouldUseFixedCallbackUrl() {
void alipayCashierShouldCreateWapWithoutOauth() {
ThirdAggregatePaySessionEntity session = reusableSession();
session.setExpireTime(LocalDateTime.now().plusMinutes(2));
MchPaySettingVo alipaySetting = merchant.getPaySettingList().get(0);
mockNewWapCashierSession(session);
when(aggregatePaySessionService.markPaying(eq(session.getId()), anyString())).thenReturn(true);
String h5Url = service.getCashierOauthUrl("tenant-code", "cashier-access-token", "AlipayClient/10.6.80");
Assertions.assertTrue(h5Url.startsWith("https://pay.example.com/#/pages/pay/index?tenantCode=tenant-code&ticket="));
ArgumentCaptor<ThirdAggregatePayCreateContext> contextCaptor = ArgumentCaptor.forClass(ThirdAggregatePayCreateContext.class);
ArgumentCaptor<String> returnUrlCaptor = ArgumentCaptor.forClass(String.class);
verify(aggregateAliPayService).createWapPay(contextCaptor.capture(), eq(alipaySetting), returnUrlCaptor.capture());
ThirdAggregatePayCreateContext context = contextCaptor.getValue();
Assertions.assertEquals(session.getPrice(), context.getAmount());
Assertions.assertEquals(session.getMchId(), context.getMchId());
Assertions.assertEquals(session.getExpireTime(), context.getExpireTime());
Assertions.assertEquals("https://pay.example.com/third/aggregate-order-pay/return/alipay/tenant-code/"
+ h5Url.substring(h5Url.indexOf("ticket=") + "ticket=".length()), returnUrlCaptor.getValue());
verify(aggregatePaySessionService).selectWapPlatform(session.getId(), context.getPayNo());
verify(aggregatePaySessionService, never()).selectPlatform(any(), any(), any(), any());
verifyNoInteractions(aggregateWechatPayService);
}
/** 流水已保存而会话推进失败时停止交付票据,保留创建中会话供原 payNo 恢复及到期补偿。 */
@Test
void wapStateAdvanceFailureShouldKeepSavedOrderRecoverable() {
ThirdAggregatePaySessionEntity session = reusableSession();
session.setExpireTime(LocalDateTime.now().plusMinutes(2));
mockNewWapCashierSession(session);
ThirdPayOrderEntity saved = new ThirdPayOrderEntity();
saved.setStatus(ThirdPayOrderStatusEnum.PAY_UNPAID.getType());
when(thirdPayOrderService.getByPayNo(anyString())).thenReturn(saved);
Assertions.assertThrows(IllegalStateException.class,
() -> service.getCashierOauthUrl("tenant-code", "cashier-access-token", "AlipayClient/10.6.80"));
verify(aggregateAliPayService).createWapPay(any(), any(), any());
verify(aggregatePaySessionService, never()).markCreateFailed(any(), any());
verify(aggregateAliPayService, never()).cancel(any(), any());
verify(aggregateAliPayService, never()).close(any(), any());
}
/** 已绑定 WAP 流水的同一访问令牌刷新时只换取新票据,不能生成新 payNo 或再次签名。 */
@Test
void alipayWapRefreshShouldReuseExistingPayNo() {
ThirdAggregatePaySessionEntity session = payingWapSession(false);
ThirdPayOrderEntity payOrder = unpaidWapOrder(session);
when(aggregatePaySessionService.getByAccessTokenHash(anyString())).thenReturn(session);
when(aggregatePaySessionService.getById(session.getId())).thenReturn(session);
when(thirdPayOrderService.getByPayNo(session.getPayNo())).thenReturn(payOrder);
String h5Url = service.getCashierOauthUrl("tenant-code", "cashier-access-token", "AlipayClient/10.6.80");
Assertions.assertTrue(h5Url.startsWith("https://pay.example.com/#/pages/pay/index?tenantCode=tenant-code&ticket="));
Assertions.assertEquals("wap-child-pay-no", session.getPayNo());
verify(aggregatePaySessionService, never()).selectWapPlatform(any(), any());
verify(aggregatePaySessionService, never()).selectPlatform(any(), any(), any(), any());
verifyNoInteractions(aggregateAliPayService, aggregateWechatPayService, remoteMchService);
}
/** 已落单但状态推进中断的 WAP 会话只恢复原 payNo,不重签第二笔请求,也不绕过历史身份绑定。 */
@Test
void alipayWapRefreshShouldRecoverSavedCreatingSession() {
ThirdAggregatePaySessionEntity creating = payingWapSession(false);
ThirdAggregatePaySessionEntity paying = payingWapSession(false);
creating.setStatus(ThirdAggregatePaySessionStatusEnum.PLATFORM_CREATING.getType());
ThirdPayOrderEntity payOrder = unpaidWapOrder(creating);
when(aggregatePaySessionService.getByAccessTokenHash(anyString())).thenReturn(creating);
when(aggregatePaySessionService.getById(creating.getId())).thenReturn(creating, paying);
when(thirdPayOrderService.getByPayNo(creating.getPayNo())).thenReturn(payOrder);
String h5Url = service.getCashierOauthUrl("tenant-code", "cashier-access-token", "AlipayClient/10.6.80");
Assertions.assertTrue(h5Url.startsWith("https://pay.example.com/#/pages/pay/index?tenantCode=tenant-code&ticket="));
verify(aggregatePaySessionService).markPaying(creating.getId(), creating.getPayNo());
verify(aggregatePaySessionService, never()).selectWapPlatform(any(), any());
verify(aggregatePaySessionService, never()).markCreateFailed(any(), any());
verifyNoInteractions(aggregateAliPayService, aggregateWechatPayService, remoteMchService);
}
/** 短期票据恢复时才在内存解密 WAP 调起地址,数据库快照仍不暴露可支付链接。 */
@Test
void wapContextShouldRestoreEncryptedUrlWithOriginalPayNo() {
ThirdAggregatePaySessionEntity session = payingWapSession(false);
session.setCreateTime(LocalDateTime.now());
ThirdPayOrderEntity payOrder = unpaidWapOrder(session);
payOrder.setPayRes(JSONUtil.createObj().set("aggregatePay", true)
.set("launchParams", JSONUtil.createObj().set("mode", "ALIPAY_WAP"))
.set("payUrlCiphertext", "encrypted-wap-pay-url").toString());
String signedUrl = "https://openapi.alipay.com/gateway.do?method=alipay.trade.wap.pay&sign=test-signature";
when(valueOperations.get(anyString())).thenReturn(session.getAggregateNo());
when(aggregatePaySessionService.getByAggregateNo(session.getAggregateNo())).thenReturn(session);
when(aggregatePaySessionService.getById(session.getId())).thenReturn(session);
when(thirdPayOrderService.getByPayNo(session.getPayNo())).thenReturn(payOrder);
when(remoteMchService.getById(session.getMchId())).thenReturn(R.ok(merchant));
when(stringEncryptor.decrypt("encrypted-wap-pay-url")).thenReturn(signedUrl);
ThirdAggregateH5ContextVo context = service.getContextByTicket("tenant-code", "0123456789abcdef0123456789abcdef");
Assertions.assertEquals(session.getPayNo(), context.getPayNo());
Assertions.assertEquals(signedUrl, context.getLaunchParams().get("payUrl"));
Assertions.assertEquals("ALIPAY_WAP", context.getLaunchParams().get("mode"));
Assertions.assertFalse(payOrder.getPayRes().contains(signedUrl));
verify(aggregateAliPayService, never()).createWapPay(any(), any(), any());
}
/** 删除旧支付宝授权和下单入口后,历史买家绑定会话只提供结果票据,不能创建第二笔 WAP。 */
@Test
void historicalAlipaySessionShouldEnterReadOnlyH5WithoutNewPayment() {
ThirdAggregatePaySessionEntity session = reusableSession();
session.setStatus(ThirdAggregatePaySessionStatusEnum.PAYING.getType());
session.setSelectedPlatform(AppPlusPlatformTypeEnum.ALI_PAY.getType());
session.setPayerIdentityHash("historical-payer-identity-hash");
session.setPayNo("historical-jsapi-pay-no");
when(aggregatePaySessionService.getByAccessTokenHash(anyString())).thenReturn(session);
String h5Url = service.getCashierOauthUrl("tenant-code", "cashier-access-token", "AlipayClient/10.6.80");
Assertions.assertTrue(h5Url.startsWith("https://pay.example.com/#/pages/pay/index?tenantCode=tenant-code&ticket="));
Assertions.assertFalse(h5Url.contains("openauth.alipay.com"));
Assertions.assertEquals("historical-jsapi-pay-no", session.getPayNo());
verifyNoInteractions(aggregateAliPayService, aggregateWechatPayService);
verify(aggregatePaySessionService, never()).selectWapPlatform(any(), any());
verify(aggregatePaySessionService, never()).selectPlatform(any(), any(), any(), any());
}
/** 历史支付宝流水仍可读取金额和状态,但不能重新向 H5 暴露已删除模式的 tradeNO 调起参数。 */
@Test
void historicalAlipayContextShouldRemainReadOnly() {
ThirdAggregatePaySessionEntity session = payingWapSession(false);
session.setCreateTime(LocalDateTime.now());
session.setPayerIdentityHash("historical-payer-identity-hash");
ThirdPayOrderEntity payOrder = unpaidWapOrder(session);
payOrder.setPayRep(JSONUtil.createObj().set("aggregatePay", true).set("productCode", "JSAPI_PAY").toString());
payOrder.setPayRes(JSONUtil.createObj().set("aggregatePay", true)
.set("launchParams", JSONUtil.createObj().set("tradeNO", "historical-trade-no")).toString());
when(valueOperations.get(anyString())).thenReturn(session.getAggregateNo());
when(aggregatePaySessionService.getByAggregateNo(session.getAggregateNo())).thenReturn(session);
when(aggregatePaySessionService.getById(session.getId())).thenReturn(session);
when(thirdPayOrderService.getByPayNo(session.getPayNo())).thenReturn(payOrder);
when(remoteMchService.getById(session.getMchId())).thenReturn(R.ok(merchant));
ThirdAggregateH5ContextVo context = service.getContextByTicket("tenant-code", "0123456789abcdef0123456789abcdef");
Assertions.assertEquals(session.getPayNo(), context.getPayNo());
Assertions.assertEquals(session.getPrice(), context.getAmount());
Assertions.assertEquals(ThirdAggregatePaySessionStatusEnum.PAYING.getType(), context.getStatus());
Assertions.assertNull(context.getLaunchParams());
verifyNoInteractions(aggregateAliPayService, aggregateWechatPayService, stringEncryptor);
}
/** 支付宝授权逻辑删除后,微信新扫码仍使用原公众号授权 URL、租户回调路径和最小静默 scope。 */
@Test
void wechatCashierShouldKeepPublicAccountOauth() {
ThirdAggregatePaySessionEntity session = reusableSession();
when(aggregatePaySessionService.getByAccessTokenHash(anyString())).thenReturn(session);
when(remoteMchService.getById(88L)).thenReturn(R.ok(merchant));
when(remoteMchService.getById(session.getMchId())).thenReturn(R.ok(merchant));
String oauthUrl = service.getCashierOauthUrl("tenant-code", "cashier-access-token", "AlipayClient/10.6.80");
String oauthUrl = service.getCashierOauthUrl("tenant-code", "cashier-access-token", "MicroMessenger/8.0");
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."));
Assertions.assertTrue(oauthUrl.startsWith("https://open.weixin.qq.com/connect/oauth2/authorize?"));
Assertions.assertTrue(oauthUrl.contains("appid=wx-service-jsapi-appid"));
Assertions.assertTrue(oauthUrl.contains("redirect_uri=https%3A%2F%2Fpay.example.com%2Fthird%2Faggregate-order-pay%2Foauth%2Fwechat%2Ftenant-code"));
Assertions.assertTrue(oauthUrl.contains("scope=snsapi_base"));
Assertions.assertTrue(oauthUrl.endsWith("#wechat_redirect"));
verifyNoInteractions(aggregateAliPayService, aggregateWechatPayService);
verify(aggregatePaySessionService, never()).selectWapPlatform(any(), any());
}
/** WAP 签名绝对期限已过且原应用明确查无此单时,结束本地请求,不反复调用关单或撤销。 */
@Test
void expiredWapWithoutChannelTradeShouldCloseLocally() {
ThirdAggregatePaySessionEntity session = payingWapSession(true);
ThirdPayOrderEntity payOrder = unpaidWapOrder(session);
mockPendingWapOrder(session, payOrder);
when(aggregateAliPayService.query(payOrder.getPayNo(), payOrder.getMchPaySettingJson())).thenReturn(missingAlipayTrade());
service.processPendingSession(session.getId());
ArgumentCaptor<ThirdPayOrderEntity> updateCaptor = ArgumentCaptor.forClass(ThirdPayOrderEntity.class);
verify(thirdPayOrderService).update(updateCaptor.capture(), any(Wrapper.class));
Assertions.assertEquals(ThirdPayOrderStatusEnum.PAY_CLOSED.getType(), updateCaptor.getValue().getStatus());
verify(aggregateAliPayService, never()).close(any(), any());
verify(aggregateAliPayService, never()).cancel(any(), any());
}
/** 有效期内查无渠道单只表示尚未进入收银台,签名请求仍可能被提交,不能关闭本地流水。 */
@Test
void unexpiredWapWithoutChannelTradeShouldRemainPayable() {
ThirdAggregatePaySessionEntity session = payingWapSession(false);
ThirdPayOrderEntity payOrder = unpaidWapOrder(session);
mockPendingWapOrder(session, payOrder);
when(aggregateAliPayService.query(payOrder.getPayNo(), payOrder.getMchPaySettingJson())).thenReturn(missingAlipayTrade());
service.processPendingSession(session.getId());
verify(thirdPayOrderService, never()).update(any(ThirdPayOrderEntity.class), any(Wrapper.class));
verify(aggregateAliPayService, never()).close(any(), any());
verify(aggregateAliPayService, never()).cancel(any(), any());
}
/** 到期查单发生网络异常或系统错误时,保持原请求待确认,不能用关单或撤销掩盖未知结果。 */
@Test
void expiredWapWithUnknownQueryShouldNotClose() {
ThirdAggregatePaySessionEntity session = payingWapSession(true);
ThirdPayOrderEntity payOrder = unpaidWapOrder(session);
mockPendingWapOrder(session, payOrder);
AlipayTradeQueryResponse systemError = new AlipayTradeQueryResponse();
systemError.setCode("20000");
systemError.setSubCode("ACQ.SYSTEM_ERROR");
when(aggregateAliPayService.query(payOrder.getPayNo(), payOrder.getMchPaySettingJson())).thenReturn(null, systemError);
service.processPendingSession(session.getId());
service.processPendingSession(session.getId());
verify(thirdPayOrderService, never()).update(any(ThirdPayOrderEntity.class), any(Wrapper.class));
verify(aggregateAliPayService, never()).close(any(), any());
verify(aggregateAliPayService, never()).cancel(any(), any());
}
/** 付款与到期关单竞争时,关单失败后复查成功事实,绝不能撤销并自动退款已完成的付款。 */
@Test
void wapCloseRaceShouldRequerySuccessfulPaymentWithoutCancel() {
ThirdAggregatePaySessionEntity session = payingWapSession(true);
ThirdPayOrderEntity payOrder = unpaidWapOrder(session);
mockPendingWapOrder(session, payOrder);
AlipayTradeQueryResponse waiting = new AlipayTradeQueryResponse();
waiting.setCode("10000");
waiting.setTradeStatus("WAIT_BUYER_PAY");
AlipayTradeQueryResponse success = new AlipayTradeQueryResponse();
success.setCode("10000");
success.setTradeStatus("TRADE_SUCCESS");
success.setTradeNo("alipay-paid-during-close");
AlipayTradeCloseResponse closeRejected = new AlipayTradeCloseResponse();
closeRejected.setCode("40004");
closeRejected.setSubCode("ACQ.TRADE_STATUS_ERROR");
when(aggregateAliPayService.query(payOrder.getPayNo(), payOrder.getMchPaySettingJson())).thenReturn(waiting, success);
when(aggregateAliPayService.close(payOrder.getPayNo(), payOrder.getMchPaySettingJson())).thenReturn(closeRejected);
service.processPendingSession(session.getId());
verify(aggregateAliPayService, times(2)).query(payOrder.getPayNo(), payOrder.getMchPaySettingJson());
verify(aggregateAliPayService).close(payOrder.getPayNo(), payOrder.getMchPaySettingJson());
verify(aggregateAliPayService, never()).cancel(any(), any());
ArgumentCaptor<ThirdPayOrderEntity> updateCaptor = ArgumentCaptor.forClass(ThirdPayOrderEntity.class);
verify(thirdPayOrderService).update(updateCaptor.capture(), any(Wrapper.class));
Assertions.assertEquals(ThirdPayOrderStatusEnum.PAY_SUCCESS.getType(), updateCaptor.getValue().getStatus());
Assertions.assertEquals("alipay-paid-during-close", updateCaptor.getValue().getThirdPayNo());
Assertions.assertNotNull(updateCaptor.getValue().getPayTime());
}
/**
@@ -559,4 +815,77 @@ class ThirdAggregateOrderPayServiceImplTest {
return session;
}
/**
* 构造不使用 OAuth 身份、已经唯一绑定支付宝 WAP 子流水的会话。
*
* @param expired 为 true 时绝对期限已过,否则保持可支付
* @return 平台、订单号和有效期一致的待支付聚合会话
*/
private ThirdAggregatePaySessionEntity payingWapSession(boolean expired) {
ThirdAggregatePaySessionEntity session = reusableSession();
session.setStatus(ThirdAggregatePaySessionStatusEnum.PAYING.getType());
session.setSelectedPlatform(AppPlusPlatformTypeEnum.ALI_PAY.getType());
session.setPayNo("wap-child-pay-no");
session.setExpireTime(expired ? LocalDateTime.now().minusSeconds(5) : LocalDateTime.now().plusMinutes(2));
return session;
}
/**
* 为新的 WAP 扫码入口提供启用商户、可信二级商户关系及平台 CAS;状态推进结果由测试独立设置。
*
* @param session 尚未选定平台且剩余有效期足够的聚合会话
*/
private void mockNewWapCashierSession(ThirdAggregatePaySessionEntity session) {
MchPaySettingVo alipaySetting = merchant.getPaySettingList().get(0);
ThirdMchFzRelationEntity relation = new ThirdMchFzRelationEntity();
relation.setEnableFz(YesNoEnum.NO.getCode());
when(aggregatePaySessionService.getByAccessTokenHash(anyString())).thenReturn(session);
when(aggregatePaySessionService.getById(session.getId())).thenReturn(session);
when(remoteMchService.getById(88L)).thenReturn(R.ok(merchant));
when(mchFzRelationService.findBoundDefault(alipaySetting)).thenReturn(relation);
when(mchFzRelationService.createPaymentSettingSnapshot(alipaySetting, relation)).thenReturn(alipaySetting);
when(aggregatePaySessionService.selectWapPlatform(eq(session.getId()), anyString())).thenReturn(true);
}
/**
* 构造由服务端保存产品码和原绝对期限的未支付 WAP 流水,不信任浏览器提交的模式标识。
*
* @param session 已绑定该订单号的聚合会话
* @return 未支付的原应用订单快照
*/
private ThirdPayOrderEntity unpaidWapOrder(ThirdAggregatePaySessionEntity session) {
ThirdPayOrderEntity order = new ThirdPayOrderEntity();
order.setId(201L);
order.setPayNo(session.getPayNo());
order.setPayPlatformType(AppPlusPlatformTypeEnum.ALI_PAY.getType());
order.setStatus(ThirdPayOrderStatusEnum.PAY_UNPAID.getType());
order.setExpireTime(session.getExpireTime());
order.setMchPaySettingJson("{}");
order.setPayRep(JSONUtil.createObj().set("aggregatePay", true).set("productCode", "QUICK_WAP_WAY").toString());
return order;
}
/**
* 为后台待确认处理固定同一会话与流水,查询和更新均由各测试单独约束。
*
* @param session 后台按主键读取的聚合会话
* @param payOrder 按原 payNo 读取的未支付流水
*/
private void mockPendingWapOrder(ThirdAggregatePaySessionEntity session, ThirdPayOrderEntity payOrder) {
when(aggregatePaySessionService.getById(session.getId())).thenReturn(session);
when(thirdPayOrderService.getByPayNo(session.getPayNo())).thenReturn(payOrder);
}
/**
* 构造渠道明确查无交易的响应;系统错误和网络空响应必须使用其他测试值。
*
* @return 结构化 ACQ.TRADE_NOT_EXIST 业务失败
*/
private AlipayTradeQueryResponse missingAlipayTrade() {
AlipayTradeQueryResponse response = new AlipayTradeQueryResponse();
response.setCode("40004");
response.setSubCode("ACQ.TRADE_NOT_EXIST");
return response;
}
}
@@ -5,6 +5,7 @@ import cn.hutool.core.date.LocalDateTimeUtil;
import cn.hutool.json.JSONUtil;
import com.alipay.api.AlipayClient;
import com.alipay.api.domain.AlipayTradeAppPayModel;
import com.alipay.api.domain.AlipayTradeCloseModel;
import com.alipay.api.domain.AlipayTradeOrderSettleModel;
import com.alipay.api.domain.AlipayTradeOrderSettleQueryModel;
import com.alipay.api.domain.AlipayTradePrecreateModel;
@@ -16,6 +17,7 @@ import com.alipay.api.domain.RoyaltyDetail;
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;
import com.alipay.api.request.AlipayTradeFastpayRefundQueryRequest;
import com.alipay.api.request.AlipayTradeOrderSettleRequest;
import com.alipay.api.request.AlipayTradeOrderSettleQueryRequest;
@@ -25,6 +27,7 @@ import com.alipay.api.request.AlipayTradeRefundRequest;
import com.alipay.api.request.AlipayTradeSettleConfirmRequest;
import com.alipay.api.response.AlipayTradeAppPayResponse;
import com.alipay.api.response.AlipayTradeCancelResponse;
import com.alipay.api.response.AlipayTradeCloseResponse;
import com.alipay.api.response.AlipayTradeFastpayRefundQueryResponse;
import com.alipay.api.response.AlipayTradeOrderSettleResponse;
import com.alipay.api.response.AlipayTradeOrderSettleQueryResponse;
@@ -33,6 +36,9 @@ import com.alipay.api.response.AlipayTradeQueryResponse;
import com.alipay.api.response.AlipayTradeRefundResponse;
import com.alipay.api.response.AlipayTradeSettleConfirmResponse;
import com.baomidou.mybatisplus.core.conditions.Wrapper;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.conditions.AbstractWrapper;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import com.bsd.pjcloud.common.core.constant.enums.YesNoEnum;
import com.bsd.pjcloud.thirdparty.config.AppPlusPayConfig;
import com.bsd.pjcloud.thirdparty.constant.fz.ThirdFzOrderStatusEnum;
@@ -55,6 +61,7 @@ import com.bsd.pjcloud.thirdparty.service.ThirdPayOrderService;
import com.bsd.pjcloud.thirdparty.vo.appplus.pay.ThirdOrderCodePayVo;
import com.bsd.pjcloud.wallet.api.entity.mch.MchPaySettingEntity;
import com.bsd.pjcloud.wallet.api.vo.mch.MchPaySettingVo;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -224,7 +231,28 @@ class ThirdAppPlusAliPayServiceImplTest {
Assertions.assertSame(webCancel, service.cancel(payDto.getOutTradeNo(), legacySnapshot));
}
/** 支付单明确保存未知应用时不能回退旧应用查单或撤销,避免误判交易不存在。 */
/** 关单必须使用支付单的原应用和授权快照,不得调用可能退款的撤销接口或改写本地终态。 */
@Test
void closeShouldKeepOriginalApplicationAndAuthorization() throws Exception {
AlipayTradeCloseResponse miniResponse = new AlipayTradeCloseResponse();
AlipayTradeCloseResponse webResponse = new AlipayTradeCloseResponse();
when(miniAlipayClient.execute(any(AlipayTradeCloseRequest.class))).thenReturn(miniResponse);
when(alipayClient.execute(any(AlipayTradeCloseRequest.class))).thenReturn(webResponse);
Assertions.assertSame(miniResponse, service.close(payDto.getOutTradeNo(), miniApplicationSnapshot()));
Assertions.assertSame(webResponse, service.close(payDto.getOutTradeNo(), JSONUtil.toJsonStr(paySetting)));
ArgumentCaptor<AlipayTradeCloseRequest> requestCaptor = ArgumentCaptor.forClass(AlipayTradeCloseRequest.class);
verify(miniAlipayClient).execute(requestCaptor.capture());
AlipayTradeCloseRequest request = requestCaptor.getValue();
Assertions.assertEquals(payDto.getOutTradeNo(), ((AlipayTradeCloseModel) request.getBizModel()).getOutTradeNo());
Assertions.assertEquals("merchant-auth-token", request.getTextParams().get("app_auth_token"));
verify(alipayClient, never()).execute(any(AlipayTradeCancelRequest.class));
verify(miniAlipayClient, never()).execute(any(AlipayTradeCancelRequest.class));
verify(thirdPayOrderService, never()).update(any(ThirdPayOrderEntity.class), any(Wrapper.class));
}
/** 支付单明确保存未知应用时不能回退旧应用查单、撤销或关单,避免误判交易不存在。 */
@Test
void unknownApplicationShouldNeverFallBackToDefault() throws Exception {
String snapshot = JSONUtil.parseObj(miniApplicationSnapshot())
@@ -232,11 +260,14 @@ class ThirdAppPlusAliPayServiceImplTest {
Assertions.assertNull(service.query(payDto.getOutTradeNo(), snapshot));
Assertions.assertNull(service.cancel(payDto.getOutTradeNo(), snapshot));
Assertions.assertNull(service.close(payDto.getOutTradeNo(), snapshot));
verify(alipayClient, never()).execute(any(AlipayTradeQueryRequest.class));
verify(alipayClient, never()).execute(any(AlipayTradeCancelRequest.class));
verify(alipayClient, never()).execute(any(AlipayTradeCloseRequest.class));
verify(miniAlipayClient, never()).execute(any(AlipayTradeQueryRequest.class));
verify(miniAlipayClient, never()).execute(any(AlipayTradeCancelRequest.class));
verify(miniAlipayClient, never()).execute(any(AlipayTradeCloseRequest.class));
}
/** 小程序原交易的退款、退款查询和通知地址必须成组使用小程序配置。 */
@@ -928,10 +959,146 @@ class ThirdAppPlusAliPayServiceImplTest {
assertBoundAggregateCallback("FACE_TO_FACE_PAYMENT", miniApplicationSnapshot(), appPlusPayConfig.getAliPayMiniConfig().getAppId());
}
/** WAP 回调沿用完整聚合绑定及金额校验,不能因产品切换被当成非聚合支付。 */
@Test
void payCallbackShouldAcceptBoundAggregateWapOrder() {
assertBoundAggregateCallback("QUICK_WAP_WAY", JSONUtil.toJsonStr(paySetting), appPlusPayConfig.getAliPayConfig().getAppId());
}
/** 已验签且业务绑定一致的 WAP 迟到成功只恢复带服务端未建单标记的关闭,CAS 必须精确限定关闭来源。 */
@Test
@SuppressWarnings({"rawtypes", "unchecked"})
void payCallbackShouldRecoverClosedWapWithRestrictedCas() {
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), "wap-callback-test"), ThirdPayOrderEntity.class);
ThirdPayOrderEntity order = aggregateWapOrder();
order.setStatus(ThirdPayOrderStatusEnum.PAY_CLOSED.getType());
order.setPayRes(JSONUtil.createObj().set("wapClosedWithoutTrade", true).toString());
bindAggregateSession(order);
ThirdPayOrderEntity paid = aggregateWapOrder();
paid.setStatus(ThirdPayOrderStatusEnum.PAY_SUCCESS.getType());
paid.setThirdPayNo("202600000001");
when(thirdPayOrderService.getByPayNo(order.getPayNo())).thenReturn(order);
when(thirdPayOrderService.update(any(ThirdPayOrderEntity.class), any(Wrapper.class))).thenReturn(true);
when(thirdPayOrderService.getById(order.getId())).thenReturn(paid);
ThirdAppPlusAliPayService.AliPayCallbackResult result = service.payCallback(successCallbackParams());
Assertions.assertNotNull(result);
Assertions.assertTrue(result.isPaySuccess());
Assertions.assertTrue(result.isStateChanged());
ArgumentCaptor<ThirdPayOrderEntity> updateCaptor = ArgumentCaptor.forClass(ThirdPayOrderEntity.class);
ArgumentCaptor<Wrapper> conditions = ArgumentCaptor.forClass(Wrapper.class);
verify(thirdPayOrderService).update(updateCaptor.capture(), conditions.capture());
Assertions.assertEquals(ThirdPayOrderStatusEnum.PAY_SUCCESS.getType(), updateCaptor.getValue().getStatus());
Assertions.assertEquals("202600000001", updateCaptor.getValue().getThirdPayNo());
Assertions.assertNotNull(updateCaptor.getValue().getPayTime());
Assertions.assertTrue(conditions.getValue().getSqlSegment().contains("status ="));
Map<String, Object> parameters = ((AbstractWrapper<?, ?, ?>) conditions.getValue()).getParamNameValuePairs();
Assertions.assertFalse(parameters.containsValue(ThirdPayOrderStatusEnum.PAY_UNPAID.getType()));
Assertions.assertTrue(parameters.containsValue(ThirdPayOrderStatusEnum.PAY_CLOSED.getType()));
Assertions.assertFalse(parameters.containsValue(ThirdPayOrderStatusEnum.REFUNDING.getType()));
Assertions.assertFalse(parameters.containsValue(ThirdPayOrderStatusEnum.REFUND_SUCCESS.getType()));
}
/** 未支付 CAS 被本地到期关闭抢先时,只对同一带标记的 WAP 流水再执行一次精确关闭状态 CAS。 */
@Test
@SuppressWarnings({"rawtypes", "unchecked"})
void payCallbackShouldRecoverWapWhenLocalExpiryWinsFirstCas() {
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), "wap-callback-race-test"), ThirdPayOrderEntity.class);
ThirdPayOrderEntity unpaid = aggregateWapOrder();
bindAggregateSession(unpaid);
ThirdPayOrderEntity locallyClosed = aggregateWapOrder();
locallyClosed.setStatus(ThirdPayOrderStatusEnum.PAY_CLOSED.getType());
locallyClosed.setPayRes(JSONUtil.createObj().set("wapClosedWithoutTrade", true).toString());
ThirdPayOrderEntity paid = aggregateWapOrder();
paid.setStatus(ThirdPayOrderStatusEnum.PAY_SUCCESS.getType());
when(thirdPayOrderService.getByPayNo(unpaid.getPayNo())).thenReturn(unpaid);
when(thirdPayOrderService.update(any(ThirdPayOrderEntity.class), any(Wrapper.class))).thenReturn(false, true);
when(thirdPayOrderService.getById(unpaid.getId())).thenReturn(locallyClosed, paid);
ThirdAppPlusAliPayService.AliPayCallbackResult result = service.payCallback(successCallbackParams());
Assertions.assertNotNull(result);
Assertions.assertTrue(result.isPaySuccess());
Assertions.assertTrue(result.isStateChanged());
ArgumentCaptor<Wrapper> conditions = ArgumentCaptor.forClass(Wrapper.class);
verify(thirdPayOrderService, times(2)).update(any(ThirdPayOrderEntity.class), conditions.capture());
List<Wrapper> attempts = conditions.getAllValues();
Assertions.assertTrue(attempts.get(0).getSqlSegment().contains("status ="));
Assertions.assertTrue(attempts.get(1).getSqlSegment().contains("status ="));
Map<String, Object> first = ((AbstractWrapper<?, ?, ?>) attempts.get(0)).getParamNameValuePairs();
Map<String, Object> retry = ((AbstractWrapper<?, ?, ?>) attempts.get(1)).getParamNameValuePairs();
Assertions.assertTrue(first.containsValue(ThirdPayOrderStatusEnum.PAY_UNPAID.getType()));
Assertions.assertFalse(first.containsValue(ThirdPayOrderStatusEnum.PAY_CLOSED.getType()));
Assertions.assertTrue(retry.containsValue(ThirdPayOrderStatusEnum.PAY_CLOSED.getType()));
Assertions.assertFalse(retry.containsValue(ThirdPayOrderStatusEnum.PAY_UNPAID.getType()));
}
/** 渠道真实关闭的 WAP 单没有未建单标记,迟到成功通知不能将其复活或重复处理业务。 */
@Test
void payCallbackShouldNotRecoverWapWithoutLocalClosureMarker() {
ThirdPayOrderEntity order = aggregateWapOrder();
order.setStatus(ThirdPayOrderStatusEnum.PAY_CLOSED.getType());
bindAggregateSession(order);
when(thirdPayOrderService.getByPayNo(order.getPayNo())).thenReturn(order);
ThirdAppPlusAliPayService.AliPayCallbackResult result = service.payCallback(successCallbackParams());
Assertions.assertNotNull(result);
Assertions.assertFalse(result.isPaySuccess());
Assertions.assertFalse(result.isStateChanged());
verify(thirdPayOrderService, never()).update(any(ThirdPayOrderEntity.class), any(Wrapper.class));
}
/** WAP 成功通知不能恢复退款终态,防止重复或迟到通知覆盖真实退款事实。 */
@Test
void payCallbackShouldNotRecoverRefundedWapOrder() {
ThirdPayOrderEntity order = aggregateWapOrder();
order.setStatus(ThirdPayOrderStatusEnum.REFUND_SUCCESS.getType());
bindAggregateSession(order);
when(thirdPayOrderService.getByPayNo(order.getPayNo())).thenReturn(order);
Assertions.assertNull(service.payCallback(successCallbackParams()));
verify(thirdPayOrderService, never()).update(any(ThirdPayOrderEntity.class), any(Wrapper.class));
}
/** WAP 的关闭通知即使晚于成功到达,也只能幂等确认,不能回退已经确认的付款成功。 */
@Test
void payCallbackShouldNotCloseAlreadyPaidWapOrder() {
ThirdPayOrderEntity order = aggregateWapOrder();
order.setStatus(ThirdPayOrderStatusEnum.PAY_SUCCESS.getType());
bindAggregateSession(order);
when(thirdPayOrderService.getByPayNo(order.getPayNo())).thenReturn(order);
Map<String, String> params = successCallbackParams();
params.put("trade_status", "TRADE_CLOSED");
ThirdAppPlusAliPayService.AliPayCallbackResult result = service.payCallback(params);
Assertions.assertNotNull(result);
Assertions.assertTrue(result.isPaySuccess());
Assertions.assertFalse(result.isStateChanged());
verify(thirdPayOrderService, never()).update(any(ThirdPayOrderEntity.class), any(Wrapper.class));
}
/** 即使应用和金额匹配,缺少聚合绑定的 WAP 通知也不能推进未支付订单或恢复已关闭订单。 */
@Test
void payCallbackShouldRejectUnboundAggregateWapOrder() {
ThirdPayOrderEntity order = unpaidOrder();
order.setPayRep(JSONUtil.createObj().set("aggregatePay", true).set("productCode", "QUICK_WAP_WAY").toString());
when(thirdPayOrderService.getByPayNo(order.getPayNo())).thenReturn(order);
Assertions.assertNull(service.payCallback(successCallbackParams()));
order.setStatus(ThirdPayOrderStatusEnum.PAY_CLOSED.getType());
Assertions.assertNull(service.payCallback(successCallbackParams()));
verify(thirdPayOrderService, never()).update(any(ThirdPayOrderEntity.class), any(Wrapper.class));
}
/**
* 对新旧聚合产品码复用完整支付回调约束,避免切换产品码后被错误拒绝或放宽会话校验。
*
* @param productCode 支付创建时保存的当面付或历史 JSAPI 产品码
* @param productCode 支付创建时保存的 WAP、当面付或历史 JSAPI 产品码
* @param settingJson 含原应用 AppID 的支付单商户配置快照
* @param appId 已验签通知中的应用标识,必须与支付单一致
*/
@@ -940,14 +1107,7 @@ class ThirdAppPlusAliPayServiceImplTest {
order.setMchPaySettingJson(settingJson);
order.setPayRep(JSONUtil.createObj().set("aggregatePay", true)
.set("productCode", productCode).toString());
ThirdAggregatePaySessionEntity session = new ThirdAggregatePaySessionEntity();
session.setPayNo(order.getPayNo());
session.setSelectedPlatform(AppPlusPlatformTypeEnum.ALI_PAY.getType());
session.setBizType(order.getBizType());
session.setBizId(order.getBizId());
session.setMchId(order.getMchId());
session.setPrice(order.getPrice());
when(aggregatePaySessionService.getByPayNo(order.getPayNo())).thenReturn(session);
bindAggregateSession(order);
when(thirdPayOrderService.getByPayNo(order.getPayNo())).thenReturn(order);
when(thirdPayOrderService.update(any(ThirdPayOrderEntity.class), any(Wrapper.class))).thenReturn(true);
ThirdPayOrderEntity paidOrder = unpaidOrder();
@@ -963,6 +1123,33 @@ class ThirdAppPlusAliPayServiceImplTest {
Assertions.assertTrue(result.isStateChanged());
}
/**
* 模拟同租户中与支付单的业务、商户、金额及支付平台完全匹配的聚合绑定。
*
* @param order 已保存的支付宝聚合子流水,不能按当前商户配置重建历史字段
*/
private void bindAggregateSession(ThirdPayOrderEntity order) {
ThirdAggregatePaySessionEntity session = new ThirdAggregatePaySessionEntity();
session.setPayNo(order.getPayNo());
session.setSelectedPlatform(AppPlusPlatformTypeEnum.ALI_PAY.getType());
session.setBizType(order.getBizType());
session.setBizId(order.getBizId());
session.setMchId(order.getMchId());
session.setPrice(order.getPrice());
when(aggregatePaySessionService.getByPayNo(order.getPayNo())).thenReturn(session);
}
/**
* 构造服务端快照明确标识的 WAP 未支付流水,供成功、关闭及退款边界测试使用。
*
* @return 绑定默认业务、商户和金额的 WAP 支付单
*/
private ThirdPayOrderEntity aggregateWapOrder() {
ThirdPayOrderEntity order = unpaidOrder();
order.setPayRep(JSONUtil.createObj().set("aggregatePay", true).set("productCode", "QUICK_WAP_WAY").toString());
return order;
}
/** 已登记的另一个应用即使验签通过,也不能处理不属于该应用的支付单。 */
@Test
void payCallbackShouldRejectAnotherRegisteredApplication() {
@@ -0,0 +1,75 @@
package com.bsd.pjcloud.thirdparty.service.impl.pay;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.conditions.Wrapper;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import com.bsd.pjcloud.thirdparty.constant.miniplatform.AppPlusPlatformTypeEnum;
import com.bsd.pjcloud.thirdparty.constant.pay.ThirdAggregatePaySessionStatusEnum;
import com.bsd.pjcloud.thirdparty.entity.pay.ThirdAggregatePaySessionEntity;
import com.bsd.pjcloud.thirdparty.mapper.pay.ThirdAggregatePaySessionMapper;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.test.util.ReflectionTestUtils;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
/** WAP 匿名绑定必须保留数据库原子互斥及期限约束,不伪造 OAuth 买家身份。 */
class ThirdAggregatePaySessionServiceImplTest {
/** 支付会话 Mapper 替身,所有断言均为离线行为,不连接数据库。 */
private ThirdAggregatePaySessionMapper mapper;
/** 被测会话持久化服务,复用 MyBatis-Plus 更新入口。 */
private ThirdAggregatePaySessionServiceImpl service;
/** 初始化实体字段映射并替换底层 Mapper,允许核对实际生成的 CAS 条件。 */
@BeforeEach
void setUp() {
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), "wap-test"), ThirdAggregatePaySessionEntity.class);
mapper = mock(ThirdAggregatePaySessionMapper.class);
service = new ThirdAggregatePaySessionServiceImpl();
ReflectionTestUtils.setField(service, "baseMapper", mapper);
}
/** 匿名 WAP 只绑定支付宝、同一预分配单号,并要求会话尚未过期且尚未选定买家或平台。 */
@Test
@SuppressWarnings({"unchecked", "rawtypes"})
void wapBindingShouldKeepAtomicConditionsWithoutPayerIdentity() {
when(mapper.update(any(ThirdAggregatePaySessionEntity.class), any(Wrapper.class))).thenReturn(1);
assertTrue(service.selectWapPlatform(7L, "pay-7"));
ArgumentCaptor<ThirdAggregatePaySessionEntity> entity = ArgumentCaptor.forClass(ThirdAggregatePaySessionEntity.class);
ArgumentCaptor<Wrapper> conditions = ArgumentCaptor.forClass(Wrapper.class);
verify(mapper).update(entity.capture(), conditions.capture());
assertEquals(AppPlusPlatformTypeEnum.ALI_PAY.getType(), entity.getValue().getSelectedPlatform());
assertEquals(ThirdAggregatePaySessionStatusEnum.PLATFORM_CREATING.getType(), entity.getValue().getStatus());
assertEquals("pay-7", entity.getValue().getPayNo());
assertNull(entity.getValue().getPayerIdentityHash());
String sql = conditions.getValue().getSqlSegment();
assertTrue(sql.contains("status ="));
assertTrue(sql.contains("expire_time >"));
assertTrue(sql.contains("selected_platform IS NULL"));
assertTrue(sql.contains("pay_no IS NULL"));
assertTrue(sql.contains("payer_identity_hash IS NULL"));
}
/** 竞争请求未更新记录时必须向编排层报告失败,不能自行宣称已取得平台绑定。 */
@Test
@SuppressWarnings("unchecked")
void wapBindingShouldReportLostCompetition() {
when(mapper.update(any(ThirdAggregatePaySessionEntity.class), any(Wrapper.class))).thenReturn(0);
assertFalse(service.selectWapPlatform(7L, "pay-7"));
}
/** WAP 的空身份例外不能放宽原 JSAPI 接口或接受缺失的会话及支付单号。 */
@Test
void invalidInputsShouldNotReachMapper() {
assertFalse(service.selectWapPlatform(null, "pay-7"));
assertFalse(service.selectWapPlatform(7L, " "));
assertFalse(service.selectPlatform(7L, AppPlusPlatformTypeEnum.ALI_PAY, "pay-7", null));
verifyNoInteractions(mapper);
}
}