mirror of
https://gitee.com/dromara/dax-pay
synced 2026-08-08 22:25:32 +08:00
fix(risk): 事前风控移至 doBeforePay 后并补齐成功路径事后补录
微信 openId 名单依赖 channelAppId 精确匹配,须在通道预处理回填后再拦截;查单/回调成功统一经 PayUniHandleService 仅用 buyerId 补录命中。退款与转账本期不接入。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -121,6 +121,8 @@ public class WechatPayCallbackService {
|
||||
data.setTradeStatus(resp.getTradeState());
|
||||
data.setCallbackErrorMsg("微信回调状态非成功: " + resp.getTradeState());
|
||||
}
|
||||
// 买家 openid 写入 buyerId,供事后风控补录(不用通道内部 userId)
|
||||
data.setBuyerId(resp.getOpenid());
|
||||
return data;
|
||||
}
|
||||
|
||||
|
||||
@@ -54,4 +54,7 @@ public class PayRiskCheckContext {
|
||||
|
||||
/// 通道应用 AppId(openId 精细匹配可选)
|
||||
private String channelAppId;
|
||||
|
||||
/// 事前命中是否阻断下单(null/true=阻断;false=仅落命中记录)
|
||||
private Boolean blockOnHit;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -36,6 +36,9 @@ public class CallbackData {
|
||||
/// 完成时间(UTC)
|
||||
private OffsetDateTime finishTime;
|
||||
|
||||
/// 付款用户标识(支付宝 user_id、微信 openid 等;事后风控补录)
|
||||
private String buyerId;
|
||||
|
||||
/// 回调信息错误信息
|
||||
private String callbackErrorMsg;
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<PayRiskChecker> 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";
|
||||
}
|
||||
}
|
||||
@@ -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 区分)
|
||||
|
||||
@@ -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<PayRiskChecker> 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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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<PayRiskChecker> 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<PayRiskChecker> 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) {
|
||||
|
||||
@@ -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={}",
|
||||
|
||||
Reference in New Issue
Block a user