fix(risk): 事前风控移至 doBeforePay 后并补齐成功路径事后补录

微信 openId 名单依赖 channelAppId 精确匹配,须在通道预处理回填后再拦截;查单/回调成功统一经 PayUniHandleService 仅用 buyerId 补录命中。退款与转账本期不接入。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
DaxPay Dev
2026-07-24 16:50:20 +08:00
parent 5420963ecc
commit e8f56fbb47
11 changed files with 221 additions and 177 deletions

View File

@@ -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;

View File

@@ -121,6 +121,8 @@ public class WechatPayCallbackService {
data.setTradeStatus(resp.getTradeState());
data.setCallbackErrorMsg("微信回调状态非成功: " + resp.getTradeState());
}
// 买家 openid 写入 buyerId供事后风控补录不用通道内部 userId
data.setBuyerId(resp.getOpenid());
return data;
}

View File

@@ -54,4 +54,7 @@ public class PayRiskCheckContext {
/// 通道应用 AppIdopenId 精细匹配可选)
private String channelAppId;
/// 事前命中是否阻断下单null/true=阻断false=仅落命中记录)
private Boolean blockOnHit;
}

View File

@@ -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 {

View File

@@ -36,6 +36,9 @@ public class CallbackData {
/// 完成时间(UTC)
private OffsetDateTime finishTime;
/// 付款用户标识(支付宝 user_id、微信 openid 等;事后风控补录)
private String buyerId;
/// 回调信息错误信息
private String callbackErrorMsg;

View File

@@ -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)

View File

@@ -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";
}
}

View File

@@ -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 区分)

View File

@@ -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;
}
}

View File

@@ -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) {

View File

@@ -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={}",