refactor(risk): 省市地区拦截合并为地区拦截开关并新增 IPv6 地区匹配

- provinceBlacklistEnabled/cityBlacklistEnabled 合并为 regionBlacklistEnabled,旧字段 @Deprecated 兼容迁移(jsonb 读取时自动合并清理)
- 新增 ipv6MatchEnabled 开关,IPv6 地域检查默认关闭(NetUtil.isInnerIP 仅支持 IPv4 需分支隔离)
- 省级名单命中后不再执行市级检查,一次 ip2region 解析同时完成省市两级匹配
- 重写 DefaultPayRiskChecker 单测(41 个),验证合并迁移与 IPv6 分支
This commit is contained in:
daxpay
2026-08-10 20:49:00 +08:00
parent 6d5395758e
commit e394c45afa
18 changed files with 261 additions and 170 deletions

View File

@@ -72,17 +72,18 @@ public class PayRiskCheckContext {
/// 读取平台配置后注入, 供检查器按 IP 归属地判定是否命中。
private Boolean blockOverseasIp;
/// 是否启用省级地区拦截null/false=不检查true=按 IP 省份匹配省级黑名单)
/// 是否启用地区拦截null/false=不检查true=按 IP 归属地匹配省级与市级黑名单)
///
/// 地域策略开关快照, 由 [cn.daxpay.open.payment.trade.runtime.service.pay.common.PayRiskAssistService]
/// 读取平台配置后注入。省级命中后不再执行市级检查。
private Boolean provinceBlacklistEnabled;
/// 读取平台配置后注入。合并原省级/市级独立开关, 省级命中后不再执行市级检查。
private Boolean regionBlacklistEnabled;
/// 是否启用市级地区拦截null/false=不检查true= IP 城市匹配市级黑名单
/// 是否启用 IPv6 地区匹配null/false=IPv6 直通放行true= IPv6 执行地区匹配
///
/// 地域策略开关快照, 由 [cn.daxpay.open.payment.trade.runtime.service.pay.common.PayRiskAssistService]
/// 读取平台配置后注入。与省级开关独立, 省级未命中时执行。
private Boolean cityBlacklistEnabled;
/// 读取平台配置后注入。关闭时省级/市级/海外/地理围栏四项检查对 IPv6 直通放行;
/// 开启时 IPv6 走 ip2region 查询(xdb 离线数据精度有限)。
private Boolean ipv6MatchEnabled;
/// 是否启用地理围栏null/false=不检查true=启用门店市级围栏检测)
///

View File

@@ -72,10 +72,10 @@ public class PayRiskAssistService {
ctx.setBlacklistEnabled(config.getBlacklistEnabled());
// 海外 IP 拦截开关(地域策略)
ctx.setBlockOverseasIp(config.getBlockOverseasIp());
// 省级地区拦截开关(地域策略)
ctx.setProvinceBlacklistEnabled(config.getProvinceBlacklistEnabled());
// 市级地区拦截开关(地域策略, 独立于省级
ctx.setCityBlacklistEnabled(config.getCityBlacklistEnabled());
// 地区拦截开关(地域策略, 含省级+市级, 省级命中后不执行市级检查
ctx.setRegionBlacklistEnabled(config.getRegionBlacklistEnabled());
// IPv6 地区匹配开关(地域策略, 默认关闭, 关闭时 IPv6 直通放行
ctx.setIpv6MatchEnabled(config.getIpv6MatchEnabled());
// 地理围栏两级门控 + 策略 + 放行城市集合(第三层)
applyGeoFence(ctx, config, payParam.getMchNo());
checker.checkBeforePay(ctx);
@@ -97,10 +97,10 @@ public class PayRiskAssistService {
PayRiskCheckContext ctx = buildContextFromTrade(trade);
// 海外 IP 拦截开关(地域策略, 事后仅记录海外访问)
ctx.setBlockOverseasIp(config.getBlockOverseasIp());
// 省级地区拦截开关(地域策略, 事后补录)
ctx.setProvinceBlacklistEnabled(config.getProvinceBlacklistEnabled());
// 市级地区拦截开关(地域策略, 事后补录, 独立于省级
ctx.setCityBlacklistEnabled(config.getCityBlacklistEnabled());
// 地区拦截开关(地域策略, 事后补录, 含省级+市级
ctx.setRegionBlacklistEnabled(config.getRegionBlacklistEnabled());
// IPv6 地区匹配开关(地域策略, 事后补录, 默认关闭
ctx.setIpv6MatchEnabled(config.getIpv6MatchEnabled());
// 地理围栏两级门控 + 策略 + 放行城市集合(第三层, 事后补录)
applyGeoFence(ctx, config, trade.getMchNo());
// 黑名单拦截开关(第一层, 事后补录)

View File

@@ -811,11 +811,11 @@
"blockOverseasIp": {
"notNull": "Overseas IP block toggle cannot be empty"
},
"provinceBlacklistEnabled": {
"notNull": "Province-level blocking toggle cannot be empty"
"regionBlacklistEnabled": {
"notNull": "Region blocking toggle cannot be empty"
},
"cityBlacklistEnabled": {
"notNull": "City-level blocking toggle cannot be empty"
"ipv6MatchEnabled": {
"notNull": "IPv6 region matching toggle cannot be empty"
},
"geoFenceEnabled": {
"notNull": "Geo-fence toggle cannot be empty"

View File

@@ -814,11 +814,11 @@
"blockOverseasIp": {
"notNull": "Saklar blokir IP luar negeri tidak boleh kosong"
},
"provinceBlacklistEnabled": {
"notNull": "Saklar pemblokiran tingkat provinsi tidak boleh kosong"
"regionBlacklistEnabled": {
"notNull": "Saklar pemblokiran wilayah tidak boleh kosong"
},
"cityBlacklistEnabled": {
"notNull": "Saklar pemblokiran tingkat kota tidak boleh kosong"
"ipv6MatchEnabled": {
"notNull": "Saklar pencocokan region IPv6 tidak boleh kosong"
},
"geoFenceEnabled": {
"notNull": "Saklar geo-pagar tidak boleh kosong"

View File

@@ -807,11 +807,11 @@
"blockOverseasIp": {
"notNull": "海外 IP ブロック スイッチは必須です"
},
"provinceBlacklistEnabled": {
"notNull": "省レベルの地域遮断スイッチは必須です"
"regionBlacklistEnabled": {
"notNull": "地域遮断スイッチは必須です"
},
"cityBlacklistEnabled": {
"notNull": "市レベルの地域遮断スイッチは必須です"
"ipv6MatchEnabled": {
"notNull": "IPv6 地域マッチングスイッチは必須です"
},
"geoFenceEnabled": {
"notNull": "ジオフェンススイッチは必須です"

View File

@@ -807,11 +807,11 @@
"blockOverseasIp": {
"notNull": "해외 IP 차단 스위치는 필수입니다"
},
"provinceBlacklistEnabled": {
"notNull": "지역 차단 스위치는 필수입니다"
"regionBlacklistEnabled": {
"notNull": "지역 차단 스위치는 필수입니다"
},
"cityBlacklistEnabled": {
"notNull": " 지역 차단 스위치는 필수입니다"
"ipv6MatchEnabled": {
"notNull": "IPv6 지역 매칭 스위치는 필수입니다"
},
"geoFenceEnabled": {
"notNull": "지오펜스 스위치는 필수입니다"

View File

@@ -814,11 +814,11 @@
"blockOverseasIp": {
"notNull": "Suis sekatan IP luar negara tidak boleh kosong"
},
"provinceBlacklistEnabled": {
"notNull": "Saklar sekatan tahap wilayah tidak boleh kosong"
"regionBlacklistEnabled": {
"notNull": "Saklar sekatan wilayah tidak boleh kosong"
},
"cityBlacklistEnabled": {
"notNull": "Saklar sekatan tahap bandar tidak boleh kosong"
"ipv6MatchEnabled": {
"notNull": "Saklar pemadanan rantau IPv6 tidak boleh kosong"
},
"geoFenceEnabled": {
"notNull": "Saklar pagar geo tidak boleh kosong"

View File

@@ -814,11 +814,11 @@
"blockOverseasIp": {
"notNull": "สวิตช์บล็อก IP ต่างประเทศต้องไม่ว่าง"
},
"provinceBlacklistEnabled": {
"notNull": "สวิตช์บล็อกภูมิภาคระดับจังหวัดต้องไม่ว่าง"
"regionBlacklistEnabled": {
"notNull": "สวิตช์บล็อกภูมิภาคต้องไม่ว่าง"
},
"cityBlacklistEnabled": {
"notNull": "สวิตช์บล็อกภูมิภาคระดับเมืองต้องไม่ว่าง"
"ipv6MatchEnabled": {
"notNull": "สวิตช์จับคู่ภูมิภาค IPv6 ต้องไม่ว่าง"
},
"geoFenceEnabled": {
"notNull": "สวิตช์รั้วภูมิศาสตร์ต้องไม่ว่าง"

View File

@@ -814,11 +814,11 @@
"blockOverseasIp": {
"notNull": "Công tắc chặn IP nước ngoài không được để trống"
},
"provinceBlacklistEnabled": {
"notNull": "Công tắc chặn cấp tỉnh không được để trống"
"regionBlacklistEnabled": {
"notNull": "Công tắc chặn khu vực không được để trống"
},
"cityBlacklistEnabled": {
"notNull": "Công tắc chặn cấp thành phố không được để trống"
"ipv6MatchEnabled": {
"notNull": "Công tắc khớp vùng IPv6 không được để trống"
},
"geoFenceEnabled": {
"notNull": "Công tắc hàng rào địa lý không được để trống"

View File

@@ -811,11 +811,11 @@
"blockOverseasIp": {
"notNull": "海外 IP 拦截开关不能为空"
},
"provinceBlacklistEnabled": {
"notNull": "省级地区拦截开关不能为空"
"regionBlacklistEnabled": {
"notNull": "地区拦截开关不能为空"
},
"cityBlacklistEnabled": {
"notNull": "市级地区拦截开关不能为空"
"ipv6MatchEnabled": {
"notNull": "IPv6 地区匹配开关不能为空"
},
"geoFenceEnabled": {
"notNull": "地理围栏开关不能为空"

View File

@@ -807,11 +807,11 @@
"blockOverseasIp": {
"notNull": "海外 IP 攔截開關不能為空"
},
"provinceBlacklistEnabled": {
"notNull": "省級地區攔截開關不能為空"
"regionBlacklistEnabled": {
"notNull": "地區攔截開關不能為空"
},
"cityBlacklistEnabled": {
"notNull": "市級地區攔截開關不能為空"
"ipv6MatchEnabled": {
"notNull": "IPv6 地區匹配開關不能為空"
},
"geoFenceEnabled": {
"notNull": "地理圍欄開關不能為空"

View File

@@ -807,11 +807,11 @@
"blockOverseasIp": {
"notNull": "海外 IP 攔截開關不能為空"
},
"provinceBlacklistEnabled": {
"notNull": "省級地區攔截開關不能為空"
"regionBlacklistEnabled": {
"notNull": "地區攔截開關不能為空"
},
"cityBlacklistEnabled": {
"notNull": "市級地區攔截開關不能為空"
"ipv6MatchEnabled": {
"notNull": "IPv6 地區匹配開關不能為空"
},
"geoFenceEnabled": {
"notNull": "地理圍欄開關不能為空"

View File

@@ -16,8 +16,7 @@ import lombok.experimental.Accessors;
/// - 事后补录: 支付成功后是否补充记录命中(用于事后分析,不阻断资金态)
/// - 用户标识拦截级别: 是否对 H5 / 主扫等免用户标识方式强制 OAuth, 见 [PayRiskOpenIdLevelEnum]
/// - 海外 IP 拦截: 拦截境外 IP 发起的支付请求(第二层)
/// - 省级地区拦截: 根据 IP 归属省份匹配省级黑名单(第二层)
/// - 市级地区拦截: 根据 IP 归属城市匹配市级黑名单(第二层, 与省级开关独立, 省命中后不执行)
/// - 地区拦截: 根据 IP 归属匹配省级与市级黑名单, 省级命中后不执行市级检查(第二层)
/// - 地理围栏: 围栏功能全局开关 + 全局策略(strict/balanced/loose), 开启后各商户 opt-in 才生效(第三层)
@Data
@Accessors(chain = true)
@@ -42,12 +41,29 @@ public class PlatformPaySecurityConfig {
/// 海外 IP 拦截(默认关闭, 拦截境外 IP 支付请求)
private Boolean blockOverseasIp = Boolean.FALSE;
/// 省级地区拦截(默认关闭, 开启后根据 IP 归属省份匹配省级黑名单
/// 地区拦截(默认关闭, 开启后根据 IP 归属匹配省级与市级黑名单; 省级命中后不执行市级检查
///
/// 合并原省级/市级两个独立开关, 名单数据仍按 type=province/city 在「支付安全 → 黑名单」中区分维护。
private Boolean regionBlacklistEnabled = Boolean.FALSE;
/// 省级地区拦截(兼容旧版 jsonb 配置迁移, 不再参与逻辑)
///
/// 由 [PlatformSecurityConfigService] 读取时合并进 [regionBlacklistEnabled] 后清理, 勿再使用。
@Deprecated
private Boolean provinceBlacklistEnabled = Boolean.FALSE;
/// 市级地区拦截(默认关闭, 开启后根据 IP 归属城市匹配市级黑名单; 与省级开关独立, 省命中后不执行
/// 市级地区拦截(兼容旧版 jsonb 配置迁移, 不再参与逻辑
///
/// 由 [PlatformSecurityConfigService] 读取时合并进 [regionBlacklistEnabled] 后清理, 勿再使用。
@Deprecated
private Boolean cityBlacklistEnabled = Boolean.FALSE;
/// IPv6 地区匹配开关(默认关闭, IPv6 离线数据精度有限)
///
/// 开启后省级/市级/海外/地理围栏四项地域检查会对 IPv6 地址执行地区匹配;
/// 关闭时 IPv6 地址在地域检查中直通放行。xdb 开源 IPv6 数据精度有限, 商用数据可提高可靠性。
private Boolean ipv6MatchEnabled = Boolean.FALSE;
/// 地理围栏全局开关(默认关闭, 开启后各商户 mch_risk_config.geoFenceEnabled opt-in 才生效)
private Boolean geoFenceEnabled = Boolean.FALSE;

View File

@@ -36,13 +36,13 @@ public class PlatformPaySecurityConfigParam {
@NotNull(message = "{validation.field.blockOverseasIp.notNull}")
private Boolean blockOverseasIp;
@Schema(description = "省级地区拦截(默认关闭, 开启后根据 IP 归属省份匹配省级黑名单")
@NotNull(message = "{validation.field.provinceBlacklistEnabled.notNull}")
private Boolean provinceBlacklistEnabled;
@Schema(description = "地区拦截(默认关闭, 开启后根据 IP 归属匹配省级与市级黑名单; 省级命中后不执行市级检查")
@NotNull(message = "{validation.field.regionBlacklistEnabled.notNull}")
private Boolean regionBlacklistEnabled;
@Schema(description = "市级地区拦截(默认关闭, 开启后根据 IP 归属城市匹配市级黑名单; 与省级开关独立")
@NotNull(message = "{validation.field.cityBlacklistEnabled.notNull}")
private Boolean cityBlacklistEnabled;
@Schema(description = "IPv6 地区匹配开关(默认关闭, 开启后地域检查对 IPv6 执行匹配; 离线数据精度有限")
@NotNull(message = "{validation.field.ipv6MatchEnabled.notNull}")
private Boolean ipv6MatchEnabled;
@Schema(description = "地理围栏全局开关(默认关闭, 开启后各商户围栏 opt-in 才生效)")
@NotNull(message = "{validation.field.geoFenceEnabled.notNull}")

View File

@@ -32,11 +32,11 @@ public class PlatformPaySecurityConfigResult extends BaseResult {
@Schema(description = "海外 IP 拦截(默认关闭, 拦截境外 IP 支付请求)")
private Boolean blockOverseasIp;
@Schema(description = "省级地区拦截(默认关闭, 开启后根据 IP 归属省份匹配省级黑名单")
private Boolean provinceBlacklistEnabled;
@Schema(description = "地区拦截(默认关闭, 开启后根据 IP 归属匹配省级与市级黑名单; 省级命中后不执行市级检查")
private Boolean regionBlacklistEnabled;
@Schema(description = "市级地区拦截(默认关闭, 开启后根据 IP 归属城市匹配市级黑名单; 与省级开关独立")
private Boolean cityBlacklistEnabled;
@Schema(description = "IPv6 地区匹配开关(默认关闭, 开启后地域检查对 IPv6 执行匹配; 离线数据精度有限")
private Boolean ipv6MatchEnabled;
@Schema(description = "地理围栏全局开关(默认关闭, 开启后各商户围栏 opt-in 才生效)")
private Boolean geoFenceEnabled;

View File

@@ -262,7 +262,16 @@ public class PlatformSecurityConfigService {
return defaultPaySecurityConfig();
}
PlatformPaySecurityConfig config = JacksonUtil.toBean(json, PlatformPaySecurityConfig.class);
return config == null ? defaultPaySecurityConfig() : config;
if (config == null) {
return defaultPaySecurityConfig();
}
// 兼容旧版: 原省级/市级开关已合并为地区拦截, 旧字段任一开启则迁移为地区拦截开启
if (config.getRegionBlacklistEnabled() == null) {
boolean legacyOn = Boolean.TRUE.equals(config.getProvinceBlacklistEnabled())
|| Boolean.TRUE.equals(config.getCityBlacklistEnabled());
config.setRegionBlacklistEnabled(legacyOn);
}
return config;
}
/// 支付安全配置默认值: 默认开启风控、命中阻断、事后补录、增强拦截
@@ -273,6 +282,7 @@ public class PlatformSecurityConfigService {
.setRiskCheckAfterPay(true)
.setRiskOpenIdLevel(PayRiskOpenIdLevelEnum.ENHANCED.getCode())
.setBlockOverseasIp(false)
.setIpv6MatchEnabled(false)
.setGeoFenceStrategy(GEO_FENCE_STRATEGY_BALANCED);
}
@@ -293,6 +303,9 @@ public class PlatformSecurityConfigService {
PlatformPaySecurityConfig.class,
defaultPaySecurityConfig());
PlatformSecurityConfigConvert.CONVERT.copy(param, data);
// 清理旧版省级/市级开关字段(已合并进 regionBlacklistEnabled, jsonb 不再保留)
data.setProvinceBlacklistEnabled(null);
data.setCityBlacklistEnabled(null);
systemConfigService.updateConfig(PlatformConfigTypeEnum.PAY_SECURITY, data);
}
}

View File

@@ -79,12 +79,8 @@ public class DefaultPayRiskChecker implements PayRiskChecker {
if (Boolean.TRUE.equals(ctx.getBlockOverseasIp())) {
checkOverseasIp(ctx, throwOnHit);
}
// IP 省级地区黑名单(第二层全局地区名单, 受 provinceBlacklistEnabled 开关控制; 省命中后不执行市级检查)
boolean provinceHit = checkProvinceBlacklist(ctx, throwOnHit);
// IP 市级地区黑名单(第二层, 受 cityBlacklistEnabled 开关控制, 与省级开关独立)
if (!provinceHit && Boolean.TRUE.equals(ctx.getCityBlacklistEnabled())) {
checkCityBlacklist(ctx, throwOnHit);
}
// IP 地区黑名单(第二层全局地区名单, 受 regionBlacklistEnabled 开关控制; 省命中后不执行市级检查)
checkRegionBlacklist(ctx, throwOnHit);
// L3 地理围栏(门店市级: IP 归属城市与门店城市比对)
if (Boolean.TRUE.equals(ctx.getGeoFenceEnabled())) {
checkStoreGeoFence(ctx, throwOnHit);
@@ -111,11 +107,8 @@ public class DefaultPayRiskChecker implements PayRiskChecker {
if (Boolean.TRUE.equals(ctx.getBlockOverseasIp())) {
checkOverseasIp(ctx, false);
}
// IP 地区黑名单(事后补录, 受 provinceBlacklistEnabled 开关控制; 省命中后不补录市级)
boolean provinceHit = checkProvinceBlacklist(ctx, false);
if (!provinceHit && Boolean.TRUE.equals(ctx.getCityBlacklistEnabled())) {
checkCityBlacklist(ctx, false);
}
// IP 地区黑名单(事后补录, 受 regionBlacklistEnabled 开关控制; 省命中后不补录市级)
checkRegionBlacklist(ctx, false);
// L3 地理围栏(事后补录: IP 归属城市与门店城市比对)
if (Boolean.TRUE.equals(ctx.getGeoFenceEnabled())) {
checkStoreGeoFence(ctx, false);
@@ -173,70 +166,53 @@ public class DefaultPayRiskChecker implements PayRiskChecker {
return true;
}
/// IP 省级地区黑名单(第二层全局地区名单)
/// IP 地区黑名单(第二层全局地区名单, 含省级 + 市级
///
/// IP 归属省份省级黑名单中则命中, value 存省行政区划编码(2位, 如"44")
/// 需平台配置 provinceBlacklistEnabled=true 才执行; IPv6 / 内网 / 解析失败 / 无法映射编码 → fail-open 放行。
/// @return 是否命中(用于外层短路市级检查)
private boolean checkProvinceBlacklist(PayRiskCheckContext ctx, boolean throwOnHit) {
// 省级拦截开关关闭时跳过
if (!Boolean.TRUE.equals(ctx.getProvinceBlacklistEnabled())) {
/// 受 regionBlacklistEnabled 开关控制。先按 IP 归属省份匹配省级黑名单(value 存省行政区划编码, 如"44"),
/// 命中即拦截不再执行市级检查; 未命中再按 IP 归属城市匹配市级黑名单(value 存市行政区划编码, 如"4403";
/// 直辖市无独立市级, 城市名单存省编码, 由 [RegionCodeResolver#resolveCityCode] 回落为省编码)。
/// 省/市名单数据在「支付安全 → 黑名单」中按 type 区分维护。
/// IPv6(受 ipv6MatchEnabled 控制) / 内网 / 解析失败 / 无法映射编码 → fail-open 放行。
/// @return 是否命中(省级或市级)
private boolean checkRegionBlacklist(PayRiskCheckContext ctx, boolean throwOnHit) {
// 地区拦截开关关闭时跳过
if (!Boolean.TRUE.equals(ctx.getRegionBlacklistEnabled())) {
return false;
}
String ip = ctx.getClientIp();
if (StrUtil.isBlank(ip)) {
return false;
}
// IPv6 暂不参与地区判定
// IPv6 受 ipv6MatchEnabled 开关控制(默认关闭, 离线数据精度有限)
if (Validator.isIpv6(ip)) {
return false;
}
// 内网/回环地址直通放行
if (NetUtil.isInnerIP(ip)) {
return false;
if (!Boolean.TRUE.equals(ctx.getIpv6MatchEnabled())) {
return false;
}
// 开关开启: 跳过 NetUtil.isInnerIP(仅支持 IPv4, IPv6 内网由 xdb 查询 fail-open 兜底
} else {
// IPv4 内网/回环地址直通放行
if (NetUtil.isInnerIP(ip)) {
return false;
}
}
IpRegion region = ipToRegionService.getRegionByIp(ip);
// 解析失败 → fail-open
if (region == null) {
return false;
}
// IP 归属省份 → 行政区划编码(港澳台/"0"段/未知无法映射 → fail-open)
// 省级名单: IP 归属省份 → 行政区划编码(港澳台/"0"段/未知无法映射 → 跳过省级, 继续市级)
String provinceCode = regionCodeResolver.resolveProvinceCode(region.getProvince());
if (StrUtil.isBlank(provinceCode)) {
return false;
if (StrUtil.isNotBlank(provinceCode)
&& rejectIfBlocked(ctx, PayBlacklistTypeEnum.PROVINCE.getCode(), provinceCode, null, throwOnHit)) {
// 省命中即拦截, 不再执行市级检查
return true;
}
return rejectIfBlocked(ctx, PayBlacklistTypeEnum.PROVINCE.getCode(), provinceCode, null, throwOnHit);
}
/// IP 市级地区黑名单(第二层全局地区名单)
///
/// IP 归属城市在市级黑名单中则命中, value 存市行政区划编码(4位, 如"4403")。
/// 直辖市无独立市级(城市名单存省编码), 由 [RegionCodeResolver#resolveCityCode] 回落为省编码。
/// 需平台配置 cityBlacklistEnabled=true 才执行; IPv6 / 内网 / 解析失败 / 无法映射编码 → fail-open 放行。
private void checkCityBlacklist(PayRiskCheckContext ctx, boolean throwOnHit) {
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) {
return;
}
// IP 归属城市 → 行政区划编码(直辖市回落省编码; "0"段/未知无法映射 → fail-open)
// 市级名单: IP 归属城市 → 行政区划编码(直辖市回落省编码; "0"段/未知无法映射 → fail-open)
String cityCode = regionCodeResolver.resolveCityCode(region.getProvince(), region.getCity());
if (StrUtil.isBlank(cityCode)) {
return;
return false;
}
rejectIfBlocked(ctx, PayBlacklistTypeEnum.CITY.getCode(), cityCode, null, throwOnHit);
return rejectIfBlocked(ctx, PayBlacklistTypeEnum.CITY.getCode(), cityCode, null, throwOnHit);
}
/// 海外 IP 地域拦截country≠中国, 港澳台放行; 未知/内网 fail-open
@@ -249,13 +225,17 @@ public class DefaultPayRiskChecker implements PayRiskChecker {
if (StrUtil.isBlank(ip)) {
return;
}
// IPv6 暂不参与网段判定, 直接放行(NetUtil.isInnerIP 仅支持 IPv4, 传入 IPv6 会抛异常)
// IPv6 受 ipv6MatchEnabled 开关控制(默认关闭, 离线数据精度有限)
if (Validator.isIpv6(ip)) {
return;
}
// 内网/回环地址直通放行(网络层判定, 不查库)
if (NetUtil.isInnerIP(ip)) {
return;
if (!Boolean.TRUE.equals(ctx.getIpv6MatchEnabled())) {
return;
}
// 开关开启: 跳过 NetUtil.isInnerIP(仅支持 IPv4, 传 IPv6 会抛异常), IPv6 内网由 xdb fail-open 兜底
} else {
// IPv4 内网/回环地址直通放行(网络层判定, 不查库)
if (NetUtil.isInnerIP(ip)) {
return;
}
}
IpRegion region = ipToRegionService.getRegionByIp(ip);
// 未知(IPv6/查询失败)/国内(含港澳台) → 放行
@@ -298,13 +278,17 @@ public class DefaultPayRiskChecker implements PayRiskChecker {
if (StrUtil.isBlank(ip)) {
return;
}
// IPv6 暂不参与地区判定
// IPv6 受 ipv6MatchEnabled 开关控制(默认关闭, 离线数据精度有限)
if (Validator.isIpv6(ip)) {
return;
}
// 内网/回环地址直通放行
if (NetUtil.isInnerIP(ip)) {
return;
if (!Boolean.TRUE.equals(ctx.getIpv6MatchEnabled())) {
return;
}
// 开关开启: 跳过 NetUtil.isInnerIP(仅支持 IPv4, IPv6 内网由 xdb 查询 fail-open 兜底
} else {
// IPv4 内网/回环地址直通放行
if (NetUtil.isInnerIP(ip)) {
return;
}
}
IpRegion region = ipToRegionService.getRegionByIp(ip);
if (region == null) {

View File

@@ -1,6 +1,7 @@
package cn.daxpay.open.plugin.risk.strategy;
import cn.daxpay.open.payment.strategy.risk.PayRiskCheckContext;
import cn.daxpay.open.payment.strategy.risk.RegionCodeResolver;
import cn.daxpay.open.platform.capability.audit.log.service.ip2region.IpRegion;
import cn.daxpay.open.platform.capability.audit.log.service.ip2region.IpToRegionService;
import cn.daxpay.open.platform.core.enums.pay.channel.ChannelEnum;
@@ -58,6 +59,7 @@ class DefaultPayRiskCheckerTest {
private static final String WECHAT_OPENID_TYPE = PayBlacklistTypeEnum.WECHAT_OPENID.getCode();
private static final String OVERSEAS_IP_TYPE = PayBlacklistTypeEnum.OVERSEAS_IP.getCode();
private static final String PROVINCE_TYPE = PayBlacklistTypeEnum.PROVINCE.getCode();
private static final String CITY_TYPE = PayBlacklistTypeEnum.CITY.getCode();
private static final String BLACKLIST_MSG_KEY = "pay.error.risk.blacklist";
@Mock
@@ -66,12 +68,14 @@ class DefaultPayRiskCheckerTest {
private PayRiskHitService payRiskHitService;
@Mock
private IpToRegionService ipToRegionService;
@Mock
private RegionCodeResolver regionCodeResolver;
private DefaultPayRiskChecker checker;
@BeforeEach
void setUp() {
checker = new DefaultPayRiskChecker(payBlacklistService, payRiskHitService, ipToRegionService);
checker = new DefaultPayRiskChecker(payBlacklistService, payRiskHitService, ipToRegionService, regionCodeResolver);
// 名单默认不命中, 让流程走到海外检查; 个别用例用 eq() 精确覆写为命中
lenient().when(payBlacklistService.findActive(anyString(), anyString(), any()))
.thenReturn(Optional.empty());
@@ -130,7 +134,7 @@ class DefaultPayRiskCheckerTest {
}
@Test
@DisplayName("海外检查: IPv6 回环直通放行(NetUtil.isInnerIP 仅支持 IPv4, 传入 IPv6 会抛异常)")
@DisplayName("海外检查: IPv6 回环直通放行(ipv6MatchEnabled 默认关闭)")
void overseasIp_ipv6Loopback_shouldPassThroughWithoutLookup() {
assertDoesNotThrow(() -> checker.checkBeforePay(overseasCtx("::1")));
@@ -139,7 +143,7 @@ class DefaultPayRiskCheckerTest {
}
@Test
@DisplayName("海外检查: IPv6 公网地址直通放行")
@DisplayName("海外检查: IPv6 公网地址直通放行(ipv6MatchEnabled 默认关闭)")
void overseasIp_ipv6Public_shouldPassThroughWithoutLookup() {
assertDoesNotThrow(() -> checker.checkBeforePay(overseasCtx("2409:8a20:210:0:0:0:0:1")));
@@ -147,6 +151,35 @@ class DefaultPayRiskCheckerTest {
verifyNoInteractions(payRiskHitService);
}
@Test
@DisplayName("海外检查: ipv6MatchEnabled 开启时 IPv6 海外地址命中拦截")
void overseasIp_ipv6MatchEnabled_shouldLookupAndBlock() {
// 开关开启: IPv6 走 getRegionByIp 查询
IpRegion overseas = new IpRegion();
overseas.setCountry("United States");
when(ipToRegionService.getRegionByIp("2606:4700:4700::1111")).thenReturn(overseas);
assertThrows(BizInfoException.class,
() -> checker.checkBeforePay(overseasCtx("2606:4700:4700::1111").setIpv6MatchEnabled(true)));
verify(ipToRegionService).getRegionByIp("2606:4700:4700::1111");
verify(payRiskHitService).recordHit(any(), eq(OVERSEAS_IP_TYPE), eq("2606:4700:4700::1111"), eq(null));
}
@Test
@DisplayName("海外检查: ipv6MatchEnabled 开启时 IPv6 国内地址放行")
void overseasIp_ipv6MatchEnabled_chinaIpShouldPass() {
// 开关开启: IPv6 走查询, 结果为中国 → 放行
IpRegion china = new IpRegion();
china.setCountry("中国");
when(ipToRegionService.getRegionByIp("240e:3b7::1")).thenReturn(china);
assertDoesNotThrow(() -> checker.checkBeforePay(overseasCtx("240e:3b7::1").setIpv6MatchEnabled(true)));
verify(ipToRegionService).getRegionByIp("240e:3b7::1");
verify(payRiskHitService, never()).recordHit(any(), eq(OVERSEAS_IP_TYPE), anyString(), any());
}
@Test
@DisplayName("海外检查: xdb 查询失败(region=null)放行不记录")
void overseasIp_lookupReturnsNull_shouldPass() {
@@ -278,7 +311,7 @@ class DefaultPayRiskCheckerTest {
.setOpenId("uid-1")
.setBlockOverseasIp(false)
.setBlacklistEnabled(null)
.setProvinceBlacklistEnabled(false)
.setRegionBlacklistEnabled(false)
.setBlockOnHit(true);
assertDoesNotThrow(() -> checker.checkBeforePay(ctx));
@@ -441,32 +474,34 @@ class DefaultPayRiskCheckerTest {
verify(payRiskHitService, never()).recordHit(any(), eq(ALIPAY_USER_TYPE), eq("uid-a"), any());
}
// ==================== 省级地区黑名单 ====================
// ==================== 地区黑名单(省级 + 市级, 合并开关) ====================
@Test
@DisplayName("省级名单: provinceBlacklistEnabled 关闭(null/false)时不触发检查")
void provinceBlacklist_switchOff_shouldNotCheck() {
@DisplayName("地区拦截: regionBlacklistEnabled 关闭(null/false)时不触发检查")
void regionBlacklist_switchOff_shouldNotCheck() {
when(ipToRegionService.getRegionByIp("114.114.114.114"))
.thenReturn(regionWithProvince("中国", "广东省", "电信"));
.thenReturn(regionWithCity("中国", "广东省", "深圳市", "电信"));
PayRiskCheckContext ctx = new PayRiskCheckContext()
.setClientIp("114.114.114.114")
.setBlockOverseasIp(false)
.setProvinceBlacklistEnabled(null)
.setRegionBlacklistEnabled(null)
.setBlockOnHit(true);
assertDoesNotThrow(() -> checker.checkBeforePay(ctx));
// 开关关闭 → 不调 ip2region, 不查省级名单
// 开关关闭 → 不调 ip2region, 不查省/市级名单
verify(ipToRegionService, never()).getRegionByIp(anyString());
verify(payBlacklistService, never()).findActive(eq(PROVINCE_TYPE), anyString(), any());
verify(payBlacklistService, never()).findActive(eq(CITY_TYPE), anyString(), any());
}
@Test
@DisplayName("省级名单: IP 归属省在名单中命中阻断")
void provinceBlacklist_hit_shouldThrow() {
@DisplayName("地区拦截: IP 归属省在省级名单中命中阻断")
void regionBlacklist_provinceHit_shouldThrow() {
when(ipToRegionService.getRegionByIp("114.114.114.114"))
.thenReturn(regionWithProvince("中国", "广东省", "电信"));
when(payBlacklistService.findActive(eq(PROVINCE_TYPE), eq("广东省"), eq(null)))
.thenReturn(regionWithCity("中国", "广东省", "深圳市", "电信"));
when(regionCodeResolver.resolveProvinceCode("广东省")).thenReturn("44");
when(payBlacklistService.findActive(eq(PROVINCE_TYPE), eq("44"), eq(null)))
.thenReturn(Optional.of(bl(55L)));
PayRiskCheckContext ctx = noOverseasCtx().setClientIp("114.114.114.114");
@@ -474,57 +509,99 @@ class DefaultPayRiskCheckerTest {
() -> checker.checkBeforePay(ctx));
assertEquals(BLACKLIST_MSG_KEY, ex.getMessageKey());
verify(payRiskHitService).recordHit(any(), eq(PROVINCE_TYPE), eq("广东省"), eq(55L));
verify(payRiskHitService).recordHit(any(), eq(PROVINCE_TYPE), eq("44"), eq(55L));
}
@Test
@DisplayName("省级名单: IP 归属省不在黑名单中放行")
void provinceBlacklist_miss_shouldPass() {
@DisplayName("地区拦截: 省级名单命中后短路市级检查")
void regionBlacklist_provinceHit_shouldShortCircuitCity() {
when(ipToRegionService.getRegionByIp("114.114.114.114"))
.thenReturn(regionWithProvince("中国", "广东省", "电信"));
.thenReturn(regionWithCity("中国", "广东省", "深圳市", "电信"));
when(regionCodeResolver.resolveProvinceCode("广东省")).thenReturn("44");
when(payBlacklistService.findActive(eq(PROVINCE_TYPE), eq("44"), eq(null)))
.thenReturn(Optional.of(bl(55L)));
PayRiskCheckContext ctx = noOverseasCtx().setClientIp("114.114.114.114");
assertThrows(BizInfoException.class, () -> checker.checkBeforePay(ctx));
// 省命中 → 不查市级名单
verify(payBlacklistService).findActive(eq(PROVINCE_TYPE), eq("44"), eq(null));
verify(payBlacklistService, never()).findActive(eq(CITY_TYPE), anyString(), any());
}
@Test
@DisplayName("地区拦截: 省级未命中、市级命中时阻断")
void regionBlacklist_cityHit_shouldThrow() {
when(ipToRegionService.getRegionByIp("114.114.114.114"))
.thenReturn(regionWithCity("中国", "广东省", "深圳市", "电信"));
when(regionCodeResolver.resolveProvinceCode("广东省")).thenReturn("44");
when(regionCodeResolver.resolveCityCode("广东省", "深圳市")).thenReturn("4403");
when(payBlacklistService.findActive(eq(CITY_TYPE), eq("4403"), eq(null)))
.thenReturn(Optional.of(bl(66L)));
PayRiskCheckContext ctx = noOverseasCtx().setClientIp("114.114.114.114");
BizInfoException ex = assertThrows(BizInfoException.class,
() -> checker.checkBeforePay(ctx));
assertEquals(BLACKLIST_MSG_KEY, ex.getMessageKey());
verify(payRiskHitService).recordHit(any(), eq(CITY_TYPE), eq("4403"), eq(66L));
}
@Test
@DisplayName("地区拦截: 省/市名单均未命中放行")
void regionBlacklist_miss_shouldPass() {
when(ipToRegionService.getRegionByIp("114.114.114.114"))
.thenReturn(regionWithCity("中国", "广东省", "深圳市", "电信"));
when(regionCodeResolver.resolveProvinceCode("广东省")).thenReturn("44");
when(regionCodeResolver.resolveCityCode("广东省", "深圳市")).thenReturn("4403");
PayRiskCheckContext ctx = noOverseasCtx().setClientIp("114.114.114.114");
assertDoesNotThrow(() -> checker.checkBeforePay(ctx));
verify(payBlacklistService).findActive(eq(PROVINCE_TYPE), eq("广东省"), eq(null));
verify(payRiskHitService, never()).recordHit(any(), eq(PROVINCE_TYPE), anyString(), any());
// 省级查一次未命中, 继续查市级
verify(payBlacklistService).findActive(eq(PROVINCE_TYPE), eq("44"), eq(null));
verify(payBlacklistService).findActive(eq(CITY_TYPE), eq("4403"), eq(null));
verify(payRiskHitService, never()).recordHit(any(), anyString(), anyString(), any());
}
@Test
@DisplayName("省级名单: 内网 IP 不查省份")
void provinceBlacklist_innerIp_shouldSkip() {
@DisplayName("地区拦截: 内网 IP 不查归属地")
void regionBlacklist_innerIp_shouldSkip() {
PayRiskCheckContext ctx = noOverseasCtx().setClientIp("192.168.1.1");
assertDoesNotThrow(() -> checker.checkBeforePay(ctx));
verify(ipToRegionService, never()).getRegionByIp(anyString());
verify(payBlacklistService, never()).findActive(eq(PROVINCE_TYPE), anyString(), any());
verify(payBlacklistService, never()).findActive(eq(CITY_TYPE), anyString(), any());
}
@Test
@DisplayName("省级名单: ip2region 返回 null fail-open")
void provinceBlacklist_nullRegion_shouldFailOpen() {
@DisplayName("地区拦截: ip2region 返回 null fail-open")
void regionBlacklist_nullRegion_shouldFailOpen() {
when(ipToRegionService.getRegionByIp("114.114.114.114")).thenReturn(null);
PayRiskCheckContext ctx = noOverseasCtx().setClientIp("114.114.114.114");
assertDoesNotThrow(() -> checker.checkBeforePay(ctx));
verify(payBlacklistService, never()).findActive(eq(PROVINCE_TYPE), anyString(), any());
verify(payBlacklistService, never()).findActive(eq(CITY_TYPE), anyString(), any());
verify(payRiskHitService, never()).recordHit(any(), eq(PROVINCE_TYPE), anyString(), any());
}
@Test
@DisplayName("省级名单: checkAfterPay 事后补录仅记录不阻断")
void provinceBlacklist_afterPay_shouldRecordOnly() {
@DisplayName("地区拦截: checkAfterPay 事后补录仅记录不阻断")
void regionBlacklist_afterPay_shouldRecordOnly() {
when(ipToRegionService.getRegionByIp("114.114.114.114"))
.thenReturn(regionWithProvince("中国", "广东省", "电信"));
when(payBlacklistService.findActive(eq(PROVINCE_TYPE), eq("广东省"), eq(null)))
.thenReturn(regionWithCity("中国", "广东省", "深圳市", "电信"));
when(regionCodeResolver.resolveProvinceCode("广东省")).thenReturn("44");
when(payBlacklistService.findActive(eq(PROVINCE_TYPE), eq("44"), eq(null)))
.thenReturn(Optional.of(bl(55L)));
PayRiskCheckContext ctx = noOverseasCtx().setClientIp("114.114.114.114");
assertDoesNotThrow(() -> checker.checkAfterPay(ctx));
verify(payRiskHitService).recordHit(any(), eq(PROVINCE_TYPE), eq("广东省"), eq(55L));
verify(payRiskHitService).recordHit(any(), eq(PROVINCE_TYPE), eq("44"), eq(55L));
}
// ==================== hasOpenIdBlacklist 缓存 ====================
@@ -567,7 +644,7 @@ class DefaultPayRiskCheckerTest {
return new PayRiskCheckContext()
.setBlockOverseasIp(false)
.setBlacklistEnabled(true)
.setProvinceBlacklistEnabled(true)
.setRegionBlacklistEnabled(true)
.setBlockOnHit(true);
}
@@ -584,8 +661,8 @@ class DefaultPayRiskCheckerTest {
return new IpRegion().setCountry(country).setIsp(isp);
}
/** 构造含 province 的 IpRegion省级名单测试用) */
private static IpRegion regionWithProvince(String country, String province, String isp) {
return new IpRegion().setCountry(country).setProvince(province).setIsp(isp);
/** 构造含 province/city 的 IpRegion地区名单测试用) */
private static IpRegion regionWithCity(String country, String province, String city, String isp) {
return new IpRegion().setCountry(country).setProvince(province).setCity(city).setIsp(isp);
}
}