diff --git a/pjcloud-thirdparty/docs/aggregate-h5.md b/pjcloud-thirdparty/docs/aggregate-h5.md index 219f6031..ba00975b 100644 --- a/pjcloud-thirdparty/docs/aggregate-h5.md +++ b/pjcloud-thirdparty/docs/aggregate-h5.md @@ -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 扫码,隐藏金额和付款键盘;渠道全部停用时提示联系商户。付款创建和进入渠道收银前再次检查渠道,前端展示不能替代后端校验。已有渠道子订单继续依据原绑定渠道和配置快照恢复。 本轮未执行实际迁移、修改平台配置或发起真实扣款。集中验证需覆盖微信/支付宝真机、退款和分账、回调验签与重复通知、延迟成功、限流、跨租户及旧消费机状态同步。 diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/appplus/pay/ThirdAggregateH5ContextVo.java b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/appplus/pay/ThirdAggregateH5ContextVo.java index a2cb34bc..19f87f04 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/appplus/pay/ThirdAggregateH5ContextVo.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/appplus/pay/ThirdAggregateH5ContextVo.java @@ -21,7 +21,7 @@ public class ThirdAggregateH5ContextVo { private String status; /** 渠道已确认成功时为 true,前端 JSBridge 结果不能替代该值。 */ private boolean paySuccess; - /** 尚可继续支付时提供的最小 JSBridge 参数,终态时为空。 */ + /** 尚可继续支付时提供渠道参数;WAP 为 mode=ALIPAY_WAP、payUrl,其他模式沿用 JSBridge,终态为空。 */ private Map launchParams; /** 当前会话的服务端截止时间。 */ private LocalDateTime expireTime; diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/config/AppPlusPayConfig.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/config/AppPlusPayConfig.java index fbc3dfe5..0b3f7ca9 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/config/AppPlusPayConfig.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/config/AppPlusPayConfig.java @@ -22,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; } /** diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/controller/appplus/ThirdAggregateOrderPayController.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/controller/appplus/ThirdAggregateOrderPayController.java index 21b1e5d1..5a964be4 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/controller/appplus/ThirdAggregateOrderPayController.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/controller/appplus/ThirdAggregateOrderPayController.java @@ -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 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 alipayOauth(@RequestParam(value = "auth_code", required = false) String authCode, - @RequestParam(value = "state", required = false) String state) { + public ResponseEntity 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(); } } diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/model/pay/ThirdAggregateAlipayOauthIdentity.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/model/pay/ThirdAggregateAlipayOauthIdentity.java deleted file mode 100644 index 6ee0e371..00000000 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/model/pay/ThirdAggregateAlipayOauthIdentity.java +++ /dev/null @@ -1,80 +0,0 @@ -package com.bsd.pjcloud.thirdparty.model.pay; - -import cn.hutool.core.util.StrUtil; - -/** - * 支付宝用户授权换取的付款人身份。 - * - *

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

- */ -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); - } -} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/model/pay/ThirdAggregateCashierLaunch.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/model/pay/ThirdAggregateCashierLaunch.java index cdd9d17a..e7739e3d 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/model/pay/ThirdAggregateCashierLaunch.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/model/pay/ThirdAggregateCashierLaunch.java @@ -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 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 payParams) { this.platform = platform; @@ -58,7 +58,7 @@ public class ThirdAggregateCashierLaunch { } /** - * 获取不可变的 JSBridge 调起参数。 + * 获取不可变的渠道调起参数;签名地址只能用于本次原交易,不得写入浏览器恢复存储。 * * @return 不包含密钥的调起参数 */ diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdAggregateAliPayService.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdAggregateAliPayService.java index 6da11f47..80ba4dc4 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdAggregateAliPayService.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdAggregateAliPayService.java @@ -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); /** * 查询聚合支付宝子订单状态。 diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdAggregateCashierHtmlRenderer.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdAggregateCashierHtmlRenderer.java deleted file mode 100644 index af896c09..00000000 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdAggregateCashierHtmlRenderer.java +++ /dev/null @@ -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 渲染器。 - * - *

页面不加载外部脚本或前端框架,只调用支付宝或微信客户端内置 JSBridge。

- */ -@Component -public class ThirdAggregateCashierHtmlRenderer { - - /** - * CSP nonce 随机字节数,每次响应重新生成。 - */ - private static final int NONCE_BYTES = 18; - - /** - * 支付宝 JSBridge 自动调用后显示人工重试入口的等待时间,单位毫秒。 - * - *

部分支付宝 WebView 会限制无用户手势的支付调起,等待后展示按钮可让付款人通过真实点击再次调用。

- */ - 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 "" - + "" - + "订单支付
" - + "

正在调起支付

请在当前应用内完成付款

" - + "
" - + ""; - } - - /** - * 构造支付宝内置浏览器的收银台调起脚本。 - * - *

严格按支付宝文档传递 {@code tradeNO},并处理官方结果码。自动调起被 WebView 限制、桥接尚未注入或同步抛错时, - * 页面提供真实点击重试入口;支付结果仍由服务端状态轮询最终确认,避免仅凭客户端结果认定成功。

- * - * @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 "" - + "" - + "订单支付

无法继续支付

" + escapeHtml(message) + "

"; - } - - /** - * 生成当前响应使用的不可预测 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 实体注入。 - * - *

Hutool 对顶层字符串调用 {@code toJsonStr} 不会补 JSON 引号,必须单独使用 {@code quote};否则 HTTPS - * 状态地址会被渲染为非法 JavaScript,并导致包括 JSBridge 调起在内的整段脚本无法执行。

- * - * @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("&", "&").replace("<", "<").replace(">", ">") - .replace("\"", """).replace("'", "'"); - } -} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdAggregateOrderPayService.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdAggregateOrderPayService.java index ec7f141a..b99280d5 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdAggregateOrderPayService.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdAggregateOrderPayService.java @@ -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); } diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdAggregatePaySessionService.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdAggregatePaySessionService.java index 1d474c61..f1f4fddd 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdAggregatePaySessionService.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdAggregatePaySessionService.java @@ -56,6 +56,15 @@ public interface ThirdAggregatePaySessionService extends IService 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 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; } diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateOrderPayServiceImpl.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateOrderPayServiceImpl.java index 9e6aec51..4f2bb0e2 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateOrderPayServiceImpl.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateOrderPayServiceImpl.java @@ -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 子订单并在自动处理期限内同步聚合会话。 + * 识别共享回调中的聚合渠道流水并同步聚合会话,历史支付宝流水仍按原绑定处理。 * *

已验签的延迟成功回调仍以渠道事实为准,即使会话已经进入 EXPIRED,也允许 CAS 恢复为支付成功; * 只有没有绑定渠道子订单的历史会话才可以本地终止。

@@ -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() + .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 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 查询参数进行百分号编码。 * diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusAliPayServiceImpl.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusAliPayServiceImpl.java index 56dc2636..80741a85 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusAliPayServiceImpl.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusAliPayServiceImpl.java @@ -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() .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() + .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; } diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdAggregatePaySessionServiceImpl.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdAggregatePaySessionServiceImpl.java index f6a10712..2c4a46dd 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdAggregatePaySessionServiceImpl.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdAggregatePaySessionServiceImpl.java @@ -116,6 +116,31 @@ public class ThirdAggregatePaySessionServiceImpl extends ServiceImpl() + .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)); + } + /** * 在第三方子订单保存成功后把会话从创建中推进为支付中。 * diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/controller/appplus/ThirdAggregateOrderPayControllerTest.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/controller/appplus/ThirdAggregateOrderPayControllerTest.java index 09c6f313..cd533da3 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/controller/appplus/ThirdAggregateOrderPayControllerTest.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/controller/appplus/ThirdAggregateOrderPayControllerTest.java @@ -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 response = new ThirdAggregateOrderPayController(service, support).alipayOauth("code", "state"); + when(service.handleWechatOauth("tenant", "code", "state")).thenReturn(new ThirdAggregateCashierPageData(null, null, target)); + ResponseEntity 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 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 controlCharacter = controller.alipayReturn("tenant\n", "a".repeat(32)); + ResponseEntity 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 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); + } } diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/ThirdAggregateCashierHtmlRendererTest.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/ThirdAggregateCashierHtmlRendererTest.java deleted file mode 100644 index ac4186a2..00000000 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/ThirdAggregateCashierHtmlRendererTest.java +++ /dev/null @@ -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"); - } -} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateAliPayServiceImplTest.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateAliPayServiceImplTest.java index 9cccb43f..45e05eb7 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateAliPayServiceImplTest.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateAliPayServiceImplTest.java @@ -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 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 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 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 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 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 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 requestCaptor = ArgumentCaptor.forClass(AlipayTradeCreateRequest.class); - verify(alipayClient).execute(requestCaptor.capture()); - AlipayTradeCreateRequest request = requestCaptor.getValue(); - AlipayTradeCreateModel model = (AlipayTradeCreateModel) request.getBizModel(); + ArgumentCaptor 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 requestCaptor = ArgumentCaptor.forClass(AlipayTradeCreateRequest.class); - verify(alipayClient).execute(requestCaptor.capture()); - AlipayTradeCreateRequest request = requestCaptor.getValue(); - AlipayTradeCreateModel model = (AlipayTradeCreateModel) request.getBizModel(); + ArgumentCaptor 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 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; - } } } diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateOrderPayServiceImplTest.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateOrderPayServiceImplTest.java index 22b60c42..36d8ab90 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateOrderPayServiceImplTest.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateOrderPayServiceImplTest.java @@ -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 contextCaptor = ArgumentCaptor.forClass(ThirdAggregatePayCreateContext.class); + ArgumentCaptor 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 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 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; + } + } diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusAliPayServiceImplTest.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusAliPayServiceImplTest.java index d35da86e..f9f7d5d7 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusAliPayServiceImplTest.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusAliPayServiceImplTest.java @@ -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 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 updateCaptor = ArgumentCaptor.forClass(ThirdPayOrderEntity.class); + ArgumentCaptor 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 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 conditions = ArgumentCaptor.forClass(Wrapper.class); + verify(thirdPayOrderService, times(2)).update(any(ThirdPayOrderEntity.class), conditions.capture()); + List attempts = conditions.getAllValues(); + Assertions.assertTrue(attempts.get(0).getSqlSegment().contains("status =")); + Assertions.assertTrue(attempts.get(1).getSqlSegment().contains("status =")); + Map first = ((AbstractWrapper) attempts.get(0)).getParamNameValuePairs(); + Map 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 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() { diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdAggregatePaySessionServiceImplTest.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdAggregatePaySessionServiceImplTest.java new file mode 100644 index 00000000..5a076ad9 --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdAggregatePaySessionServiceImplTest.java @@ -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 entity = ArgumentCaptor.forClass(ThirdAggregatePaySessionEntity.class); + ArgumentCaptor 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); + } +}