diff --git a/_config/sql/update-tables.sql b/_config/sql/update-tables.sql index d3855659b..0c64b071a 100644 --- a/_config/sql/update-tables.sql +++ b/_config/sql/update-tables.sql @@ -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); diff --git a/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/common/util/PayMethodOpenIdSupport.java b/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/common/util/PayMethodOpenIdSupport.java index a3d178511..36fca9609 100644 --- a/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/common/util/PayMethodOpenIdSupport.java +++ b/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/common/util/PayMethodOpenIdSupport.java @@ -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 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; + } + } } diff --git a/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/strategy/risk/PayRiskChecker.java b/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/strategy/risk/PayRiskChecker.java index 61b3cd61b..7fc63c0a5 100644 --- a/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/strategy/risk/PayRiskChecker.java +++ b/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/strategy/risk/PayRiskChecker.java @@ -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; + } } diff --git a/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/trade/runtime/service/pay/gateway/AggregatePayService.java b/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/trade/runtime/service/pay/gateway/AggregatePayService.java index cf0f9d9ca..63cc2a01d 100644 --- a/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/trade/runtime/service/pay/gateway/AggregatePayService.java +++ b/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/trade/runtime/service/pay/gateway/AggregatePayService.java @@ -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 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); + } } diff --git a/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/trade/runtime/service/pay/gateway/CashierPayService.java b/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/trade/runtime/service/pay/gateway/CashierPayService.java index 9c9d0badb..290745e0b 100644 --- a/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/trade/runtime/service/pay/gateway/CashierPayService.java +++ b/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/trade/runtime/service/pay/gateway/CashierPayService.java @@ -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 payRiskCheckerProvider; /// 公开支付项列表(落地页展示) public List 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); } } diff --git a/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/trade/runtime/service/pay/gateway/GatewayPayHandleService.java b/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/trade/runtime/service/pay/gateway/GatewayPayHandleService.java index e649b3bdb..f34f8239d 100644 --- a/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/trade/runtime/service/pay/gateway/GatewayPayHandleService.java +++ b/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/trade/runtime/service/pay/gateway/GatewayPayHandleService.java @@ -8,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 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 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; + } + } diff --git a/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/trade/runtime/service/pay/normal/NormalPayService.java b/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/trade/runtime/service/pay/normal/NormalPayService.java index 807c2cfd7..98a51491d 100644 --- a/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/trade/runtime/service/pay/normal/NormalPayService.java +++ b/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/trade/runtime/service/pay/normal/NormalPayService.java @@ -6,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 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 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"; + } } diff --git a/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/unipay/result/gateway/CashierItemPublicResult.java b/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/unipay/result/gateway/CashierItemPublicResult.java index 141686ab4..e08219d35 100644 --- a/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/unipay/result/gateway/CashierItemPublicResult.java +++ b/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/unipay/result/gateway/CashierItemPublicResult.java @@ -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; } diff --git a/daxpay-payment/daxpay-payment-unipay/src/main/java/cn/daxpay/open/payment/unipay/client/service/CodePayAssistService.java b/daxpay-payment/daxpay-payment-unipay/src/main/java/cn/daxpay/open/payment/unipay/client/service/CodePayAssistService.java index 5c6d941c3..0afed00de 100644 --- a/daxpay-payment/daxpay-payment-unipay/src/main/java/cn/daxpay/open/payment/unipay/client/service/CodePayAssistService.java +++ b/daxpay-payment/daxpay-payment-unipay/src/main/java/cn/daxpay/open/payment/unipay/client/service/CodePayAssistService.java @@ -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 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) { diff --git a/daxpay-platform/daxpay-platform-service/service-system/src/main/java/cn/daxpay/open/platform/system/service/config/security/PlatformSecurityConfigService.java b/daxpay-platform/daxpay-platform-service/service-system/src/main/java/cn/daxpay/open/platform/system/service/config/security/PlatformSecurityConfigService.java index e75c9f783..a1a2aa945 100644 --- a/daxpay-platform/daxpay-platform-service/service-system/src/main/java/cn/daxpay/open/platform/system/service/config/security/PlatformSecurityConfigService.java +++ b/daxpay-platform/daxpay-platform-service/service-system/src/main/java/cn/daxpay/open/platform/system/service/config/security/PlatformSecurityConfigService.java @@ -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); } diff --git a/daxpay-plugin/daxpay-plugin-risk/src/main/java/cn/daxpay/open/plugin/risk/dao/PayBlacklistManager.java b/daxpay-plugin/daxpay-plugin-risk/src/main/java/cn/daxpay/open/plugin/risk/dao/PayBlacklistManager.java index 38ef4cf6b..1b54b9841 100644 --- a/daxpay-plugin/daxpay-plugin-risk/src/main/java/cn/daxpay/open/plugin/risk/dao/PayBlacklistManager.java +++ b/daxpay-plugin/daxpay-plugin-risk/src/main/java/cn/daxpay/open/plugin/risk/dao/PayBlacklistManager.java @@ -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 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); diff --git a/daxpay-plugin/daxpay-plugin-risk/src/main/java/cn/daxpay/open/plugin/risk/service/PayBlacklistService.java b/daxpay-plugin/daxpay-plugin-risk/src/main/java/cn/daxpay/open/plugin/risk/service/PayBlacklistService.java index 486deb945..eff20ef74 100644 --- a/daxpay-plugin/daxpay-plugin-risk/src/main/java/cn/daxpay/open/plugin/risk/service/PayBlacklistService.java +++ b/daxpay-plugin/daxpay-plugin-risk/src/main/java/cn/daxpay/open/plugin/risk/service/PayBlacklistService.java @@ -92,6 +92,11 @@ public class PayBlacklistService { return findActive(type, value, channel, channelAppId).isPresent(); } + /// 是否存在有效的 openId 类型黑名单(供网关层智能触发 OAuth) + public boolean hasActiveOpenIdBlacklist() { + return payBlacklistManager.hasActiveOpenIdBlacklist(); + } + /// 查找有效名单行 public Optional findActive(String type, String value, String channel, String channelAppId) { return payBlacklistManager.findActiveHit(type, value, channel, channelAppId); diff --git a/daxpay-plugin/daxpay-plugin-risk/src/main/java/cn/daxpay/open/plugin/risk/strategy/DefaultPayRiskChecker.java b/daxpay-plugin/daxpay-plugin-risk/src/main/java/cn/daxpay/open/plugin/risk/strategy/DefaultPayRiskChecker.java index b9446b860..c446c29d4 100644 --- a/daxpay-plugin/daxpay-plugin-risk/src/main/java/cn/daxpay/open/plugin/risk/strategy/DefaultPayRiskChecker.java +++ b/daxpay-plugin/daxpay-plugin-risk/src/main/java/cn/daxpay/open/plugin/risk/strategy/DefaultPayRiskChecker.java @@ -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 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 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; } }