diff --git a/daxpay-channel/daxpay-channel-alipay/src/main/java/cn/daxpay/open/channel/alipay/service/callback/AlipayCallbackService.java b/daxpay-channel/daxpay-channel-alipay/src/main/java/cn/daxpay/open/channel/alipay/service/callback/AlipayCallbackService.java index 16ae10d6b..50e706f8a 100644 --- a/daxpay-channel/daxpay-channel-alipay/src/main/java/cn/daxpay/open/channel/alipay/service/callback/AlipayCallbackService.java +++ b/daxpay-channel/daxpay-channel-alipay/src/main/java/cn/daxpay/open/channel/alipay/service/callback/AlipayCallbackService.java @@ -136,6 +136,12 @@ public class AlipayCallbackService { callbackData.setCallbackErrorMsg("支付宝回调状态非成功: " + resp.getTradeStatus()); } callbackData.setFinishTime(resp.getFinishTime()); + // 支付宝异步通知 buyer_id 供事后风控补录(不用通道内部 userId) + String buyerId = params.get("buyer_id"); + if (StrUtil.isBlank(buyerId)) { + buyerId = params.get("buyer_open_id"); + } + callbackData.setBuyerId(buyerId); payCallbackService.payCallback(callbackData); payCallbackRecordService.savePay(channelMchNo, callbackData); return NOTIFY_SUCCESS; diff --git a/daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/service/callback/WechatPayCallbackService.java b/daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/service/callback/WechatPayCallbackService.java index 45c2a92df..5321d981e 100644 --- a/daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/service/callback/WechatPayCallbackService.java +++ b/daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/service/callback/WechatPayCallbackService.java @@ -121,6 +121,8 @@ public class WechatPayCallbackService { data.setTradeStatus(resp.getTradeState()); data.setCallbackErrorMsg("微信回调状态非成功: " + resp.getTradeState()); } + // 买家 openid 写入 buyerId,供事后风控补录(不用通道内部 userId) + data.setBuyerId(resp.getOpenid()); return data; } diff --git a/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/strategy/risk/PayRiskCheckContext.java b/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/strategy/risk/PayRiskCheckContext.java index 5180a6725..35e2f33d9 100644 --- a/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/strategy/risk/PayRiskCheckContext.java +++ b/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/strategy/risk/PayRiskCheckContext.java @@ -54,4 +54,7 @@ public class PayRiskCheckContext { /// 通道应用 AppId(openId 精细匹配可选) private String channelAppId; + + /// 事前命中是否阻断下单(null/true=阻断;false=仅落命中记录) + private Boolean blockOnHit; } diff --git a/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/strategy/risk/PayRiskChecker.java b/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/strategy/risk/PayRiskChecker.java index 18aa03edc..77b11604a 100644 --- a/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/strategy/risk/PayRiskChecker.java +++ b/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/strategy/risk/PayRiskChecker.java @@ -5,6 +5,10 @@ package cn.daxpay.open.payment.strategy.risk; /// 实现放在可选插件(如 `daxpay-plugin-risk`)。无 Bean 时支付主链路视为放行。 /// 支付前命中应抛业务异常以拒绝下单;支付后默认只记命中,不阻断已成功资金态。 /// +/// **范围**:仅覆盖支付发起(普通 / 网关)的事前拦截与成功后补录。 +/// **不覆盖**退款、转账——黑名单语义是拦截付款发起;退款为资金回流、转账为出款, +/// 与拦截付款人下单不是同一产品决策,本期明确不接入,避免误伤合规退款。 +/// /// **注意**:与 [cn.daxpay.open.payment.strategy.plugin.AbsPayPluginStrategy] 不同—— /// 后者为协议侧事后广播且吞异常;本接口异常须向上抛出以拒绝交易。 public interface PayRiskChecker { diff --git a/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/trade/runtime/bo/CallbackData.java b/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/trade/runtime/bo/CallbackData.java index 235c084d3..49a385f74 100644 --- a/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/trade/runtime/bo/CallbackData.java +++ b/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/trade/runtime/bo/CallbackData.java @@ -36,6 +36,9 @@ public class CallbackData { /// 完成时间(UTC) private OffsetDateTime finishTime; + /// 付款用户标识(支付宝 user_id、微信 openid 等;事后风控补录) + private String buyerId; + /// 回调信息错误信息 private String callbackErrorMsg; diff --git a/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/trade/runtime/service/callback/PayCallbackService.java b/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/trade/runtime/service/callback/PayCallbackService.java index c25190b4b..bc7c157fb 100644 --- a/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/trade/runtime/service/callback/PayCallbackService.java +++ b/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/trade/runtime/service/callback/PayCallbackService.java @@ -106,7 +106,8 @@ public class PayCallbackService { if (Objects.nonNull(callbackData.getOutTradeNo())) { trade.setOutOrderNo(callbackData.getOutTradeNo()); } - payUniHandleService.paySuccess(trade); + // 回写 buyerId 并事后风控补录(统一由 paySuccess 重载处理) + payUniHandleService.paySuccess(trade, callbackData.getBuyerId()); } /// 支付失败: 仅 PROCESSING 可关失败; 与同步路径一致走 payFail(资金态 FAIL) diff --git a/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/trade/runtime/service/pay/common/PayRiskAssistService.java b/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/trade/runtime/service/pay/common/PayRiskAssistService.java new file mode 100644 index 000000000..f97306b2f --- /dev/null +++ b/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/trade/runtime/service/pay/common/PayRiskAssistService.java @@ -0,0 +1,158 @@ +package cn.daxpay.open.payment.trade.runtime.service.pay.common; + +import cn.daxpay.open.payment.strategy.pay.AbsNormalPayStrategy; +import cn.daxpay.open.payment.strategy.risk.PayRiskCheckContext; +import cn.daxpay.open.payment.strategy.risk.PayRiskChecker; +import cn.daxpay.open.payment.trade.enums.PayTradeTypeEnum; +import cn.daxpay.open.payment.trade.order.dao.GatewayPayOrderManager; +import cn.daxpay.open.payment.trade.order.dao.NormalPayOrderManager; +import cn.daxpay.open.payment.trade.order.entity.GatewayPayOrder; +import cn.daxpay.open.payment.trade.order.entity.NormalPayOrder; +import cn.daxpay.open.payment.trade.order.entity.PayTrade; +import cn.daxpay.open.payment.unipay.param.trade.pay.NormalPayParam; +import cn.daxpay.open.platform.core.enums.pay.channel.ProductEnum; +import cn.daxpay.open.platform.core.enums.pay.trade.TradeSourceEnum; +import cn.daxpay.open.platform.system.entity.config.platform.security.PlatformPaySecurityConfig; +import cn.daxpay.open.platform.system.service.config.security.PlatformSecurityConfigService; +import cn.hutool.core.util.StrUtil; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.stereotype.Service; + +import java.util.Objects; + +/// # 支付风控辅助 +/// +/// 统一封装事前/事后黑名单检查入口,供普通支付、网关支付、同步查单、异步回调复用。 +/// 插件缺失或总开关关闭时视为放行。 +@Slf4j +@Service +@RequiredArgsConstructor +public class PayRiskAssistService { + + private final ObjectProvider payRiskCheckerProvider; + private final PlatformSecurityConfigService platformSecurityConfigService; + private final NormalPayOrderManager normalPayOrderManager; + private final GatewayPayOrderManager gatewayPayOrderManager; + + /// 支付前检查(须在 [AbsNormalPayStrategy#doBeforePay] 之后调用,保证微信 channelAppId 已回填) + /// + /// `riskBlockBeforePay=false` 时仍执行检查并落命中,但不抛异常阻断下单。 + public void checkBeforePay(NormalPayParam payParam, String scene) { + PayRiskChecker checker = payRiskCheckerProvider.getIfAvailable(); + if (checker == null || payParam == null) { + return; + } + var config = platformSecurityConfigService.getPaySecurityConfig(); + if (!Boolean.TRUE.equals(config.getRiskEnabled())) { + return; + } + PayRiskCheckContext ctx = buildContextFromParam(payParam, scene); + // false=仅记录不拦截;缺省/true=命中拒绝下单 + ctx.setBlockOnHit(!Boolean.FALSE.equals(config.getRiskBlockBeforePay())); + checker.checkBeforePay(ctx); + } + + /// 支付成功后补录(同步返回 / 查单 / 回调统一入口);异常只告警,不阻断资金态 + /// + /// 仅使用付款用户标识 [buyerId](微信 openid / 支付宝 user_id),不读取通道内部 userId。 + public void checkAfterPay(PayTrade trade, String buyerId) { + PayRiskChecker checker = payRiskCheckerProvider.getIfAvailable(); + if (checker == null || trade == null) { + return; + } + PlatformPaySecurityConfig config = platformSecurityConfigService.getPaySecurityConfig(); + if (!Boolean.TRUE.equals(config.getRiskEnabled()) + || !Boolean.TRUE.equals(config.getRiskCheckAfterPay())) { + return; + } + PayRiskCheckContext ctx = buildContextFromTrade(trade); + if (StrUtil.isNotBlank(buyerId)) { + ctx.setBuyerId(buyerId); + } + // 下单未带 openId 时,用通道回写的 buyerId 补用户标识比对 + if (StrUtil.isBlank(ctx.getOpenId()) && StrUtil.isNotBlank(ctx.getBuyerId())) { + ctx.setOpenId(ctx.getBuyerId()); + } + try { + checker.checkAfterPay(ctx); + } catch (Exception e) { + log.warn("支付后风控补录失败 tradeNo={}: {}", trade.getTradeNo(), e.getMessage()); + } + } + + private PayRiskCheckContext buildContextFromParam(NormalPayParam payParam, String scene) { + PayRiskCheckContext ctx = new PayRiskCheckContext() + .setScene(StrUtil.blankToDefault(scene, resolveSceneFromSource(payParam.getSource()))) + .setMchNo(payParam.getMchNo()) + .setAppId(payParam.getAppId()) + .setClientIp(payParam.getClientIp()) + .setOpenId(payParam.getOpenId()) + .setMethod(payParam.getMethod()) + .setProduct(payParam.getProduct()) + .setChannelAppId(payParam.getChannelAppId()) + .setBizOrderNo(payParam.getBizOrderNo()); + fillChannelByProduct(ctx, payParam.getProduct()); + return ctx; + } + + private PayRiskCheckContext buildContextFromTrade(PayTrade trade) { + PayRiskCheckContext ctx = new PayRiskCheckContext() + .setTradeNo(trade.getTradeNo()) + .setTradeType(trade.getTradeType()) + .setMchNo(trade.getMchNo()) + .setAppId(trade.getAppId()); + if (Objects.equals(trade.getTradeType(), PayTradeTypeEnum.GATEWAY.getCode())) { + GatewayPayOrder order = gatewayPayOrderManager.findById(trade.getContainerId()).orElse(null); + if (order != null) { + ctx.setScene("gateway") + .setClientIp(order.getClientIp()) + .setOpenId(order.getOpenid()) + .setBuyerId(order.getBuyerId()) + .setMethod(order.getMethod()) + .setProduct(order.getProduct()) + .setChannel(order.getChannel()) + .setChannelAppId(order.getChannelAppId()) + .setOrderNo(order.getOrderNo()) + .setBizOrderNo(order.getBizOrderNo()); + } + } else { + NormalPayOrder order = normalPayOrderManager.findById(trade.getContainerId()).orElse(null); + if (order != null) { + ctx.setScene(resolveSceneFromSource(order.getSource())) + .setClientIp(order.getClientIp()) + .setOpenId(order.getOpenid()) + .setBuyerId(order.getBuyerId()) + .setMethod(order.getMethod()) + .setProduct(order.getProduct()) + .setChannel(order.getChannel()) + .setChannelAppId(order.getChannelAppId()) + .setOrderNo(order.getOrderNo()) + .setBizOrderNo(order.getBizOrderNo()); + } + } + if (StrUtil.isBlank(ctx.getChannel())) { + fillChannelByProduct(ctx, ctx.getProduct()); + } + return ctx; + } + + private static void fillChannelByProduct(PayRiskCheckContext ctx, String product) { + if (StrUtil.isBlank(product)) { + return; + } + try { + ctx.setChannel(ProductEnum.findByCode(product).getChannel()); + } catch (Exception ignored) { + // 反推失败忽略, channel 留空 + } + } + + private static String resolveSceneFromSource(String source) { + if (StrUtil.isNotBlank(source) && TradeSourceEnum.CASHIER_CODE.getCode().equals(source)) { + return "code"; + } + return "api"; + } +} diff --git a/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/trade/runtime/service/pay/common/PayUniHandleService.java b/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/trade/runtime/service/pay/common/PayUniHandleService.java index ce0085465..ffa59b3cb 100644 --- a/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/trade/runtime/service/pay/common/PayUniHandleService.java +++ b/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/trade/runtime/service/pay/common/PayUniHandleService.java @@ -40,6 +40,7 @@ public class PayUniHandleService { private final GatewayPayOrderManager gatewayPayOrderManager; private final PayPluginAssistService payPluginAssistService; private final TradeNoticeBridge tradeNoticeBridge; + private final PayRiskAssistService payRiskAssistService; /// 支付发起后处理 /// 不论是否完成都更新交易单; 仅资金状态为 SUCCESS 时同步容器为 PAID。 @@ -75,11 +76,13 @@ public class PayUniHandleService { payNormalOrderManager.updateById(order); } } - // 出站通知 + 插件: 仅支付成功时 + // 出站通知 + 插件 + 事后风控: 仅支付成功时 if (Objects.equals(trade.getStatus(), PayFundStatusEnum.SUCCESS.getCode())) { // 商户出站通知(系统协议) tradeNoticeBridge.dispatchPay(trade, NoticeEventEnum.PAY_SUCCESS); payPluginAssistService.paySuccess(trade); + // 事后风控补录(仅用付款用户 buyerId,不用通道内部 userId) + payRiskAssistService.checkAfterPay(trade, result.getBuyerId()); } } @@ -98,6 +101,8 @@ public class PayUniHandleService { // 商户出站通知(系统协议) tradeNoticeBridge.dispatchPay(trade, NoticeEventEnum.PAY_SUCCESS); payPluginAssistService.paySuccess(trade); + payRiskAssistService.checkAfterPay(trade, + syncResult != null ? syncResult.getBuyerId() : null); return; } NormalPayOrder order = payNormalOrderManager.findById(trade.getContainerId()).orElse(null); @@ -112,17 +117,25 @@ public class PayUniHandleService { // 商户出站通知(系统协议) tradeNoticeBridge.dispatchPay(trade, NoticeEventEnum.PAY_SUCCESS); payPluginAssistService.paySuccess(trade); + payRiskAssistService.checkAfterPay(trade, + syncResult != null ? syncResult.getBuyerId() : null); } - /// 支付成功后续处理(回调路径, 无回执详情) - public void paySuccess(PayTrade trade) { + /// 支付成功后续处理(回调路径);可选回写 buyerId 后补录风控 + public void paySuccess(PayTrade trade, String buyerId) { if (isGateway(trade)) { GatewayPayOrder order = gatewayPayOrderManager.findById(trade.getContainerId()).orElse(null); + if (order != null && StrUtil.isNotBlank(buyerId)) { + order.setBuyerId(buyerId); + } applyProviderFallback(trade, order); updateTradeWithPosted(trade); markContainerPaid(trade, order); } else { NormalPayOrder order = payNormalOrderManager.findById(trade.getContainerId()).orElse(null); + if (order != null && StrUtil.isNotBlank(buyerId)) { + order.setBuyerId(buyerId); + } applyProviderFallback(trade, order); updateTradeWithPosted(trade); markContainerPaid(trade, order); @@ -130,6 +143,12 @@ public class PayUniHandleService { // 商户出站通知(系统协议) tradeNoticeBridge.dispatchPay(trade, NoticeEventEnum.PAY_SUCCESS); payPluginAssistService.paySuccess(trade); + payRiskAssistService.checkAfterPay(trade, buyerId); + } + + /// 支付成功后续处理(回调路径, 无回执详情) + public void paySuccess(PayTrade trade) { + paySuccess(trade, (String) null); } /// 支付失败处理: 资金态 FAIL, 容器态 FAILED(与主动关单 closed 区分) diff --git a/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/trade/runtime/service/pay/gateway/GatewayPayHandleService.java b/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/trade/runtime/service/pay/gateway/GatewayPayHandleService.java index 76b9af824..91669c8d5 100644 --- a/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/trade/runtime/service/pay/gateway/GatewayPayHandleService.java +++ b/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/trade/runtime/service/pay/gateway/GatewayPayHandleService.java @@ -8,9 +8,8 @@ import cn.daxpay.open.payment.route.service.runtime.PayRouteService; import cn.daxpay.open.payment.strategy.PaymentStrategyFactory; import cn.daxpay.open.payment.strategy.pay.AbsNormalPayStrategy; import cn.daxpay.open.payment.strategy.pay.PayStrategyContext; -import cn.daxpay.open.payment.strategy.risk.PayRiskCheckContext; -import cn.daxpay.open.payment.strategy.risk.PayRiskChecker; import cn.daxpay.open.payment.trade.runtime.bo.PayTradeResultBo; +import cn.daxpay.open.payment.trade.runtime.service.pay.common.PayRiskAssistService; import cn.daxpay.open.payment.trade.runtime.service.pay.common.PayUniHandleService; import cn.daxpay.open.payment.trade.order.dao.GatewayPayOrderManager; import cn.daxpay.open.payment.trade.order.dao.PayTradeManager; @@ -24,14 +23,11 @@ import cn.daxpay.open.platform.core.enums.pay.channel.ProductEnum; import cn.daxpay.open.platform.core.exception.BizInfoException; import cn.daxpay.open.platform.core.exception.PayFailureException; import cn.daxpay.open.platform.core.util.TradeNoGenerateUtil; -import cn.daxpay.open.platform.system.entity.config.platform.security.PlatformPaySecurityConfig; -import cn.daxpay.open.platform.system.service.config.security.PlatformSecurityConfigService; import cn.daxpay.open.payment.trade.util.PayTradeInitUtil; import cn.daxpay.open.payment.trade.util.PayTradeProviderUtil; import cn.hutool.core.util.StrUtil; import lombok.AllArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.ObjectProvider; import org.springframework.context.annotation.Lazy; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -53,9 +49,7 @@ public class GatewayPayHandleService { private final PayUniHandleService payUniHandleService; private final GatewayPayAssistService gatewayPayAssistService; private final LockExecutor lockExecutor; - /// 风控检查器(可选 SPI:插件 daxpay-plugin-risk 在 classpath 时生效,无 Bean 视为放行) - private final ObjectProvider payRiskCheckerProvider; - private final PlatformSecurityConfigService platformSecurityConfigService; + private final PayRiskAssistService payRiskAssistService; /// 自注入,保证 [GatewayPayHandleService#createTrade] / [GatewayPayHandleService#paySuccess] 走 Spring 事务代理 @Lazy @@ -114,11 +108,11 @@ public class GatewayPayHandleService { // 组装路由用参数 NormalPayParam payParam = this.buildPayParam(current, product, method, channelMchNo, capability, openId, clientIp); payRouteService.resolve(payParam); - // 风控前置检查: 命中黑名单(IP/openId)抛异常拒绝下单; openId 缺失时降级为仅 IP 校验 + 事后补录 - this.runRiskBeforePay(current, payParam); var payStrategy = PaymentStrategyFactory.createByProduct(payParam.getProduct(), AbsNormalPayStrategy.class); var context = new PayStrategyContext().setPayParam(payParam); + // 通道预处理先回填 channelAppId, 再做风控, 保证微信 openId 名单可精确匹配 payStrategy.doBeforePay(context); + payRiskAssistService.checkBeforePay(payParam, "gateway"); // 建 Trade(若无) + 回填容器 if (existing == null) { @@ -140,10 +134,8 @@ public class GatewayPayHandleService { payUniHandleService.payFail(existing, errMsg); throw (RuntimeException) e; } - NormalPayResult payResult = self.paySuccess(current, existing, result); - // 风控事后补录: 用通道回写 buyerId 补充命中检查, 仅记录不阻断资金态 - this.runRiskAfterPay(current, payParam, existing, result); - return payResult; + // 事后风控在 payAfterHandel(SUCCESS) 内统一补录 + return self.paySuccess(current, existing, result); }, () -> new BizInfoException(CommonErrorCode.VALIDATE_PARAMETERS_ERROR, "pay.error.pay.processing") ); @@ -267,71 +259,4 @@ public class GatewayPayHandleService { .setPayBodyType(order.getPayBodyType()); } - /// 支付前风控检查:命中黑名单抛异常拒绝下单;插件缺失或开关关闭视为放行 - private void runRiskBeforePay(GatewayPayOrder order, NormalPayParam payParam) { - PayRiskChecker checker = payRiskCheckerProvider.getIfAvailable(); - if (checker == null) { - return; - } - PlatformPaySecurityConfig config = platformSecurityConfigService.getPaySecurityConfig(); - if (!Boolean.TRUE.equals(config.getRiskEnabled()) - || !Boolean.TRUE.equals(config.getRiskBlockBeforePay())) { - return; - } - PayRiskCheckContext ctx = this.buildRiskContext(order, payParam); - checker.checkBeforePay(ctx); - } - - /// 支付成功后风控补录:用通道回写 buyerId 补充命中检查,仅记录不阻断资金态 - private void runRiskAfterPay(GatewayPayOrder order, NormalPayParam payParam, PayTrade trade, PayTradeResultBo result) { - PayRiskChecker checker = payRiskCheckerProvider.getIfAvailable(); - if (checker == null) { - return; - } - PlatformPaySecurityConfig config = platformSecurityConfigService.getPaySecurityConfig(); - if (!Boolean.TRUE.equals(config.getRiskEnabled()) - || !Boolean.TRUE.equals(config.getRiskCheckAfterPay())) { - return; - } - PayRiskCheckContext ctx = this.buildRiskContext(order, payParam); - ctx.setTradeNo(trade.getTradeNo()); - ctx.setTradeType(PayTradeTypeEnum.GATEWAY.getCode()); - if (result != null) { - ctx.setBuyerId(result.getBuyerId()); - if (StrUtil.isBlank(ctx.getOpenId())) { - ctx.setOpenId(result.getUserId()); - } - } - try { - checker.checkAfterPay(ctx); - } catch (Exception e) { - log.warn("网关支付后风控补录失败 tradeNo={}: {}", trade.getTradeNo(), e.getMessage()); - } - } - - /// 构建风控上下文(路由解析后调用, 此时 product/method/channelAppId 已回填) - private PayRiskCheckContext buildRiskContext(GatewayPayOrder order, NormalPayParam payParam) { - PayRiskCheckContext ctx = new PayRiskCheckContext() - // 网关统一标 gateway, 命中记录可按场景筛选运营处置 - .setScene("gateway") - .setMchNo(payParam.getMchNo()) - .setAppId(payParam.getAppId()) - .setClientIp(payParam.getClientIp()) - .setOpenId(payParam.getOpenId()) - .setMethod(payParam.getMethod()) - .setProduct(payParam.getProduct()) - .setChannelAppId(payParam.getChannelAppId()) - .setOrderNo(order.getOrderNo()) - .setBizOrderNo(payParam.getBizOrderNo()); - // 由支付产品反推通道族, 供 openId 名单精确匹配 - if (StrUtil.isNotBlank(payParam.getProduct())) { - try { - ctx.setChannel(ProductEnum.findByCode(payParam.getProduct()).getChannel()); - } catch (Exception e) { - // 反推失败忽略, channel 留空走宽匹配 - } - } - return ctx; - } - } diff --git a/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/trade/runtime/service/pay/normal/NormalPayService.java b/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/trade/runtime/service/pay/normal/NormalPayService.java index 98a51491d..aa1376b92 100644 --- a/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/trade/runtime/service/pay/normal/NormalPayService.java +++ b/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/trade/runtime/service/pay/normal/NormalPayService.java @@ -6,18 +6,13 @@ import cn.daxpay.open.platform.core.code.CommonErrorCode; import cn.daxpay.open.platform.core.exception.BizInfoException; import cn.daxpay.open.platform.core.exception.PayFailureException; import cn.daxpay.open.platform.common.spring.util.WebServletUtil; -import cn.daxpay.open.platform.system.entity.config.platform.security.PlatformPaySecurityConfig; -import cn.daxpay.open.platform.system.service.config.security.PlatformSecurityConfigService; -import cn.daxpay.open.payment.strategy.risk.PayRiskCheckContext; -import cn.daxpay.open.payment.strategy.risk.PayRiskChecker; -import cn.daxpay.open.platform.core.enums.pay.channel.ProductEnum; import cn.daxpay.open.platform.core.enums.pay.trade.TradeSourceEnum; import cn.daxpay.open.payment.trade.enums.PayFundStatusEnum; -import cn.daxpay.open.payment.trade.enums.PayTradeTypeEnum; import cn.daxpay.open.payment.common.context.MerchantContextLoader; import cn.daxpay.open.payment.common.util.PayBarCodeUtil; import cn.daxpay.open.payment.strategy.PaymentStrategyFactory; import cn.daxpay.open.payment.trade.runtime.bo.PayTradeResultBo; +import cn.daxpay.open.payment.trade.runtime.service.pay.common.PayRiskAssistService; import cn.daxpay.open.payment.trade.runtime.service.pay.common.PayUniHandleService; import cn.daxpay.open.payment.trade.order.entity.PayTrade; import cn.daxpay.open.payment.route.service.runtime.PayRouteService; @@ -30,7 +25,6 @@ import cn.daxpay.open.platform.common.redis.lock.LockExecutor; import cn.hutool.core.collection.CollUtil; import cn.hutool.core.util.StrUtil; import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.ObjectProvider; import org.springframework.context.annotation.Lazy; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -49,9 +43,7 @@ public class NormalPayService { private final PayRouteService payRouteService; private final MerchantContextLoader merchantContextLoader; private final SensitiveWordCheckService sensitiveWordCheckService; - /// 风控检查器(可选 SPI:插件 daxpay-plugin-risk 在 classpath 时生效,无 Bean 视为放行) - private final ObjectProvider payRiskCheckerProvider; - private final PlatformSecurityConfigService platformSecurityConfigService; + private final PayRiskAssistService payRiskAssistService; /// 自注入,保证 [NormalPayService#paySuccess] 走 Spring 事务代理 private final NormalPayService self; @@ -62,8 +54,7 @@ public class NormalPayService { PayRouteService payRouteService, MerchantContextLoader merchantContextLoader, SensitiveWordCheckService sensitiveWordCheckService, - ObjectProvider payRiskCheckerProvider, - PlatformSecurityConfigService platformSecurityConfigService, + PayRiskAssistService payRiskAssistService, @Lazy NormalPayService self) { this.payAssistService = payAssistService; this.payUniHandleService = payUniHandleService; @@ -71,8 +62,7 @@ public class NormalPayService { this.payRouteService = payRouteService; this.merchantContextLoader = merchantContextLoader; this.sensitiveWordCheckService = sensitiveWordCheckService; - this.payRiskCheckerProvider = payRiskCheckerProvider; - this.platformSecurityConfigService = platformSecurityConfigService; + this.payRiskAssistService = payRiskAssistService; this.self = self; } @@ -113,7 +103,7 @@ public class NormalPayService { } /// 支付操作 - /// 拆分为多阶段: 1.应用解析与校验 2.付款码识别 3.通道路由 4.查询已有订单 5.新建订单 6.发起支付 7.支付成功后处理 + /// 拆分为多阶段: 1.应用解析与校验 2.付款码识别 3.通道路由 4.通道预处理 5.风控 6.建单 7.发起支付 8.成功后处理 public NormalPayResult payHandle(NormalPayParam payParam) { // 应用解析: 空则取商户默认应用, 校验启用与归属, 回填到 payParam var mchApp = merchantContextLoader.resolveApp(payParam.getMchNo(), payParam.getAppId()); @@ -122,12 +112,12 @@ public class NormalPayService { this.resolveBarcodeMethodIfNeeded(payParam); // 路由解析:直接指定(已传 channelMchNo)优先,否则按 appId+method 跟随通道路由匹配 payRouteService.resolve(payParam); - // 风控前置检查: 命中黑名单(IP/openId)抛异常拒绝下单; openId 缺失时降级为仅 IP 校验 + 事后补录 - this.runRiskBeforePay(payParam); var payStrategy = PaymentStrategyFactory.createByProduct(payParam.getProduct(), AbsNormalPayStrategy.class); - // 支付前处理: 校验与通道配置组装(只依赖请求参数), 失败直接抛出不持久化(订单尚未创建) + // 支付前处理: 校验与通道配置组装(只依赖请求参数), 含微信 channelAppId 回填; 失败直接抛出不持久化 var context = new PayStrategyContext().setPayParam(payParam); payStrategy.doBeforePay(context); + // 风控前置: 须在 doBeforePay 之后, 微信 openId 名单依赖已回填的 channelAppId 精确匹配 + payRiskAssistService.checkBeforePay(payParam, this.resolveRiskScene(payParam)); // 查询已有订单并校验,结果填充到 context payAssistService.findAndCheckOrder(payParam.getBizOrderNo(), context); // 已拉起支付则返回缓存的支付参数(payBody 仅在容器) @@ -153,10 +143,8 @@ public class NormalPayService { payUniHandleService.payFail(trade, errMsg); throw e; } - NormalPayResult payResult = self.paySuccess(trade, result); - // 风控事后补录: 用通道回写 buyerId 补充命中检查, 仅记录不阻断资金态 - this.runRiskAfterPay(payParam, trade, result); - return payResult; + // 事后风控在 payAfterHandel(SUCCESS) 内统一补录 + return self.paySuccess(trade, result); } /// 付款码 method 回填: 仅 authCode 时识别; 已传分钱包条码 method 时校验前缀一致 @@ -181,78 +169,11 @@ public class NormalPayService { } // trade.status 在 complete=false 时保持 PROCESSING(createOrder 时已设) trade.setOutOrderNo(result.getOutOrderNo()); - // 回执与 payBody 写容器, 由 payAfterHandel 统一处理 + // 回执与 payBody 写容器, 由 payAfterHandel 统一处理(含事后风控补录) payUniHandleService.payAfterHandel(trade, result); return payAssistService.buildResult(trade); } - /// 支付前风控检查:命中黑名单抛异常拒绝下单;插件缺失或开关关闭视为放行 - private void runRiskBeforePay(NormalPayParam payParam) { - PayRiskChecker checker = payRiskCheckerProvider.getIfAvailable(); - if (checker == null) { - return; - } - PlatformPaySecurityConfig config = platformSecurityConfigService.getPaySecurityConfig(); - if (!Boolean.TRUE.equals(config.getRiskEnabled()) - || !Boolean.TRUE.equals(config.getRiskBlockBeforePay())) { - return; - } - PayRiskCheckContext ctx = this.buildRiskContext(payParam); - checker.checkBeforePay(ctx); - } - - /// 支付成功后风控补录:用通道回写 buyerId 补充命中检查,仅记录不阻断资金态 - private void runRiskAfterPay(NormalPayParam payParam, PayTrade trade, PayTradeResultBo result) { - PayRiskChecker checker = payRiskCheckerProvider.getIfAvailable(); - if (checker == null) { - return; - } - PlatformPaySecurityConfig config = platformSecurityConfigService.getPaySecurityConfig(); - if (!Boolean.TRUE.equals(config.getRiskEnabled()) - || !Boolean.TRUE.equals(config.getRiskCheckAfterPay())) { - return; - } - PayRiskCheckContext ctx = this.buildRiskContext(payParam); - ctx.setTradeNo(trade.getTradeNo()); - ctx.setTradeType(PayTradeTypeEnum.NORMAL.getCode()); - if (result != null) { - ctx.setBuyerId(result.getBuyerId()); - // 优先用下单 openId, 空则用通道回写的 userId 兜底 - if (StrUtil.isBlank(ctx.getOpenId())) { - ctx.setOpenId(result.getUserId()); - } - } - try { - checker.checkAfterPay(ctx); - } catch (Exception e) { - // 事后补录失败不影响交易, 仅告警 - log.warn("支付后风控补录失败 tradeNo={}: {}", trade.getTradeNo(), e.getMessage()); - } - } - - /// 构建风控上下文(路由解析后调用, 此时 product/method/channelAppId 已回填) - private PayRiskCheckContext buildRiskContext(NormalPayParam payParam) { - PayRiskCheckContext ctx = new PayRiskCheckContext() - .setScene(this.resolveRiskScene(payParam)) - .setMchNo(payParam.getMchNo()) - .setAppId(payParam.getAppId()) - .setClientIp(payParam.getClientIp()) - .setOpenId(payParam.getOpenId()) - .setMethod(payParam.getMethod()) - .setProduct(payParam.getProduct()) - .setChannelAppId(payParam.getChannelAppId()) - .setBizOrderNo(payParam.getBizOrderNo()); - // 由支付产品反推通道族, 供 openId 名单精确匹配 - if (StrUtil.isNotBlank(payParam.getProduct())) { - try { - ctx.setChannel(ProductEnum.findByCode(payParam.getProduct()).getChannel()); - } catch (Exception e) { - // 反推失败忽略, channel 留空走宽匹配 - } - } - return ctx; - } - /// 按交易来源派生风控场景(与 PayRiskHitSceneEnum 编码对齐): /// 码牌(cashier_code)→code;其余 API 直连→api private String resolveRiskScene(NormalPayParam payParam) { diff --git a/daxpay-plugin/daxpay-plugin-risk/src/main/java/cn/daxpay/open/plugin/risk/strategy/DefaultPayRiskChecker.java b/daxpay-plugin/daxpay-plugin-risk/src/main/java/cn/daxpay/open/plugin/risk/strategy/DefaultPayRiskChecker.java index 85d80aeb2..0529c8995 100644 --- a/daxpay-plugin/daxpay-plugin-risk/src/main/java/cn/daxpay/open/plugin/risk/strategy/DefaultPayRiskChecker.java +++ b/daxpay-plugin/daxpay-plugin-risk/src/main/java/cn/daxpay/open/plugin/risk/strategy/DefaultPayRiskChecker.java @@ -49,10 +49,12 @@ public class DefaultPayRiskChecker implements PayRiskChecker { return; } ctx.setPhase(PayRiskHitPhaseEnum.BEFORE_PAY.getCode()); + // null/true=阻断下单;false=仅落命中(对齐 riskBlockBeforePay) + boolean throwOnHit = !Boolean.FALSE.equals(ctx.getBlockOnHit()); // IP 名单(全局生效) - rejectIfBlocked(ctx, PayBlacklistTypeEnum.IP.getCode(), ctx.getClientIp(), null, true); + rejectIfBlocked(ctx, PayBlacklistTypeEnum.IP.getCode(), ctx.getClientIp(), null, throwOnHit); // 用户标识:按通道映射名单类型 - boolean identityBlocked = checkUserIdentity(ctx, ctx.getOpenId(), true); + boolean identityBlocked = checkUserIdentity(ctx, ctx.getOpenId(), throwOnHit); if (!identityBlocked && StrUtil.isBlank(ctx.getOpenId())) { log.warn("支付前 openId 缺失, 用户标识黑名单降级为仅 IP 校验 + 事后补录: " + "tradeType={}, method={}, mchNo={}, clientIp={}",