mirror of
https://gitee.com/dromara/dax-pay
synced 2026-08-10 06:46:04 +08:00
feat(risk): 实现门店市级地理围栏(IP 城市级判定)
- 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 + 告警
This commit is contained in:
@@ -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 '支付风险命中记录(事前拦截与事后命中,供运营预警与处置)';
|
||||
|
||||
-- ----------------------------
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<String, String> 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -78,6 +78,15 @@ public class PayRiskHit extends MpBaseEntity implements ToResult<PayRiskHitResul
|
||||
/// 备注
|
||||
private String remark;
|
||||
|
||||
/// 客户端 IP 归属城市(ip2region 解析快照)
|
||||
private String clientCity;
|
||||
|
||||
/// 门店所在城市(围栏命中快照)
|
||||
private String storeCity;
|
||||
|
||||
/// 门店号(围栏命中快照)
|
||||
private String storeNo;
|
||||
|
||||
@Override
|
||||
public PayRiskHitResult toResult() {
|
||||
return PayRiskHitConvert.CONVERT.toResult(this);
|
||||
|
||||
@@ -78,4 +78,13 @@ public class PayRiskHitResult extends BaseResult {
|
||||
|
||||
@Schema(description = "备注")
|
||||
private String remark;
|
||||
|
||||
@Schema(description = "客户端IP归属城市")
|
||||
private String clientCity;
|
||||
|
||||
@Schema(description = "门店所在城市")
|
||||
private String storeCity;
|
||||
|
||||
@Schema(description = "门店号")
|
||||
private String storeNo;
|
||||
}
|
||||
|
||||
@@ -67,7 +67,11 @@ public class PayRiskHitService {
|
||||
.setClientIp(ctx.getClientIp())
|
||||
.setOpenid(ctx.getOpenId())
|
||||
.setBuyerId(ctx.getBuyerId())
|
||||
.setScene(StrUtil.blankToDefault(ctx.getScene(), PayRiskHitSceneEnum.UNKNOWN.getCode()));
|
||||
.setScene(StrUtil.blankToDefault(ctx.getScene(), PayRiskHitSceneEnum.UNKNOWN.getCode()))
|
||||
// 围栏命中位置快照(IP 城市 / 门店城市 / 门店号)
|
||||
.setClientCity(ctx.getClientCity())
|
||||
.setStoreCity(ctx.getStoreCity())
|
||||
.setStoreNo(ctx.getStoreNo());
|
||||
payRiskHitManager.save(hit);
|
||||
}
|
||||
|
||||
|
||||
@@ -48,6 +48,9 @@ public class DefaultPayRiskChecker implements PayRiskChecker {
|
||||
|
||||
private static final String OPEN_ID_BLACKLIST_CACHE_KEY = "hasOpenId";
|
||||
|
||||
/// 地理围栏命中类型(非黑名单来源, 记录 IP 城市与门店城市不一致)
|
||||
private static final String HIT_TYPE_GEO_FENCE = "geo_fence";
|
||||
|
||||
@Override
|
||||
public void checkBeforePay(PayRiskCheckContext ctx) {
|
||||
if (ctx == null) {
|
||||
@@ -56,19 +59,28 @@ public class DefaultPayRiskChecker implements PayRiskChecker {
|
||||
ctx.setPhase(PayRiskHitPhaseEnum.BEFORE_PAY.getCode());
|
||||
// null/true=阻断下单;false=仅落命中(对齐 riskBlockBeforePay)
|
||||
boolean throwOnHit = !Boolean.FALSE.equals(ctx.getBlockOnHit());
|
||||
// IP 名单(全局生效)
|
||||
rejectIfBlocked(ctx, PayBlacklistTypeEnum.IP.getCode(), ctx.getClientIp(), null, throwOnHit);
|
||||
// 用户标识:按通道映射名单类型
|
||||
boolean identityBlocked = checkUserIdentity(ctx, ctx.getOpenId(), throwOnHit);
|
||||
if (!identityBlocked && StrUtil.isBlank(ctx.getOpenId())) {
|
||||
log.warn("支付前 openId 缺失, 用户标识黑名单降级为仅 IP 校验 + 事后补录: "
|
||||
+ "tradeType={}, method={}, mchNo={}, clientIp={}",
|
||||
ctx.getTradeType(), ctx.getMethod(), ctx.getMchNo(), ctx.getClientIp());
|
||||
// L1 黑名单(IP / 用户标识, 受 blacklistEnabled 开关控制)
|
||||
if (Boolean.TRUE.equals(ctx.getBlacklistEnabled())) {
|
||||
// IP 名单
|
||||
rejectIfBlocked(ctx, PayBlacklistTypeEnum.IP.getCode(), ctx.getClientIp(), null, throwOnHit);
|
||||
// 用户标识:按通道映射名单类型
|
||||
boolean identityBlocked = checkUserIdentity(ctx, ctx.getOpenId(), throwOnHit);
|
||||
if (!identityBlocked && StrUtil.isBlank(ctx.getOpenId())) {
|
||||
log.warn("支付前 openId 缺失, 用户标识黑名单降级为仅 IP 校验 + 事后补录: "
|
||||
+ "tradeType={}, method={}, mchNo={}, clientIp={}",
|
||||
ctx.getTradeType(), ctx.getMethod(), ctx.getMchNo(), ctx.getClientIp());
|
||||
}
|
||||
}
|
||||
// 海外 IP 拦截(地域策略, 非黑名单)
|
||||
if (Boolean.TRUE.equals(ctx.getBlockOverseasIp())) {
|
||||
checkOverseasIp(ctx, throwOnHit);
|
||||
}
|
||||
// IP 省级地区黑名单(第二层全局地区名单, 受 provinceBlacklistEnabled 开关控制)
|
||||
checkProvinceBlacklist(ctx, throwOnHit);
|
||||
// L3 地理围栏(门店市级: IP 归属城市与门店城市比对)
|
||||
if (Boolean.TRUE.equals(ctx.getGeoFenceEnabled())) {
|
||||
checkStoreGeoFence(ctx, throwOnHit);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -78,16 +90,25 @@ public class DefaultPayRiskChecker implements PayRiskChecker {
|
||||
}
|
||||
ctx.setPhase(PayRiskHitPhaseEnum.AFTER_PAY.getCode());
|
||||
// 事后只记命中,不抛错
|
||||
rejectIfBlocked(ctx, PayBlacklistTypeEnum.IP.getCode(), ctx.getClientIp(), null, false);
|
||||
checkUserIdentity(ctx, ctx.getOpenId(), false);
|
||||
// buyerId 按用户标识维度比对(主扫补洞)
|
||||
if (StrUtil.isNotBlank(ctx.getBuyerId()) && !StrUtil.equals(ctx.getBuyerId(), ctx.getOpenId())) {
|
||||
checkUserIdentity(ctx, ctx.getBuyerId(), false);
|
||||
// L1 黑名单(IP / 用户标识, 受 blacklistEnabled 开关控制)
|
||||
if (Boolean.TRUE.equals(ctx.getBlacklistEnabled())) {
|
||||
rejectIfBlocked(ctx, PayBlacklistTypeEnum.IP.getCode(), ctx.getClientIp(), null, false);
|
||||
checkUserIdentity(ctx, ctx.getOpenId(), false);
|
||||
// buyerId 按用户标识维度比对(主扫补洞)
|
||||
if (StrUtil.isNotBlank(ctx.getBuyerId()) && !StrUtil.equals(ctx.getBuyerId(), ctx.getOpenId())) {
|
||||
checkUserIdentity(ctx, ctx.getBuyerId(), false);
|
||||
}
|
||||
}
|
||||
// 海外 IP 访问记录(事后仅记录, 不阻断)
|
||||
if (Boolean.TRUE.equals(ctx.getBlockOverseasIp())) {
|
||||
checkOverseasIp(ctx, false);
|
||||
}
|
||||
// IP 省级地区黑名单(事后补录, 受 provinceBlacklistEnabled 开关控制)
|
||||
checkProvinceBlacklist(ctx, false);
|
||||
// L3 地理围栏(事后补录: IP 归属城市与门店城市比对)
|
||||
if (Boolean.TRUE.equals(ctx.getGeoFenceEnabled())) {
|
||||
checkStoreGeoFence(ctx, false);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -141,6 +162,35 @@ public class DefaultPayRiskChecker implements PayRiskChecker {
|
||||
return true;
|
||||
}
|
||||
|
||||
/// IP 省级地区黑名单(第二层全局地区名单)
|
||||
///
|
||||
/// IP 归属省份在省级黑名单中则命中, value 存省名(与 ip2region 返回格式对齐)。
|
||||
/// 需平台配置 provinceBlacklistEnabled=true 才执行; IPv6 / 内网 / 解析失败 → fail-open 放行。
|
||||
private void checkProvinceBlacklist(PayRiskCheckContext ctx, boolean throwOnHit) {
|
||||
// 省级拦截开关关闭时跳过
|
||||
if (!Boolean.TRUE.equals(ctx.getProvinceBlacklistEnabled())) {
|
||||
return;
|
||||
}
|
||||
String ip = ctx.getClientIp();
|
||||
if (StrUtil.isBlank(ip)) {
|
||||
return;
|
||||
}
|
||||
// IPv6 暂不参与地区判定
|
||||
if (Validator.isIpv6(ip)) {
|
||||
return;
|
||||
}
|
||||
// 内网/回环地址直通放行
|
||||
if (NetUtil.isInnerIP(ip)) {
|
||||
return;
|
||||
}
|
||||
IpRegion region = ipToRegionService.getRegionByIp(ip);
|
||||
// 解析失败或无省份信息 → fail-open
|
||||
if (region == null || StrUtil.isBlank(region.getProvince())) {
|
||||
return;
|
||||
}
|
||||
rejectIfBlocked(ctx, PayBlacklistTypeEnum.PROVINCE.getCode(), region.getProvince(), null, throwOnHit);
|
||||
}
|
||||
|
||||
/// 海外 IP 地域拦截(country≠中国, 港澳台放行; 未知/内网 fail-open)
|
||||
///
|
||||
/// 与黑名单不同, 海外命中不关联名单行(blacklistId=null), 命中类型为 [PayBlacklistTypeEnum#OVERSEAS_IP]。
|
||||
@@ -179,4 +229,76 @@ public class DefaultPayRiskChecker implements PayRiskChecker {
|
||||
throw new BizInfoException(PayErrorCode.OPERATION_FAIL, "pay.error.risk.blacklist");
|
||||
}
|
||||
}
|
||||
|
||||
/// 门店市级地理围栏
|
||||
///
|
||||
/// IP 归属城市与门店所在城市不一致则命中; 城市名比对前做后缀归一化(去"市/省")。
|
||||
/// 直辖市用 [IpRegion#isProvinceLevel] 归一(省份即城市)。
|
||||
/// 门店无地址(storeCity 为空)时 fail-open + 告警; IPv6/内网/解析失败 fail-open。
|
||||
private void checkStoreGeoFence(PayRiskCheckContext ctx, boolean throwOnHit) {
|
||||
// 门店无地址: fail-open + 告警(提示补录门店行政区划地址)
|
||||
if (StrUtil.isBlank(ctx.getStoreCity())) {
|
||||
if (ctx.getStoreNo() != null) {
|
||||
log.warn("围栏开启但门店城市为空, storeNo={}, mchNo={}, 跳过围栏校验, 请补录门店行政区划地址",
|
||||
ctx.getStoreNo(), ctx.getMchNo());
|
||||
}
|
||||
return;
|
||||
}
|
||||
String ip = ctx.getClientIp();
|
||||
if (StrUtil.isBlank(ip)) {
|
||||
return;
|
||||
}
|
||||
// IPv6 暂不参与地区判定
|
||||
if (Validator.isIpv6(ip)) {
|
||||
return;
|
||||
}
|
||||
// 内网/回环地址直通放行
|
||||
if (NetUtil.isInnerIP(ip)) {
|
||||
return;
|
||||
}
|
||||
IpRegion region = ipToRegionService.getRegionByIp(ip);
|
||||
if (region == null) {
|
||||
return;
|
||||
}
|
||||
// 解析出城市或为直辖市才继续, 否则 fail-open
|
||||
boolean hasCity = StrUtil.isNotBlank(region.getCity());
|
||||
boolean isDirect = region.isProvinceLevel();
|
||||
if (!hasCity && !isDirect) {
|
||||
return;
|
||||
}
|
||||
// 直辖市: 城市即省份; 普通市: 城市为 city
|
||||
String clientCity = isDirect ? region.getProvince() : region.getCity();
|
||||
// 回填 ctx 供命中落库
|
||||
ctx.setClientCity(clientCity);
|
||||
// 后缀归一化后比对(去"市/省"等后缀, 提升门店城市与 IP 城市对齐率)
|
||||
String clientNorm = normalizeRegionName(clientCity);
|
||||
String storeNorm = normalizeRegionName(ctx.getStoreCity());
|
||||
if (!clientNorm.equals(storeNorm)) {
|
||||
// 记录围栏命中(非黑名单来源, blacklistId=null)
|
||||
try {
|
||||
payRiskHitService.recordHit(ctx, HIT_TYPE_GEO_FENCE, clientCity, null);
|
||||
} catch (Exception e) {
|
||||
log.warn("记录地理围栏命中失败 clientCity={}, storeCity={}: {}",
|
||||
clientCity, ctx.getStoreCity(), e.getMessage());
|
||||
}
|
||||
if (throwOnHit) {
|
||||
// 交易被限制(模糊文案,防探测)
|
||||
throw new BizInfoException(PayErrorCode.OPERATION_FAIL, "pay.error.risk.blacklist");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 行政区划名称归一化: 去掉省/市等常见后缀, 用于门店城市与 IP 归属城市对齐比对
|
||||
///
|
||||
/// 覆盖: 直辖市(北京市→北京)、普通地级市(深圳市→深圳)。自治州/盟保留全名(已知限制)。
|
||||
private static String normalizeRegionName(String name) {
|
||||
if (StrUtil.isBlank(name)) {
|
||||
return "";
|
||||
}
|
||||
String result = name.trim();
|
||||
if (result.endsWith("市") || result.endsWith("省")) {
|
||||
result = result.substring(0, result.length() - 1);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user