From 600b567759e2345abef8914feccc00b064c8b099 Mon Sep 17 00:00:00 2001 From: daxpay Date: Thu, 6 Aug 2026 10:37:33 +0800 Subject: [PATCH] =?UTF-8?q?feat(risk):=20=E5=AE=9E=E7=8E=B0=E9=97=A8?= =?UTF-8?q?=E5=BA=97=E5=B8=82=E7=BA=A7=E5=9C=B0=E7=90=86=E5=9B=B4=E6=A0=8F?= =?UTF-8?q?(IP=20=E5=9F=8E=E5=B8=82=E7=BA=A7=E5=88=A4=E5=AE=9A)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DefaultPayRiskChecker 新增 checkStoreGeoFence: IP 归属城市与门店城市比对, 不一致按 blockOnHit 处理 - PayRiskAssistService 提取 terminal.storeNo 并按 regionCode 反查门店城市(直辖市特殊处理) - GatewayPayHandleService.buildPayParam 补 terminal.storeNo, 修复网关链路风控拿不到门店号缺口 - pay_risk_hit 增加 client_city/store_city/store_no 快照字段(table.sql) - 门店无地址时围栏 fail-open + 告警 --- _config/sql/table.sql | 12 +- .../strategy/risk/PayRiskCheckContext.java | 33 ++++ .../pay/common/PayRiskAssistService.java | 59 ++++++- .../pay/gateway/GatewayPayHandleService.java | 5 + .../open/plugin/risk/entity/PayRiskHit.java | 9 ++ .../plugin/risk/result/PayRiskHitResult.java | 9 ++ .../risk/service/PayRiskHitService.java | 6 +- .../risk/strategy/DefaultPayRiskChecker.java | 148 ++++++++++++++++-- 8 files changed, 263 insertions(+), 18 deletions(-) diff --git a/_config/sql/table.sql b/_config/sql/table.sql index d8cd6bdaa..350ed5ab7 100644 --- a/_config/sql/table.sql +++ b/_config/sql/table.sql @@ -1738,6 +1738,7 @@ CREATE TABLE "public"."mch_info" ( "admin_user_id" int8, "status" varchar(32) COLLATE "pg_catalog"."default", "subject_type" varchar(32) COLLATE "pg_catalog"."default", + "geo_fence_enabled" bool NOT NULL DEFAULT false, "deleted" bool NOT NULL DEFAULT false, "creator" int8, "create_time" timestamp(6), @@ -1753,6 +1754,7 @@ COMMENT ON COLUMN "public"."mch_info"."mch_short_name" IS '商户简称'; COMMENT ON COLUMN "public"."mch_info"."admin_user_id" IS '关联管理员用户ID'; COMMENT ON COLUMN "public"."mch_info"."status" IS '状态'; COMMENT ON COLUMN "public"."mch_info"."subject_type" IS '主体类型'; +COMMENT ON COLUMN "public"."mch_info"."geo_fence_enabled" IS '是否启用地理围栏'; COMMENT ON COLUMN "public"."mch_info"."deleted" IS '删除标志'; COMMENT ON COLUMN "public"."mch_info"."creator" IS '创建者ID'; COMMENT ON COLUMN "public"."mch_info"."create_time" IS '创建时间'; @@ -3136,7 +3138,10 @@ CREATE TABLE "public"."pay_risk_hit" ( "openid" varchar(128) COLLATE "pg_catalog"."default", "buyer_id" varchar(128) COLLATE "pg_catalog"."default", "scene" varchar(16) COLLATE "pg_catalog"."default" NOT NULL DEFAULT 'unknown'::character varying, - "remark" varchar(255) COLLATE "pg_catalog"."default" + "remark" varchar(255) COLLATE "pg_catalog"."default", + "client_city" varchar(64) COLLATE "pg_catalog"."default", + "store_city" varchar(64) COLLATE "pg_catalog"."default", + "store_no" varchar(64) COLLATE "pg_catalog"."default" ) ; COMMENT ON COLUMN "public"."pay_risk_hit"."id" IS '主键'; @@ -3147,7 +3152,7 @@ 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_type" IS '命中类型(与黑名单 type 一致: ip / alipay_user / wechat_openid / overseas_ip / province; 围栏命中: geo_fence)'; 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 '商户号(业务字段,非租户行级隔离)'; @@ -3164,6 +3169,9 @@ 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"."remark" IS '备注'; +COMMENT ON COLUMN "public"."pay_risk_hit"."client_city" IS '客户端 IP 归属城市(ip2region 解析快照)'; +COMMENT ON COLUMN "public"."pay_risk_hit"."store_city" IS '门店所在城市(围栏命中快照)'; +COMMENT ON COLUMN "public"."pay_risk_hit"."store_no" IS '门店号(围栏命中快照)'; COMMENT ON TABLE "public"."pay_risk_hit" IS '支付风险命中记录(事前拦截与事后命中,供运营预警与处置)'; -- ---------------------------- diff --git a/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/strategy/risk/PayRiskCheckContext.java b/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/strategy/risk/PayRiskCheckContext.java index 10f2b32cf..c617f7ce7 100644 --- a/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/strategy/risk/PayRiskCheckContext.java +++ b/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/strategy/risk/PayRiskCheckContext.java @@ -58,9 +58,42 @@ public class PayRiskCheckContext { /// 事前命中是否阻断下单(null/true=阻断;false=仅落命中记录) private Boolean blockOnHit; + /// 是否启用黑名单拦截(null/false=不检查;true=执行 IP/用户标识黑名单检查) + /// + /// 第一层黑名单开关快照, 由 [cn.daxpay.open.payment.trade.runtime.service.pay.common.PayRiskAssistService] + /// 读取平台配置后注入。 + private Boolean blacklistEnabled; + /// 是否拦截海外 IP(null/false=不拦截;true=拦截) /// /// 地域策略开关快照, 由 [cn.daxpay.open.payment.trade.runtime.service.pay.common.PayRiskAssistService] /// 读取平台配置后注入, 供检查器按 IP 归属地判定是否命中。 private Boolean blockOverseasIp; + + /// 是否启用省级地区拦截(null/false=不检查;true=按 IP 省份匹配省级黑名单) + /// + /// 地域策略开关快照, 由 [cn.daxpay.open.payment.trade.runtime.service.pay.common.PayRiskAssistService] + /// 读取平台配置后注入。 + private Boolean provinceBlacklistEnabled; + + /// 是否启用地理围栏(null/false=不检查;true=启用门店市级围栏检测) + /// + /// 第三层围栏全局开关快照, 由 [cn.daxpay.open.payment.trade.runtime.service.pay.common.PayRiskAssistService] + /// 读取平台配置后注入。开启后比对 IP 归属城市与门店所在城市, 不一致按 blockOnHit 处理。 + private Boolean geoFenceEnabled; + + /// 门店号(围栏命中快照来源) + /// + /// 由 [cn.daxpay.open.payment.trade.runtime.service.pay.common.PayRiskAssistService] + /// 从 terminal.storeNo 提取注入。 + private String storeNo; + + /// 门店所在城市(围栏比对基准, 中文城市名) + /// + /// 由 [cn.daxpay.open.payment.trade.runtime.service.pay.common.PayRiskAssistService] + /// 按 storeNo 查门店 regionCode 反查 base_city 名称注入; 门店无地址时为 null(围栏 fail-open)。 + private String storeCity; + + /// 客户端 IP 归属城市(命中落库快照, 由检查器解析 IP 后填入) + private String clientCity; } diff --git a/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/trade/runtime/service/pay/common/PayRiskAssistService.java b/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/trade/runtime/service/pay/common/PayRiskAssistService.java index 13118bba3..168c8cfbb 100644 --- a/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/trade/runtime/service/pay/common/PayRiskAssistService.java +++ b/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/trade/runtime/service/pay/common/PayRiskAssistService.java @@ -1,5 +1,7 @@ package cn.daxpay.open.payment.trade.runtime.service.pay.common; +import cn.daxpay.open.payment.merchant.dao.store.MchStoreInfoManager; +import cn.daxpay.open.payment.merchant.entity.store.MchStoreInfo; import cn.daxpay.open.payment.strategy.pay.AbsNormalPayStrategy; import cn.daxpay.open.payment.strategy.risk.PayRiskCheckContext; import cn.daxpay.open.payment.strategy.risk.PayRiskChecker; @@ -12,7 +14,9 @@ import cn.daxpay.open.payment.trade.order.entity.PayTrade; import cn.daxpay.open.payment.unipay.param.trade.pay.NormalPayParam; import cn.daxpay.open.platform.core.enums.pay.channel.ProductEnum; import cn.daxpay.open.platform.core.enums.pay.trade.TradeSourceEnum; +import cn.daxpay.open.platform.system.dao.region.CityManager; import cn.daxpay.open.platform.system.entity.config.platform.security.PlatformPaySecurityConfig; +import cn.daxpay.open.platform.system.entity.region.City; import cn.daxpay.open.platform.system.service.config.security.PlatformSecurityConfigService; import cn.hutool.core.util.StrUtil; import lombok.RequiredArgsConstructor; @@ -20,6 +24,7 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.ObjectProvider; import org.springframework.stereotype.Service; +import java.util.Map; import java.util.Objects; /// # 支付风控辅助 @@ -35,6 +40,8 @@ public class PayRiskAssistService { private final PlatformSecurityConfigService platformSecurityConfigService; private final NormalPayOrderManager normalPayOrderManager; private final GatewayPayOrderManager gatewayPayOrderManager; + private final MchStoreInfoManager mchStoreInfoManager; + private final CityManager cityManager; /// 支付前检查(须在 [AbsNormalPayStrategy#doBeforePay] 之后调用,保证微信 channelAppId 已回填) /// @@ -51,8 +58,14 @@ public class PayRiskAssistService { PayRiskCheckContext ctx = buildContextFromParam(payParam, scene); // false=仅记录不拦截;缺省/true=命中拒绝下单 ctx.setBlockOnHit(!Boolean.FALSE.equals(config.getRiskBlockBeforePay())); + // 黑名单拦截开关(第一层: IP / 用户标识) + ctx.setBlacklistEnabled(config.getBlacklistEnabled()); // 海外 IP 拦截开关(地域策略) ctx.setBlockOverseasIp(config.getBlockOverseasIp()); + // 省级地区拦截开关(地域策略) + ctx.setProvinceBlacklistEnabled(config.getProvinceBlacklistEnabled()); + // 地理围栏全局开关(第三层, Phase 2 实现检查逻辑) + ctx.setGeoFenceEnabled(config.getGeoFenceEnabled()); checker.checkBeforePay(ctx); } @@ -72,6 +85,12 @@ public class PayRiskAssistService { PayRiskCheckContext ctx = buildContextFromTrade(trade); // 海外 IP 拦截开关(地域策略, 事后仅记录海外访问) ctx.setBlockOverseasIp(config.getBlockOverseasIp()); + // 省级地区拦截开关(地域策略, 事后补录) + ctx.setProvinceBlacklistEnabled(config.getProvinceBlacklistEnabled()); + // 地理围栏全局开关(第三层, Phase 2 实现检查逻辑) + ctx.setGeoFenceEnabled(config.getGeoFenceEnabled()); + // 黑名单拦截开关(第一层, 事后补录) + ctx.setBlacklistEnabled(config.getBlacklistEnabled()); if (StrUtil.isNotBlank(buyerId)) { ctx.setBuyerId(buyerId); } @@ -99,6 +118,10 @@ public class PayRiskAssistService { .setChannelAppId(payParam.getChannelAppId()) .setBizOrderNo(payParam.getBizOrderNo()); fillChannelByProduct(ctx, payParam.getProduct()); + // 门店号与门店城市(围栏比对基准): 从 terminal.storeNo 提取, 反查门店 regionCode 得城市名 + String storeNo = payParam.getTerminal() != null ? payParam.getTerminal().getStoreNo() : null; + ctx.setStoreNo(storeNo); + ctx.setStoreCity(resolveStoreCity(storeNo)); return ctx; } @@ -121,7 +144,8 @@ public class PayRiskAssistService { .setChannel(order.getChannel()) .setChannelAppId(order.getChannelAppId()) .setOrderNo(order.getOrderNo()) - .setBizOrderNo(order.getBizOrderNo()); + .setBizOrderNo(order.getBizOrderNo()) + .setStoreNo(order.getStoreNo()); } } else { NormalPayOrder order = normalPayOrderManager.findById(trade.getContainerId()).orElse(null); @@ -135,12 +159,15 @@ public class PayRiskAssistService { .setChannel(order.getChannel()) .setChannelAppId(order.getChannelAppId()) .setOrderNo(order.getOrderNo()) - .setBizOrderNo(order.getBizOrderNo()); + .setBizOrderNo(order.getBizOrderNo()) + .setStoreNo(order.getStoreNo()); } } if (StrUtil.isBlank(ctx.getChannel())) { fillChannelByProduct(ctx, ctx.getProduct()); } + // 门店城市反查(围栏比对基准) + ctx.setStoreCity(resolveStoreCity(ctx.getStoreNo())); return ctx; } @@ -163,4 +190,32 @@ public class PayRiskAssistService { } return "api"; } + + /// 直辖市省份编码 → 城市名(base_city 对直辖市存"市辖区", 围栏比对需用省名) + private static final Map DIRECT_CITY_CODE_TO_NAME = Map.of( + "11", "北京市", "12", "天津市", "31", "上海市", "50", "重庆市"); + + /// 按 storeNo 查门店所在城市(中文城市名, 与 ip2region 返回格式对齐) + /// + /// 门店 regionCode 为 6 位区县码: 直辖市用省名, 其余取前 4 位查 base_city。 + /// 门店不存在或未录地址返回 null(围栏 fail-open)。 + private String resolveStoreCity(String storeNo) { + if (StrUtil.isBlank(storeNo)) { + return null; + } + MchStoreInfo store = mchStoreInfoManager.findByStoreNo(storeNo).orElse(null); + if (store == null || StrUtil.isBlank(store.getRegionCode()) || store.getRegionCode().length() < 4) { + return null; + } + String regionCode = store.getRegionCode(); + // 直辖市(北京11/天津12/上海31/重庆50): base_city 为"市辖区", 改用省名作为城市 + String directCity = DIRECT_CITY_CODE_TO_NAME.get(regionCode.substring(0, 2)); + if (directCity != null) { + return directCity; + } + // 普通省市: 前 4 位为城市码, 查 base_city 得城市名 + return cityManager.findById(regionCode.substring(0, 4)) + .map(City::getName) + .orElse(null); + } } 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 6cce9ff06..06919ab0c 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 @@ -16,6 +16,7 @@ import cn.daxpay.open.payment.trade.order.dao.PayTradeManager; 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.param.trade.pay.TerminalInfo; 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; @@ -257,6 +258,10 @@ public class GatewayPayHandleService { payParam.setExpiredTime(order.getExpiredTime()); payParam.setClientIp(StrUtil.blankToDefault(clientIp, order.getClientIp())); payParam.setGoodsDetail(order.getGoodsDetail()); + // 终端信息: 网关容器冗余的 storeNo 注入 terminal, 供风控上下文取用(与码牌路径对齐) + TerminalInfo terminal = new TerminalInfo(); + terminal.setStoreNo(order.getStoreNo()); + payParam.setTerminal(terminal); return payParam; } diff --git a/daxpay-plugin/daxpay-plugin-risk/src/main/java/cn/daxpay/open/plugin/risk/entity/PayRiskHit.java b/daxpay-plugin/daxpay-plugin-risk/src/main/java/cn/daxpay/open/plugin/risk/entity/PayRiskHit.java index b4bd9d702..af30afc79 100644 --- a/daxpay-plugin/daxpay-plugin-risk/src/main/java/cn/daxpay/open/plugin/risk/entity/PayRiskHit.java +++ b/daxpay-plugin/daxpay-plugin-risk/src/main/java/cn/daxpay/open/plugin/risk/entity/PayRiskHit.java @@ -78,6 +78,15 @@ public class PayRiskHit extends MpBaseEntity implements ToResult