feat(risk): 接入风控主链路并扩展 openId 黑名单覆盖范围

- 接入 PayRiskChecker SPI 到 NormalPayService/GatewayPayHandleService,
  修复此前风控完全不生效问题(checkBeforePay/checkAfterPay)
- PlatformPaySecurityConfig 加 @Cacheable 多级缓存避免每笔支付打 DB
- PayRiskChecker 加 hasOpenIdBlacklist() SPI, 默认 false, 由插件实现
- PayMethodOpenIdSupport 新增 canAcquireOpenId(method, clientEnv) 矩阵:
  JSAPI/MINI/QR/H5 在微信/支付宝/抖音内可静默 OAuth; 付款码/APP/PC/聚合码
  始终不可; union_pay 一期无平台 OAuth; browser 外部浏览器不可
- AggregatePayService/CodePayAssistService/CashierItemPublicResult 的 needOpenId
  智能计算: JSAPI/MINI 业务必需 OR (存在 openId 名单 AND canAcquireOpenId)
- DefaultPayRiskChecker 实现 hasOpenIdBlacklist + Caffeine 30s 短缓存 +
  openId 缺失时 WARN 降级日志(付款码/APP/外部浏览器走 IP + 事后补录)
- 补齐 pay_blacklist / pay_risk_hit 两张表 DDL(PG 风格 + COMMENT ON)
This commit is contained in:
DaxPay Dev
2026-07-20 12:00:59 +08:00
parent 78b99f9be3
commit e8f5e785c3
13 changed files with 573 additions and 32 deletions

View File

@@ -1,3 +1,134 @@
-- pay_trade 新增 title 列: 冗余自容器(业务订单), 资金列表/工作台免 JOIN 容器即可展示订单标题
ALTER TABLE pay_trade ADD COLUMN title VARCHAR(255);
COMMENT ON COLUMN pay_trade.title IS '订单标题(冗余自容器, 资金列表免JOIN; 权威在容器)';
-- ====================================================================
-- 增量建表脚本PostgreSQL
-- 与全量 tables.sql 配合使用tables.sql 创建基础表,本文件追加新增表
-- 字段注释一律使用 COMMENT ON 语句(符合 PG 标准 DDL
-- ====================================================================
-- ----------------------------
-- Table structure for pay_blacklist
-- 支付黑名单配置(平台级名单,维度为 IP / openId
-- ----------------------------
DROP TABLE IF EXISTS "public"."pay_blacklist";
CREATE TABLE "public"."pay_blacklist" (
"id" int8 NOT NULL,
"creator" int8,
"create_time" timestamptz(6),
"last_modifier" int8,
"last_modified_time" timestamptz(6),
"version" int4 NOT NULL DEFAULT 0,
"deleted" bool NOT NULL DEFAULT false,
"type" varchar(32) NOT NULL,
"value" varchar(128) NOT NULL,
"channel" varchar(32),
"channel_app_id" varchar(64),
"status" varchar(16) NOT NULL DEFAULT 'enable',
"reason" varchar(255),
"expire_time" timestamptz(6),
"remark" varchar(255)
)
;
COMMENT ON COLUMN "public"."pay_blacklist"."id" IS '主键';
COMMENT ON COLUMN "public"."pay_blacklist"."creator" IS '创建者ID';
COMMENT ON COLUMN "public"."pay_blacklist"."create_time" IS '创建时间';
COMMENT ON COLUMN "public"."pay_blacklist"."last_modifier" IS '最后修改ID';
COMMENT ON COLUMN "public"."pay_blacklist"."last_modified_time" IS '最后修改时间';
COMMENT ON COLUMN "public"."pay_blacklist"."version" IS '版本号';
COMMENT ON COLUMN "public"."pay_blacklist"."deleted" IS '删除标志';
COMMENT ON COLUMN "public"."pay_blacklist"."type" IS '名单类型: ip-IP地址, open_id-用户标识';
COMMENT ON COLUMN "public"."pay_blacklist"."value" IS '名单值IP 地址或 openId';
COMMENT ON COLUMN "public"."pay_blacklist"."channel" IS '通道族openId 建议 wechat/alipayIP 可空,空表示全局)';
COMMENT ON COLUMN "public"."pay_blacklist"."channel_app_id" IS '通道应用 AppId可选防 openId 跨应用误杀)';
COMMENT ON COLUMN "public"."pay_blacklist"."status" IS '状态: enable-启用, disable-禁用';
COMMENT ON COLUMN "public"."pay_blacklist"."reason" IS '拉黑原因';
COMMENT ON COLUMN "public"."pay_blacklist"."expire_time" IS '过期时间(空表示永久有效)';
COMMENT ON COLUMN "public"."pay_blacklist"."remark" IS '备注';
COMMENT ON TABLE "public"."pay_blacklist" IS '支付黑名单配置(平台级名单,维度为 IP / openId不含商户号事前拉黑';
-- ----------------------------
-- Primary Key & Indexes for pay_blacklist
-- ----------------------------
ALTER TABLE "public"."pay_blacklist" ADD CONSTRAINT "pay_blacklist_pkey" PRIMARY KEY ("id");
CREATE INDEX "idx_pay_blacklist_type_value" ON "public"."pay_blacklist" USING btree ("type", "value");
CREATE INDEX "idx_pay_blacklist_status" ON "public"."pay_blacklist" USING btree ("status");
-- ----------------------------
-- Table structure for pay_risk_hit
-- 支付风险命中记录(事前拦截与事后命中)
-- ----------------------------
DROP TABLE IF EXISTS "public"."pay_risk_hit";
CREATE TABLE "public"."pay_risk_hit" (
"id" int8 NOT NULL,
"creator" int8,
"create_time" timestamptz(6),
"last_modifier" int8,
"last_modified_time" timestamptz(6),
"version" int4 NOT NULL DEFAULT 0,
"deleted" bool NOT NULL DEFAULT false,
"phase" varchar(16) NOT NULL,
"hit_type" varchar(32) NOT NULL,
"hit_value" varchar(128) NOT NULL,
"blacklist_id" int8,
"mch_no" varchar(32),
"app_id" varchar(64),
"trade_no" varchar(64),
"order_no" varchar(64),
"biz_order_no" varchar(128),
"trade_type" varchar(32),
"method" varchar(32),
"product" varchar(32),
"channel" varchar(32),
"client_ip" varchar(64),
"openid" varchar(128),
"buyer_id" varchar(128),
"scene" varchar(16) NOT NULL DEFAULT 'unknown',
"handle_status" varchar(20) NOT NULL DEFAULT 'pending',
"handle_remark" varchar(500),
"handle_user_id" int8,
"handle_time" timestamptz(6),
"remark" varchar(255)
)
;
COMMENT ON COLUMN "public"."pay_risk_hit"."id" IS '主键';
COMMENT ON COLUMN "public"."pay_risk_hit"."creator" IS '创建者ID';
COMMENT ON COLUMN "public"."pay_risk_hit"."create_time" IS '创建时间';
COMMENT ON COLUMN "public"."pay_risk_hit"."last_modifier" IS '最后修改ID';
COMMENT ON COLUMN "public"."pay_risk_hit"."last_modified_time" IS '最后修改时间';
COMMENT ON COLUMN "public"."pay_risk_hit"."version" IS '版本号';
COMMENT ON COLUMN "public"."pay_risk_hit"."deleted" IS '删除标志';
COMMENT ON COLUMN "public"."pay_risk_hit"."phase" IS '命中阶段: before_pay-事前拦截, after_pay-事后命中';
COMMENT ON COLUMN "public"."pay_risk_hit"."hit_type" IS '命中类型(与黑名单 type 一致: ip / open_id';
COMMENT ON COLUMN "public"."pay_risk_hit"."hit_value" IS '命中值快照IP 或 openId';
COMMENT ON COLUMN "public"."pay_risk_hit"."blacklist_id" IS '关联名单 ID可空';
COMMENT ON COLUMN "public"."pay_risk_hit"."mch_no" IS '商户号(业务字段,非租户行级隔离)';
COMMENT ON COLUMN "public"."pay_risk_hit"."app_id" IS '应用号';
COMMENT ON COLUMN "public"."pay_risk_hit"."trade_no" IS '平台交易号';
COMMENT ON COLUMN "public"."pay_risk_hit"."order_no" IS '容器单号';
COMMENT ON COLUMN "public"."pay_risk_hit"."biz_order_no" IS '商户业务单号';
COMMENT ON COLUMN "public"."pay_risk_hit"."trade_type" IS '交易类型: normal / gateway';
COMMENT ON COLUMN "public"."pay_risk_hit"."method" IS '支付方式';
COMMENT ON COLUMN "public"."pay_risk_hit"."product" IS '支付产品';
COMMENT ON COLUMN "public"."pay_risk_hit"."channel" IS '支付通道';
COMMENT ON COLUMN "public"."pay_risk_hit"."client_ip" IS '客户端 IP 快照';
COMMENT ON COLUMN "public"."pay_risk_hit"."openid" IS '下单 openId 快照';
COMMENT ON COLUMN "public"."pay_risk_hit"."buyer_id" IS '通道回写付款人标识buyerId / openId';
COMMENT ON COLUMN "public"."pay_risk_hit"."scene" IS '来源场景: api/gateway/code/manual/unknown';
COMMENT ON COLUMN "public"."pay_risk_hit"."handle_status" IS '处理状态: pending/ignored/added_blacklist/merchant_disabled/other';
COMMENT ON COLUMN "public"."pay_risk_hit"."handle_remark" IS '处理说明';
COMMENT ON COLUMN "public"."pay_risk_hit"."handle_user_id" IS '处理人';
COMMENT ON COLUMN "public"."pay_risk_hit"."handle_time" IS '处理时间';
COMMENT ON COLUMN "public"."pay_risk_hit"."remark" IS '备注';
COMMENT ON TABLE "public"."pay_risk_hit" IS '支付风险命中记录(事前拦截与事后命中,供运营预警与处置)';
-- ----------------------------
-- Primary Key & Indexes for pay_risk_hit
-- ----------------------------
ALTER TABLE "public"."pay_risk_hit" ADD CONSTRAINT "pay_risk_hit_pkey" PRIMARY KEY ("id");
CREATE INDEX "idx_pay_risk_hit_phase" ON "public"."pay_risk_hit" USING btree ("phase");
CREATE INDEX "idx_pay_risk_hit_hit_type_value" ON "public"."pay_risk_hit" USING btree ("hit_type", "hit_value");
CREATE INDEX "idx_pay_risk_hit_handle_status" ON "public"."pay_risk_hit" USING btree ("handle_status");
CREATE INDEX "idx_pay_risk_hit_trade_no" ON "public"."pay_risk_hit" USING btree ("trade_no");
CREATE INDEX "idx_pay_risk_hit_create_time" ON "public"."pay_risk_hit" USING btree ("create_time" DESC);

View File

@@ -1,16 +1,24 @@
package cn.daxpay.open.payment.common.util;
import cn.daxpay.open.payment.merchant.enums.ClientEnvEnum;
import cn.daxpay.open.platform.core.enums.pay.channel.PayMethodEnum;
import cn.hutool.core.util.StrUtil;
import java.util.Set;
/// # 支付方式是否需要买家 openId / userId
/// # 支付方式是否需要 / 能获取买家 openId / userId
///
/// 供码牌 H5 OAuth 门控、聚合 meta、下单前校验共用。
/// 与交互形态绑定(JSAPI/小程序需用户标识H5/主扫等一般不需要)**不**挂产品/Gateway 策略。
///
/// 换票时序:授权回跳页立即 code→openId支付只传 openId禁止支付时再换 code。
///
/// ## 两个维度的区分
/// - [needsOpenId]: 业务上是否**必须** openIdJSAPI/MINI 永远 true
/// - [canAcquireOpenId]: 技术上是否**能**拿到 openId视 clientEnv 而定)
///
/// 当存在 openId 黑名单时, 网关层可用 `canAcquireOpenId` 决定是否对原本免 openId 的方式
/// (主扫/H5) 触发静默 OAuth, 实现 openId 黑名单在网关内的全局拦截。
public final class PayMethodOpenIdSupport {
/// 需要买家标识的支付方式(与聚合历史白名单一致)
@@ -22,6 +30,24 @@ public final class PayMethodOpenIdSupport {
PayMethodEnum.DOUYIN_JSAPI.getCode()
);
/// 完全无 OAuth 时机的支付方式付款码被扫、APP 内 SDK、PC 网页、聚合码、卡组等)
private static final Set<String> METHODS_NO_OAUTH_AT_ALL = Set.of(
PayMethodEnum.WECHAT_BARCODE.getCode(),
PayMethodEnum.ALIPAY_BARCODE.getCode(),
PayMethodEnum.UNION_BARCODE.getCode(),
PayMethodEnum.WECHAT_APP.getCode(),
PayMethodEnum.ALIPAY_APP.getCode(),
PayMethodEnum.DOUYIN_APP.getCode(),
PayMethodEnum.ALIPAY_PC.getCode(),
PayMethodEnum.WECHAT_CASHIER.getCode(),
PayMethodEnum.AGGREGATE_PAY_QRCODE.getCode(),
PayMethodEnum.VISA_CARD_GATEWAY.getCode(),
PayMethodEnum.VISA_CARD_PRESENT.getCode(),
PayMethodEnum.MASTERCARD_CARD_GATEWAY.getCode(),
PayMethodEnum.MASTERCARD_CARD_PRESENT.getCode(),
PayMethodEnum.OTHER.getCode()
);
private PayMethodOpenIdSupport() {
}
@@ -48,4 +74,60 @@ public final class PayMethodOpenIdSupport {
return lower.contains("jsapi") || lower.contains("mini");
}
}
/// 该支付方式 + 客户端环境组合下, 是否可走 OAuth 静默获取 openId
///
/// 用于网关层判断: openId 黑名单存在时, 是否对当前方式触发强制 OAuth 拦截。
/// 与 [needsOpenId] 互补——前者回答"业务是否需要", 本方法回答"技术是否能拿到"。
///
/// 矩阵:
/// - JSAPI/MINI/QR/H5 在微信/支付宝/抖音内: ✅ 可静默 OAuth (snsapi_base / auth_base / silent_auth)
/// - 付款码/APP/PC/聚合码/卡组: ❌ 始终无 OAuth 时机
/// - union_pay: ❌ 一期无平台 OAuth
/// - browser(外部浏览器): ❌ 无法 OAuth 拿微信/支付宝 openId
///
/// @param methodCode 支付方式编码
/// @param clientEnv 客户端环境, null 视为不可
public static boolean canAcquireOpenId(String methodCode, ClientEnvEnum clientEnv) {
if (clientEnv == null) {
return false;
}
// 付款码 / APP / PC 等完全无 OAuth 时机
if (cannotAcquireOpenIdAtAll(methodCode)) {
return false;
}
// 客户端环境维度: 仅微信/支付宝/抖音内浏览器可静默授权
return switch (clientEnv) {
case WECHAT, ALIPAY, DOUYIN -> true;
// 一期无平台 OAuth, 暂不可
case UNION_PAY -> false;
// 外部浏览器无法 OAuth 拿微信/支付宝 openId
case BROWSER -> false;
};
}
/// 这些方式完全无 OAuth 时机付款码被扫、APP 内 SDK、PC 网页、聚合码、卡组)
private static boolean cannotAcquireOpenIdAtAll(String methodCode) {
if (StrUtil.isBlank(methodCode)) {
// 未知方式保守起见视为不可, 避免无效 OAuth 跳转
return true;
}
if (METHODS_NO_OAUTH_AT_ALL.contains(methodCode)) {
return true;
}
try {
PayMethodEnum method = PayMethodEnum.findByCode(methodCode);
return switch (method) {
case WECHAT_BARCODE, ALIPAY_BARCODE, UNION_BARCODE,
WECHAT_APP, ALIPAY_APP, DOUYIN_APP,
ALIPAY_PC, VISA_CARD_GATEWAY, VISA_CARD_PRESENT,
MASTERCARD_CARD_GATEWAY, MASTERCARD_CARD_PRESENT,
WECHAT_CASHIER, AGGREGATE_PAY_QRCODE, OTHER -> true;
default -> false;
};
} catch (Exception e) {
// 未知扩展 method: 保守视为不可
return true;
}
}
}

View File

@@ -7,8 +7,6 @@ package cn.daxpay.open.payment.strategy.risk;
///
/// **注意**:与 [cn.daxpay.open.payment.strategy.plugin.AbsPayPluginStrategy] 不同——
/// 后者为协议侧事后广播且吞异常;本接口异常须向上抛出以拒绝交易。
///
/// 本期支付主链路尚未调用本接口,仅预留契约与插件实现。
public interface PayRiskChecker {
/// 支付前检查。命中应抛业务异常(如 BizInfoException以拒绝下单。
@@ -17,4 +15,12 @@ public interface PayRiskChecker {
/// 支付成功后检查(主扫等事后 buyerId 补洞)。默认只记命中,不抛错。
default void checkAfterPay(PayRiskCheckContext ctx) {
}
/// 是否存在 openId 类型黑名单(供网关层决定是否触发强制 OAuth 取 openId
///
/// 默认 false插件未安装或当前无 openId 名单时,主扫/H5 等免 openId 方式
/// 不强制走 OAuth保持现有用户体验。仅当存在 openId 名单时才触发静默授权。
default boolean hasOpenIdBlacklist() {
return false;
}
}

View File

@@ -6,6 +6,7 @@ import cn.daxpay.open.payment.merchant.entity.gateway.GatewayAggregateConfig;
import cn.daxpay.open.payment.merchant.enums.ClientEnvEnum;
import cn.daxpay.open.payment.merchant.enums.ClientRuntimeEnum;
import cn.daxpay.open.payment.merchant.service.gateway.ClientEnvPayResolveService;
import cn.daxpay.open.payment.strategy.risk.PayRiskChecker;
import cn.daxpay.open.payment.trade.enums.GatewayPayTypeEnum;
import cn.daxpay.open.payment.trade.order.entity.GatewayPayOrder;
import cn.daxpay.open.payment.unipay.param.gateway.AggregateQrPayParam;
@@ -17,6 +18,7 @@ import cn.daxpay.open.platform.core.exception.BizInfoException;
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;
@@ -33,6 +35,8 @@ public class AggregatePayService {
private final ClientEnvPayResolveService clientEnvPayResolveService;
private final GatewayPayHandleService gatewayPayHandleService;
private final GatewayAggregateConfigManager aggregateConfigManager;
/// 风控检查器(可选 SPI用于判断是否存在 openId 黑名单, 决定是否触发强制 OAuth
private final ObjectProvider<PayRiskChecker> payRiskCheckerProvider;
/// H5 聚合元数据: autoLaunch / needOpenId(不下发敏感路由字段)
public AggregatePayMetaResult getMeta(String orderNo, String clientEnvCode, String runtimeCode) {
@@ -50,8 +54,8 @@ public class AggregatePayService {
return new AggregatePayMetaResult()
.setAutoLaunch(Boolean.TRUE.equals(config.getAutoLaunch()))
// 与码牌同源: 按 method 判定是否需 OAuth 取 openId
.setNeedOpenId(PayMethodOpenIdSupport.needsOpenId(resolved.method()));
// openId 触发判定: 业务必需(JSAPI/MINI) 或 存在 openId 黑名单且当前环境可 OAuth
.setNeedOpenId(this.resolveNeedOpenId(resolved.method(), clientEnv));
}
/// 聚合扫码发起支付
@@ -71,4 +75,20 @@ public class AggregatePayService {
resolved.channelMchNo(), resolved.capability(),
param.getOpenId(), clientEnv.getCode(), param.getDevice(), clientIp);
}
/// openId 触发判定
///
/// 1. JSAPI/MINI 类方式: 业务必需, 永远 true与历史行为一致
/// 2. 主扫/H5 等免 openId 方式: 仅当存在 openId 黑名单且当前 clientEnv 可 OAuth 时 true,
/// 实现 openId 黑名单在聚合网关内的全局拦截
private boolean resolveNeedOpenId(String method, ClientEnvEnum clientEnv) {
if (PayMethodOpenIdSupport.needsOpenId(method)) {
return true;
}
PayRiskChecker checker = payRiskCheckerProvider.getIfAvailable();
if (checker == null || !checker.hasOpenIdBlacklist()) {
return false;
}
return PayMethodOpenIdSupport.canAcquireOpenId(method, clientEnv);
}
}

View File

@@ -1,10 +1,12 @@
package cn.daxpay.open.payment.trade.runtime.service.pay.gateway;
import cn.daxpay.open.payment.common.util.PayMethodOpenIdSupport;
import cn.daxpay.open.payment.merchant.dao.gateway.GatewayCashierItemManager;
import cn.daxpay.open.payment.merchant.entity.gateway.GatewayCashierItem;
import cn.daxpay.open.payment.merchant.enums.CashierItemResolveModeEnum;
import cn.daxpay.open.payment.merchant.enums.ClientEnvEnum;
import cn.daxpay.open.payment.merchant.enums.GatewayCashierTypeEnum;
import cn.daxpay.open.payment.strategy.risk.PayRiskChecker;
import cn.daxpay.open.payment.trade.enums.GatewayPayTypeEnum;
import cn.daxpay.open.payment.trade.order.entity.GatewayPayOrder;
import cn.daxpay.open.payment.unipay.param.gateway.CashierPayParam;
@@ -17,6 +19,7 @@ import cn.daxpay.open.platform.core.exception.DataNotExistException;
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.List;
@@ -43,6 +46,8 @@ public class CashierPayService {
private final GatewayPayAssistService gatewayPayAssistService;
private final GatewayPayHandleService gatewayPayHandleService;
private final GatewayCashierItemManager gatewayCashierItemManager;
/// 风控检查器(可选 SPI用于判断是否存在 openId 黑名单, 决定是否触发强制 OAuth
private final ObjectProvider<PayRiskChecker> payRiskCheckerProvider;
/// 公开支付项列表(落地页展示)
public List<CashierItemPublicResult> listPublicItems(String orderNo, String cashierType, String clientEnv) {
@@ -52,9 +57,10 @@ public class CashierPayService {
}
GatewayCashierTypeEnum typeEnum = GatewayCashierTypeEnum.findByCode(cashierType);
String bucketClientEnv = normalizeClientEnvForBucket(typeEnum, clientEnv);
ClientEnvEnum envEnum = StrUtil.isBlank(bucketClientEnv) ? null : ClientEnvEnum.findByCode(bucketClientEnv);
return gatewayCashierItemManager.listByAppAndBucket(order.getAppId(), typeEnum.getCode(), bucketClientEnv)
.stream()
.map(this::toPublicResult)
.map(item -> this.toPublicResult(item, envEnum))
.toList();
}
@@ -148,12 +154,29 @@ public class CashierPayService {
return env.getCode();
}
private CashierItemPublicResult toPublicResult(GatewayCashierItem item) {
private CashierItemPublicResult toPublicResult(GatewayCashierItem item, ClientEnvEnum clientEnv) {
return new CashierItemPublicResult()
.setId(item.getId())
.setName(item.getName())
.setIcon(item.getIcon())
.setRecommend(item.getRecommend())
.setSortNo(item.getSortNo());
.setSortNo(item.getSortNo())
// openId 触发判定: JSAPI/MINI 业务必需, 或存在 openId 黑名单且环境可 OAuth
.setNeedOpenId(this.resolveItemNeedOpenId(item.getMethod(), clientEnv));
}
/// openId 触发判定(与聚合/码牌同源逻辑)
///
/// 1. JSAPI/MINI 类方式: 业务必需, 永远 true
/// 2. 主扫/H5 等免 openId 方式: 仅当存在 openId 黑名单且当前 clientEnv 可 OAuth 时 true
private boolean resolveItemNeedOpenId(String method, ClientEnvEnum clientEnv) {
if (PayMethodOpenIdSupport.needsOpenId(method)) {
return true;
}
PayRiskChecker checker = payRiskCheckerProvider.getIfAvailable();
if (checker == null || !checker.hasOpenIdBlacklist()) {
return false;
}
return PayMethodOpenIdSupport.canAcquireOpenId(method, clientEnv);
}
}

View File

@@ -8,6 +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.PayUniHandleService;
import cn.daxpay.open.payment.trade.order.dao.GatewayPayOrderManager;
@@ -16,15 +18,18 @@ import cn.daxpay.open.payment.trade.order.entity.GatewayPayOrder;
import cn.daxpay.open.payment.trade.order.entity.PayTrade;
import cn.daxpay.open.payment.unipay.param.trade.pay.NormalPayParam;
import cn.daxpay.open.payment.unipay.result.trade.pay.NormalPayResult;
import cn.daxpay.open.platform.common.redis.lock.LockExecutor;
import cn.daxpay.open.platform.core.code.CommonErrorCode;
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.platform.common.redis.lock.LockExecutor;
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;
@@ -45,6 +50,9 @@ 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;
/// 自注入,保证 [GatewayPayHandleService#createTrade] / [GatewayPayHandleService#paySuccess] 走 Spring 事务代理
private final GatewayPayHandleService self;
@@ -56,6 +64,8 @@ public class GatewayPayHandleService {
PayUniHandleService payUniHandleService,
GatewayPayAssistService gatewayPayAssistService,
LockExecutor lockExecutor,
ObjectProvider<PayRiskChecker> payRiskCheckerProvider,
PlatformSecurityConfigService platformSecurityConfigService,
@Lazy GatewayPayHandleService self) {
this.gatewayPayOrderManager = gatewayPayOrderManager;
this.payTradeManager = payTradeManager;
@@ -64,6 +74,8 @@ public class GatewayPayHandleService {
this.payUniHandleService = payUniHandleService;
this.gatewayPayAssistService = gatewayPayAssistService;
this.lockExecutor = lockExecutor;
this.payRiskCheckerProvider = payRiskCheckerProvider;
this.platformSecurityConfigService = platformSecurityConfigService;
this.self = self;
}
@@ -112,6 +124,8 @@ 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);
payStrategy.doBeforePay(context);
@@ -139,7 +153,10 @@ public class GatewayPayHandleService {
}
throw new PayFailureException(errMsg);
}
return self.paySuccess(current, existing, result);
NormalPayResult payResult = self.paySuccess(current, existing, result);
// 风控事后补录: 用通道回写 buyerId 补充命中检查, 仅记录不阻断资金态
this.runRiskAfterPay(current, payParam, existing, result);
return payResult;
},
() -> new BizInfoException(CommonErrorCode.VALIDATE_PARAMETERS_ERROR, "pay.error.pay.processing")
);
@@ -259,4 +276,71 @@ 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,7 +6,14 @@ 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;
@@ -23,6 +30,7 @@ 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;
@@ -41,6 +49,9 @@ 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;
/// 自注入,保证 [NormalPayService#paySuccess] 走 Spring 事务代理
private final NormalPayService self;
@@ -51,6 +62,8 @@ public class NormalPayService {
PayRouteService payRouteService,
MerchantContextLoader merchantContextLoader,
SensitiveWordCheckService sensitiveWordCheckService,
ObjectProvider<PayRiskChecker> payRiskCheckerProvider,
PlatformSecurityConfigService platformSecurityConfigService,
@Lazy NormalPayService self) {
this.payAssistService = payAssistService;
this.payUniHandleService = payUniHandleService;
@@ -58,6 +71,8 @@ public class NormalPayService {
this.payRouteService = payRouteService;
this.merchantContextLoader = merchantContextLoader;
this.sensitiveWordCheckService = sensitiveWordCheckService;
this.payRiskCheckerProvider = payRiskCheckerProvider;
this.platformSecurityConfigService = platformSecurityConfigService;
this.self = self;
}
@@ -107,6 +122,8 @@ 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);
// 支付前处理: 校验与通道配置组装(只依赖请求参数), 失败直接抛出不持久化(订单尚未创建)
var context = new PayStrategyContext().setPayParam(payParam);
@@ -136,7 +153,10 @@ public class NormalPayService {
payUniHandleService.payFail(trade, errMsg);
throw e;
}
return self.paySuccess(trade, result);
NormalPayResult payResult = self.paySuccess(trade, result);
// 风控事后补录: 用通道回写 buyerId 补充命中检查, 仅记录不阻断资金态
this.runRiskAfterPay(payParam, trade, result);
return payResult;
}
/// 付款码 method 回填: 仅 authCode 时识别; 已传分钱包条码 method 时校验前缀一致
@@ -165,4 +185,81 @@ public class NormalPayService {
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) {
String source = payParam.getSource();
if (StrUtil.isNotBlank(source) && TradeSourceEnum.CASHIER_CODE.getCode().equals(source)) {
return "code";
}
return "api";
}
}

View File

@@ -7,6 +7,7 @@ import lombok.experimental.Accessors;
/// # 收银台支付项公开结果
///
/// 落地页列表展示用, 不下发 method/channelMchNo/capability/resolveMode 等路由敏感字段。
/// `needOpenId` 由后端按 item.method + clientEnv 综合判定, 控制前端是否跳 OAuth。
@Data
@Accessors(chain = true)
@Schema(title = "收银台支付项(公开)")
@@ -26,4 +27,7 @@ public class CashierItemPublicResult {
@Schema(description = "排序号")
private Integer sortNo;
@Schema(description = "是否需要先走 OAuth 获取 openId前端据此跳授权")
private Boolean needOpenId;
}

View File

@@ -14,6 +14,7 @@ import cn.daxpay.open.payment.merchant.enums.ClientRuntimeEnum;
import cn.daxpay.open.payment.merchant.enums.CodePayFormEnum;
import cn.daxpay.open.payment.merchant.service.gateway.CodePayResolveService;
import cn.daxpay.open.payment.route.service.runtime.PayRouteService;
import cn.daxpay.open.payment.strategy.risk.PayRiskChecker;
import cn.daxpay.open.payment.trade.order.dao.NormalPayOrderManager;
import cn.daxpay.open.payment.trade.order.entity.NormalPayOrder;
import cn.daxpay.open.payment.trade.runtime.service.pay.normal.NormalPayService;
@@ -39,6 +40,7 @@ import cn.daxpay.open.platform.core.util.TradeNoGenerateUtil;
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;
/// # 码牌支付编排(公开/H5/小程序侧)
@@ -60,6 +62,8 @@ public class CodePayAssistService {
private final PayRouteService payRouteService;
private final ChannelAuthFacade channelAuthFacade;
private final NormalPayOrderManager normalPayOrderManager;
/// 风控检查器(可选 SPI用于判断是否存在 openId 黑名单, 决定是否触发强制 OAuth
private final ObjectProvider<PayRiskChecker> payRiskCheckerProvider;
/// 根据码牌编码查询支付信息(公开接口, 脱敏返回)
///
@@ -80,9 +84,10 @@ public class CodePayAssistService {
.setProgramType(entity.getProgramType());
if (StrUtil.isNotBlank(clientEnv)) {
try {
ClientEnvEnum env = ClientEnvEnum.findByCode(clientEnv);
String method = this.resolveMethod(entity, clientEnv);
// 严格按 method 判定;非 JSAPI/MINI 不强制 OAuth
result.setNeedOpenId(PayMethodOpenIdSupport.needsOpenId(method));
// openId 触发判定: 业务必需(JSAPI/MINI) 或 存在 openId 黑名单且当前环境可 OAuth
result.setNeedOpenId(this.resolveNeedOpenId(method, env));
} catch (Exception e) {
// 策略未配置等: 不误强制授权(false);支付时会因策略失败再报错
log.warn("码牌 needOpenId 解析失败 code={} clientEnv={}: {}", code, clientEnv, e.getMessage());
@@ -154,8 +159,8 @@ public class CodePayAssistService {
}
CodePayFormEnum payForm = CodePayFormEnum.fromProgramType(entity.getProgramType());
var resolved = codePayResolveService.resolveRequired(mchApp.getAppId(), clientEnv, payForm);
if (!PayMethodOpenIdSupport.needsOpenId(resolved.method())) {
// 当前支付方式无需授权(H5/主扫等)
if (!PayMethodOpenIdSupport.canAcquireOpenId(resolved.method(), clientEnv)) {
// 当前支付方式/环境无法走 OAuth付款码/APP/PC/外部浏览器/union_pay 一期)
throw new OperationFailException(CommonCode.FAIL_CODE, "error.device.qrcode.authNotRequired");
}
@@ -260,6 +265,22 @@ public class CodePayAssistService {
return codePayResolveService.resolveRequired(mchApp.getAppId(), clientEnv, payForm).method();
}
/// openId 触发判定
///
/// 1. JSAPI/MINI 类方式: 业务必需, 永远 true与历史行为一致
/// 2. 主扫/H5 等免 openId 方式: 仅当存在 openId 黑名单且当前 clientEnv 可 OAuth 时 true,
/// 实现 openId 黑名单在码牌场景的全局拦截
private boolean resolveNeedOpenId(String method, ClientEnvEnum clientEnv) {
if (PayMethodOpenIdSupport.needsOpenId(method)) {
return true;
}
PayRiskChecker checker = payRiskCheckerProvider.getIfAvailable();
if (checker == null || !checker.hasOpenIdBlacklist()) {
return false;
}
return PayMethodOpenIdSupport.canAcquireOpenId(method, clientEnv);
}
/// 分端页 returnPath: /h/wechat|{alipay}/:code?authed=1
private String buildReturnPath(ClientEnvEnum clientEnv, String code) {
String segment = switch (clientEnv) {

View File

@@ -228,10 +228,14 @@ public class PlatformSecurityConfigService {
systemConfigService.updateConfig(PlatformConfigTypeEnum.IAM_REPLAY_PROTECT, data);
}
// ========== 支付安全配置(风控开关,支付主链路按此决定是否调用风控检查器 ==========
// ========== 支付安全配置(风控开关,支付主链路高频读取,走多级缓存 ==========
/// 获取支付安全配置实体
public PlatformPaySecurityConfig getPaySecurityConfig() {
/// 支付安全配置缓存名L1 Caffeine + L2 Redis
public static final String PAY_SECURITY_CACHE_NAME = "system:pay-security-config";
/// 缓存配置 JSONL2 安全类型: Stringkey 带 :json 避免命中旧版 POJO 缓存)
@Cacheable(value = PAY_SECURITY_CACHE_NAME, key = "'current:json'")
public String getPaySecurityConfigJson() {
PlatformPaySecurityConfig config = systemConfigService.getOrCreateConfig(
PlatformConfigTypeEnum.PAY_SECURITY,
PlatformPaySecurityConfig.class,
@@ -239,7 +243,18 @@ public class PlatformSecurityConfigService {
if (config == null) {
config = defaultPaySecurityConfig();
}
return config;
return JacksonUtil.toJson(config);
}
/// 获取支付安全配置实体(从缓存 JSON 显式还原类型)
public PlatformPaySecurityConfig getPaySecurityConfig() {
// 走 self 代理以命中缓存
String json = self.getPaySecurityConfigJson();
if (StrUtil.isBlank(json)) {
return defaultPaySecurityConfig();
}
PlatformPaySecurityConfig config = JacksonUtil.toBean(json, PlatformPaySecurityConfig.class);
return config == null ? defaultPaySecurityConfig() : config;
}
/// 支付安全配置默认值: 默认开启风控、命中阻断、事后补录
@@ -252,12 +267,16 @@ public class PlatformSecurityConfigService {
/// 获取支付安全配置(结果)
public PlatformPaySecurityConfigResult findPaySecurityConfig() {
return PlatformSecurityConfigConvert.CONVERT.toPaySecurityResult(this.getPaySecurityConfig());
return PlatformSecurityConfigConvert.CONVERT.toPaySecurityResult(self.getPaySecurityConfig());
}
/// 更新支付安全配置
@CacheEvict(value = PAY_SECURITY_CACHE_NAME, allEntries = true)
public void updatePaySecurityConfig(PlatformPaySecurityConfigParam param) {
PlatformPaySecurityConfig data = this.getPaySecurityConfig();
PlatformPaySecurityConfig data = systemConfigService.getOrCreateConfig(
PlatformConfigTypeEnum.PAY_SECURITY,
PlatformPaySecurityConfig.class,
defaultPaySecurityConfig());
PlatformSecurityConfigConvert.CONVERT.copy(param, data);
systemConfigService.updateConfig(PlatformConfigTypeEnum.PAY_SECURITY, data);
}

View File

@@ -6,6 +6,7 @@ import cn.daxpay.open.platform.common.mybatisplus.util.MpUtil;
import cn.daxpay.open.platform.core.rest.param.PageParam;
import cn.daxpay.open.plugin.risk.entity.PayBlacklist;
import cn.daxpay.open.plugin.risk.enums.PayBlacklistStatusEnum;
import cn.daxpay.open.plugin.risk.enums.PayBlacklistTypeEnum;
import cn.daxpay.open.plugin.risk.param.PayBlacklistQuery;
import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
@@ -83,6 +84,19 @@ public class PayBlacklistManager extends BaseManager<PayBlacklistMapper, PayBlac
.findFirst();
}
/// 是否存在有效的 openId 类型黑名单enable 且未过期)
///
/// 供网关层智能触发 OAuth 取 openId仅当存在 openId 名单时,
/// 才对主扫/H5 等免 openId 方式走静默授权, 避免无黑名单时的无效开销
public boolean hasActiveOpenIdBlacklist() {
OffsetDateTime now = OffsetDateTime.now(ZoneOffset.UTC);
return lambdaQuery()
.eq(PayBlacklist::getType, PayBlacklistTypeEnum.OPEN_ID.getCode())
.eq(PayBlacklist::getStatus, PayBlacklistStatusEnum.ENABLE.getCode())
.and(w -> w.isNull(PayBlacklist::getExpireTime).or().gt(PayBlacklist::getExpireTime, now))
.exists();
}
private static boolean matchNullable(String rule, String actual) {
if (StrUtil.isBlank(rule)) {
return StrUtil.isBlank(actual);

View File

@@ -92,6 +92,11 @@ public class PayBlacklistService {
return findActive(type, value, channel, channelAppId).isPresent();
}
/// 是否存在有效的 openId 类型黑名单(供网关层智能触发 OAuth
public boolean hasActiveOpenIdBlacklist() {
return payBlacklistManager.hasActiveOpenIdBlacklist();
}
/// 查找有效名单行
public Optional<PayBlacklist> findActive(String type, String value, String channel, String channelAppId) {
return payBlacklistManager.findActiveHit(type, value, channel, channelAppId);

View File

@@ -10,15 +10,19 @@ import cn.daxpay.open.plugin.risk.enums.PayRiskHitPhaseEnum;
import cn.daxpay.open.plugin.risk.service.PayBlacklistService;
import cn.daxpay.open.plugin.risk.service.PayRiskHitService;
import cn.hutool.core.util.StrUtil;
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.Optional;
import java.util.concurrent.TimeUnit;
/// # 默认支付风控检查器
///
/// 实现 [PayRiskChecker] SPI。本期支付主链路尚未调用,便于联调与后续接入。
/// 实现 [PayRiskChecker] SPI,由 daxpay-plugin-risk 自动配置注入。
/// 支付前命中黑名单抛异常拒绝下单;支付后用通道回写 buyerId 补洞(仅记录不阻断)。
@Slf4j
@Service
@RequiredArgsConstructor
@@ -27,17 +31,35 @@ public class DefaultPayRiskChecker implements PayRiskChecker {
private final PayBlacklistService payBlacklistService;
private final PayRiskHitService payRiskHitService;
/// openId 黑名单存在性缓存(短 TTL 30s
///
/// 仅供网关层判断是否触发强制 OAuth 取 openId, 非关键路径,
/// 30s 延迟可接受(黑名单 CRUD 不会立刻反映到 OAuth 触发判定)
private final Cache<String, Boolean> openIdBlacklistCache = Caffeine.newBuilder()
.expireAfterWrite(30, TimeUnit.SECONDS)
.maximumSize(1)
.build();
private static final String OPEN_ID_BLACKLIST_CACHE_KEY = "hasOpenId";
@Override
public void checkBeforePay(PayRiskCheckContext ctx) {
if (ctx == null) {
return;
}
ctx.setPhase(PayRiskHitPhaseEnum.BEFORE_PAY.getCode());
// IP
// IP 名单(全局生效)
rejectIfBlocked(ctx, PayBlacklistTypeEnum.IP.getCode(), ctx.getClientIp(), null, null, true);
// openId
rejectIfBlocked(ctx, PayBlacklistTypeEnum.OPEN_ID.getCode(), ctx.getOpenId(),
// openId 名单(按通道精细匹配)
boolean openIdBlocked = rejectIfBlocked(ctx, PayBlacklistTypeEnum.OPEN_ID.getCode(), ctx.getOpenId(),
ctx.getChannel(), ctx.getChannelAppId(), true);
// openId 缺失降级: ctx.openId 为空且本次未触发 openId 拦截时, 打 WARN 供运营识别
// 付款码 / APP / 外部浏览器 H5 等场景无 OAuth 时机, 依赖 IP + 事后 buyerId 补录
if (!openIdBlocked && StrUtil.isBlank(ctx.getOpenId())) {
log.warn("支付前 openId 缺失, openId 黑名单降级为仅 IP 校验 + 事后补录: "
+ "tradeType={}, method={}, mchNo={}, clientIp={}",
ctx.getTradeType(), ctx.getMethod(), ctx.getMchNo(), ctx.getClientIp());
}
}
@Override
@@ -57,14 +79,26 @@ public class DefaultPayRiskChecker implements PayRiskChecker {
}
}
private void rejectIfBlocked(PayRiskCheckContext ctx, String type, String value,
String channel, String channelAppId, boolean throwOnHit) {
@Override
public boolean hasOpenIdBlacklist() {
Boolean cached = openIdBlacklistCache.getIfPresent(OPEN_ID_BLACKLIST_CACHE_KEY);
if (cached != null) {
return cached;
}
boolean exists = payBlacklistService.hasActiveOpenIdBlacklist();
openIdBlacklistCache.put(OPEN_ID_BLACKLIST_CACHE_KEY, exists);
return exists;
}
/// 返回是否命中(用于外层判断是否需要打降级日志)
private boolean rejectIfBlocked(PayRiskCheckContext ctx, String type, String value,
String channel, String channelAppId, boolean throwOnHit) {
if (StrUtil.isBlank(value)) {
return;
return false;
}
Optional<PayBlacklist> hit = payBlacklistService.findActive(type, value, channel, channelAppId);
if (hit.isEmpty()) {
return;
return false;
}
PayBlacklist bl = hit.get();
try {
@@ -76,5 +110,6 @@ public class DefaultPayRiskChecker implements PayRiskChecker {
// 交易被限制(模糊文案,防探测)
throw new BizInfoException(PayErrorCode.OPERATION_FAIL, "pay.error.risk.blacklist");
}
return true;
}
}