From b20b680bea072d7a31fea1856c0d5be6f485270c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A4=A7=E6=A3=AE=E6=9E=97?= Date: Sun, 24 May 2026 16:16:51 +0800 Subject: [PATCH] =?UTF-8?q?fix(alipay):=20=E5=8A=A0=E5=9B=BA=E6=8E=88?= =?UTF-8?q?=E6=9D=83=E5=9B=9E=E8=B0=83=20state=20=E8=A7=A3=E6=9E=90=20+=20?= =?UTF-8?q?=E5=BC=82=E6=AD=A5=E9=80=9A=E7=9F=A5=E8=A1=A5=E4=B8=9A=E5=8A=A1?= =?UTF-8?q?=E5=AD=97=E6=AE=B5=E4=BA=A4=E5=8F=89=E6=A0=A1=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修复 #81(感谢 @xiaowuDev 提交的 PR 描述定位到这两处问题)。 1) 授权回调 state 参数解析鲁棒性 - 原 AlipayBizController.redirectAppToAppAuth / appToAppAuthCallback 直接 split("_")[0]/[1],没做长度、空值、空串校验,异常或被构造的 state 会触发 ArrayIndexOutOfBoundsException / 空指针,回调链路出现 500。 - 抽出 AlipayKit.parseIsvAndMchAppIdState(String) 工具方法:用 indexOf 而非 split(避免 mchAppId 内部含 "_" 时被切碎),任一段为空都返回 null。 - 两个回调入口改用工具方法 + null 校验 + mchAppService.getById 结果空校验, 抛出 BizException 由现有异常处理统一展示给用户。 - 顺手清理 redirectAppToAppAuth 里两行无效代码(getSandbox() 返回值未使用、 isvNo 局部变量从未读取)。 2) 异步通知业务字段交叉校验 - 原 AlipayChannelNoticeService.doNotice 验签通过后直接信任 jsonParams, 仅取了 trade_no / buyer_id / trade_status 来设置 ChannelRetMsg。 验签通过仅证明请求来自支付宝并由本商户密钥签名,不保证回调内容就是 这笔本地订单。多商户配置切换 / 跨订单签名穿越 / 沙箱与生产串扰 等场景下,仍可能出现 out_trade_no / 金额 / app_id 与本地订单上下文 不一致的回调,按现有逻辑会把这笔"看似合法"的回调直接当成功来回写订单。 - 在验签通过后、设置 ChannelRetMsg 前补一道校验: out_trade_no 必须等于 payOrder.payOrderId、 app_id 必须等于商户配置里的 appId、 total_amount(元)必须等于本地金额(分→元,用 BigDecimal compareTo 比对避免 scale 差异)。 - 任一字段不一致:记 ERROR 日志(带具体不匹配的字段与双方值,便于排查)+ 返回 SUCCESS 给支付宝阻断重试 8 次浪费资源 + 不更新订单状态。 - ChannelState 用 UNKNOWN 而非 CONFIRM_FAIL,避免被下游误判为"已确认失败" 从而触发退款补偿逻辑。 --- .../alipay/AlipayChannelNoticeService.java | 33 +++++++++++++++++++ .../jeepay/pay/channel/alipay/AlipayKit.java | 26 +++++++++++++++ .../alipay/ctrl/AlipayBizController.java | 22 ++++++++++--- 3 files changed, 77 insertions(+), 4 deletions(-) diff --git a/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/channel/alipay/AlipayChannelNoticeService.java b/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/channel/alipay/AlipayChannelNoticeService.java index a37dd6459..bcfbbbb87 100644 --- a/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/channel/alipay/AlipayChannelNoticeService.java +++ b/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/channel/alipay/AlipayChannelNoticeService.java @@ -23,15 +23,18 @@ import com.jeequan.jeepay.core.model.params.alipay.AlipayConfig; import com.jeequan.jeepay.core.model.params.alipay.AlipayIsvParams; import com.jeequan.jeepay.core.model.params.alipay.AlipayNormalMchParams; +import com.jeequan.jeepay.core.utils.AmountUtil; import com.jeequan.jeepay.pay.channel.AbstractChannelNoticeService; import com.jeequan.jeepay.pay.model.MchAppConfigContext; import com.jeequan.jeepay.pay.rqrs.msg.ChannelRetMsg; import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.tuple.MutablePair; import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Service; import jakarta.servlet.http.HttpServletRequest; +import java.math.BigDecimal; import java.util.Map; /* @@ -75,6 +78,7 @@ public ChannelRetMsg doNotice(HttpServletRequest request, Object params, PayOrde //配置参数获取 Byte useCert = null; String alipaySignType, alipayPublicCert, alipayPublicKey = null; + String expectedAppId; // 业务字段交叉校验用 if(mchAppConfigContext.isIsvsubMch()){ // 获取支付参数 @@ -83,6 +87,7 @@ public ChannelRetMsg doNotice(HttpServletRequest request, Object params, PayOrde alipaySignType = alipayParams.getSignType(); alipayPublicCert = alipayParams.getAlipayPublicCert(); alipayPublicKey = alipayParams.getAlipayPublicKey(); + expectedAppId = alipayParams.getAppId(); }else{ @@ -93,6 +98,7 @@ public ChannelRetMsg doNotice(HttpServletRequest request, Object params, PayOrde alipaySignType = alipayParams.getSignType(); alipayPublicCert = alipayParams.getAlipayPublicCert(); alipayPublicKey = alipayParams.getAlipayPublicKey(); + expectedAppId = alipayParams.getAppId(); } // 获取请求参数 @@ -113,6 +119,33 @@ public ChannelRetMsg doNotice(HttpServletRequest request, Object params, PayOrde throw ResponseException.buildText("ERROR"); } + // 业务字段交叉校验:验签通过仅证明请求来自支付宝并由本商户密钥签名, + // 不保证回调内容就是这笔本地订单。多商户配置切换 / 跨订单签名穿越 / 沙箱与生产串扰 + // 等场景下,仍可能出现 out_trade_no、金额、app_id 与本地订单上下文不一致的回调。 + // 任一字段不一致 → 记 ERROR 日志 + 返回 SUCCESS 阻断支付宝重试 8 次 + 不更新订单状态。 + String notifyOutTradeNo = jsonParams.getString("out_trade_no"); + String notifyAppId = jsonParams.getString("app_id"); + String notifyTotalAmount = jsonParams.getString("total_amount"); + String expectedTotalAmount = AmountUtil.convertCent2Dollar(payOrder.getAmount()); + boolean amountMatch; + try { + amountMatch = new BigDecimal(notifyTotalAmount).compareTo(new BigDecimal(expectedTotalAmount)) == 0; + } catch (NumberFormatException e) { + amountMatch = false; + } + if (!StringUtils.equals(notifyOutTradeNo, payOrder.getPayOrderId()) + || !StringUtils.equals(notifyAppId, expectedAppId) + || !amountMatch) { + log.error("支付宝异步回调业务字段不匹配,已拒绝处理。out_trade_no=[{}] expected=[{}]; app_id=[{}] expected=[{}]; total_amount=[{}] expected=[{}]", + notifyOutTradeNo, payOrder.getPayOrderId(), + notifyAppId, expectedAppId, + notifyTotalAmount, expectedTotalAmount); + ChannelRetMsg dropped = new ChannelRetMsg(); + dropped.setResponseEntity(textResp("SUCCESS")); + dropped.setChannelState(ChannelRetMsg.ChannelState.UNKNOWN); + return dropped; + } + //验签成功后判断上游订单状态 ResponseEntity okResponse = textResp("SUCCESS"); diff --git a/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/channel/alipay/AlipayKit.java b/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/channel/alipay/AlipayKit.java index ea90a1a44..ba4f6f5fb 100644 --- a/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/channel/alipay/AlipayKit.java +++ b/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/channel/alipay/AlipayKit.java @@ -27,6 +27,8 @@ import com.jeequan.jeepay.pay.model.MchAppConfigContext; import com.jeequan.jeepay.pay.service.ConfigContextQueryService; import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.tuple.ImmutablePair; +import org.apache.commons.lang3.tuple.Pair; /* * 【支付宝】支付通道工具包 @@ -108,6 +110,30 @@ public static String appendErrCode(String code, String subCode){ return StringUtils.defaultIfEmpty(subCode, code); //优先: subCode } + /** + * 解析支付宝授权回调中拼接的 state 参数(格式:ISVNO_MCHAPPID)。 + * + * 历史上 controller 直接写 split("_")[0]/[1],没做边界校验: + * 空串、不含 "_"、或者 "_" 在首尾的输入都会触发 ArrayIndexOutOfBoundsException + * 或得到空字符串,进而被恶意构造的请求触发回调链路 500。 + * + * 使用 indexOf 取第一个 "_" 切分,而不是 split,避免 mchAppId 内部含 "_" 时被切碎。 + * + * @return ImmutablePair(isvNo, mchAppId);解析失败返回 null,由调用方决定如何提示 + */ + public static Pair parseIsvAndMchAppIdState(String state) { + if (StringUtils.isEmpty(state)) { + return null; + } + int idx = state.indexOf('_'); + // idx <= 0:没有 "_" 或 "_" 在最前面(isvNo 为空) + // idx >= state.length() - 1:" _" 在最后(mchAppId 为空) + if (idx <= 0 || idx >= state.length() - 1) { + return null; + } + return ImmutablePair.of(state.substring(0, idx), state.substring(idx + 1)); + } + public static String appendErrMsg(String msg, String subMsg){ String result = null; diff --git a/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/channel/alipay/ctrl/AlipayBizController.java b/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/channel/alipay/ctrl/AlipayBizController.java index 54abec579..e00d4bb06 100644 --- a/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/channel/alipay/ctrl/AlipayBizController.java +++ b/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/channel/alipay/ctrl/AlipayBizController.java @@ -38,6 +38,7 @@ import com.jeequan.jeepay.service.impl.SysConfigService; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.tuple.Pair; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.PathVariable; @@ -73,10 +74,16 @@ public class AlipayBizController extends AbstractCtrl { @RequestMapping("/redirectAppToAppAuth/{isvAndMchAppId}") public void redirectAppToAppAuth(@PathVariable("isvAndMchAppId") String isvAndMchAppId) throws IOException { - String isvNo = isvAndMchAppId.split("_")[0]; + Pair parsed = AlipayKit.parseIsvAndMchAppIdState(isvAndMchAppId); + if (parsed == null) { + throw new BizException("授权参数非法:isvAndMchAppId 格式应为 ISVNO_MCHAPPID"); + } + String isvNo = parsed.getLeft(); AlipayIsvParams alipayIsvParams = (AlipayIsvParams) configContextQueryService.queryIsvParams(isvNo, CS.IF_CODE.ALIPAY); - alipayIsvParams.getSandbox(); + if (alipayIsvParams == null) { + throw new BizException("ISV [" + isvNo + "] 未配置支付宝参数"); + } String oauthUrl = AlipayConfig.PROD_APP_TO_APP_AUTH_URL; if(alipayIsvParams.getSandbox() != null && alipayIsvParams.getSandbox() == CS.YES){ @@ -101,10 +108,17 @@ public String appToAppAuthCallback() { if(StringUtils.isNotEmpty(isvAndMchAppId) && StringUtils.isNotEmpty(appAuthCode)){ isAlipaySysAuth = false; - String isvNo = isvAndMchAppId.split("_")[0]; - String mchAppId = isvAndMchAppId.split("_")[1]; + + Pair parsed = AlipayKit.parseIsvAndMchAppIdState(isvAndMchAppId); + if (parsed == null) { + throw new BizException("授权回调 state 格式非法:应为 ISVNO_MCHAPPID"); + } + String mchAppId = parsed.getRight(); MchApp mchApp = mchAppService.getById(mchAppId); + if (mchApp == null) { + throw new BizException("商户应用 [" + mchAppId + "] 不存在"); + } MchAppConfigContext mchAppConfigContext = configContextQueryService.queryMchInfoAndAppInfo(mchApp.getMchNo(), mchAppId); AlipayClientWrapper alipayClientWrapper = configContextQueryService.getAlipayClientWrapper(mchAppConfigContext);