mirror of
https://gitee.com/dromara/dax-pay
synced 2026-08-10 06:46:04 +08:00
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:
@@ -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/alipay;IP 可空,空表示全局)';
|
||||
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);
|
||||
|
||||
@@ -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]: 业务上是否**必须** openId(JSAPI/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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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";
|
||||
|
||||
/// 缓存配置 JSON(L2 安全类型: String;key 带 :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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user