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 3d64d44e3..a3ef0be3b 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 @@ -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=启用门店市级围栏检测) /// 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 9a231a143..8a5aefd9c 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 @@ -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()); // 黑名单拦截开关(第一层, 事后补录) diff --git a/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/en-US/validation.json b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/en-US/validation.json index ce8c90cf7..fc4c98655 100644 --- a/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/en-US/validation.json +++ b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/en-US/validation.json @@ -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" diff --git a/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/id-ID/validation.json b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/id-ID/validation.json index 52c4d5900..19cfc90e7 100644 --- a/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/id-ID/validation.json +++ b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/id-ID/validation.json @@ -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" diff --git a/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/ja-JP/validation.json b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/ja-JP/validation.json index 122309a22..40c5fb564 100644 --- a/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/ja-JP/validation.json +++ b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/ja-JP/validation.json @@ -807,11 +807,11 @@ "blockOverseasIp": { "notNull": "海外 IP ブロック スイッチは必須です" }, - "provinceBlacklistEnabled": { - "notNull": "省レベルの地域遮断スイッチは必須です" + "regionBlacklistEnabled": { + "notNull": "地域遮断スイッチは必須です" }, - "cityBlacklistEnabled": { - "notNull": "市レベルの地域遮断スイッチは必須です" + "ipv6MatchEnabled": { + "notNull": "IPv6 地域マッチングスイッチは必須です" }, "geoFenceEnabled": { "notNull": "ジオフェンススイッチは必須です" diff --git a/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/ko-KR/validation.json b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/ko-KR/validation.json index 1e67fe561..d317b8f19 100644 --- a/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/ko-KR/validation.json +++ b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/ko-KR/validation.json @@ -807,11 +807,11 @@ "blockOverseasIp": { "notNull": "해외 IP 차단 스위치는 필수입니다" }, - "provinceBlacklistEnabled": { - "notNull": "성 지역 차단 스위치는 필수입니다" + "regionBlacklistEnabled": { + "notNull": "지역 차단 스위치는 필수입니다" }, - "cityBlacklistEnabled": { - "notNull": "시 지역 차단 스위치는 필수입니다" + "ipv6MatchEnabled": { + "notNull": "IPv6 지역 매칭 스위치는 필수입니다" }, "geoFenceEnabled": { "notNull": "지오펜스 스위치는 필수입니다" diff --git a/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/ms-MY/validation.json b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/ms-MY/validation.json index 973b6bea5..f8caf29de 100644 --- a/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/ms-MY/validation.json +++ b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/ms-MY/validation.json @@ -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" diff --git a/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/th-TH/validation.json b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/th-TH/validation.json index 6c5f66c27..e04d2c8fd 100644 --- a/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/th-TH/validation.json +++ b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/th-TH/validation.json @@ -814,11 +814,11 @@ "blockOverseasIp": { "notNull": "สวิตช์บล็อก IP ต่างประเทศต้องไม่ว่าง" }, - "provinceBlacklistEnabled": { - "notNull": "สวิตช์บล็อกภูมิภาคระดับจังหวัดต้องไม่ว่าง" + "regionBlacklistEnabled": { + "notNull": "สวิตช์บล็อกภูมิภาคต้องไม่ว่าง" }, - "cityBlacklistEnabled": { - "notNull": "สวิตช์บล็อกภูมิภาคระดับเมืองต้องไม่ว่าง" + "ipv6MatchEnabled": { + "notNull": "สวิตช์จับคู่ภูมิภาค IPv6 ต้องไม่ว่าง" }, "geoFenceEnabled": { "notNull": "สวิตช์รั้วภูมิศาสตร์ต้องไม่ว่าง" diff --git a/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/vi-VN/validation.json b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/vi-VN/validation.json index c28bb77ad..1ebc3c069 100644 --- a/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/vi-VN/validation.json +++ b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/vi-VN/validation.json @@ -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" diff --git a/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/zh-CN/validation.json b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/zh-CN/validation.json index c5ed7d244..b32dc7b96 100644 --- a/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/zh-CN/validation.json +++ b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/zh-CN/validation.json @@ -811,11 +811,11 @@ "blockOverseasIp": { "notNull": "海外 IP 拦截开关不能为空" }, - "provinceBlacklistEnabled": { - "notNull": "省级地区拦截开关不能为空" + "regionBlacklistEnabled": { + "notNull": "地区拦截开关不能为空" }, - "cityBlacklistEnabled": { - "notNull": "市级地区拦截开关不能为空" + "ipv6MatchEnabled": { + "notNull": "IPv6 地区匹配开关不能为空" }, "geoFenceEnabled": { "notNull": "地理围栏开关不能为空" diff --git a/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/zh-HK/validation.json b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/zh-HK/validation.json index 42eb7c832..a482ba7ed 100644 --- a/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/zh-HK/validation.json +++ b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/zh-HK/validation.json @@ -807,11 +807,11 @@ "blockOverseasIp": { "notNull": "海外 IP 攔截開關不能為空" }, - "provinceBlacklistEnabled": { - "notNull": "省級地區攔截開關不能為空" + "regionBlacklistEnabled": { + "notNull": "地區攔截開關不能為空" }, - "cityBlacklistEnabled": { - "notNull": "市級地區攔截開關不能為空" + "ipv6MatchEnabled": { + "notNull": "IPv6 地區匹配開關不能為空" }, "geoFenceEnabled": { "notNull": "地理圍欄開關不能為空" diff --git a/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/zh-TW/validation.json b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/zh-TW/validation.json index 7613a40a2..fbb05be45 100644 --- a/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/zh-TW/validation.json +++ b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/zh-TW/validation.json @@ -807,11 +807,11 @@ "blockOverseasIp": { "notNull": "海外 IP 攔截開關不能為空" }, - "provinceBlacklistEnabled": { - "notNull": "省級地區攔截開關不能為空" + "regionBlacklistEnabled": { + "notNull": "地區攔截開關不能為空" }, - "cityBlacklistEnabled": { - "notNull": "市級地區攔截開關不能為空" + "ipv6MatchEnabled": { + "notNull": "IPv6 地區匹配開關不能為空" }, "geoFenceEnabled": { "notNull": "地理圍欄開關不能為空" diff --git a/daxpay-platform/daxpay-platform-service/service-system/src/main/java/cn/daxpay/open/platform/system/entity/config/platform/security/PlatformPaySecurityConfig.java b/daxpay-platform/daxpay-platform-service/service-system/src/main/java/cn/daxpay/open/platform/system/entity/config/platform/security/PlatformPaySecurityConfig.java index 38290c001..2fcb43c0c 100644 --- a/daxpay-platform/daxpay-platform-service/service-system/src/main/java/cn/daxpay/open/platform/system/entity/config/platform/security/PlatformPaySecurityConfig.java +++ b/daxpay-platform/daxpay-platform-service/service-system/src/main/java/cn/daxpay/open/platform/system/entity/config/platform/security/PlatformPaySecurityConfig.java @@ -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; diff --git a/daxpay-platform/daxpay-platform-service/service-system/src/main/java/cn/daxpay/open/platform/system/param/config/security/PlatformPaySecurityConfigParam.java b/daxpay-platform/daxpay-platform-service/service-system/src/main/java/cn/daxpay/open/platform/system/param/config/security/PlatformPaySecurityConfigParam.java index 8b204c77f..3e10d621e 100644 --- a/daxpay-platform/daxpay-platform-service/service-system/src/main/java/cn/daxpay/open/platform/system/param/config/security/PlatformPaySecurityConfigParam.java +++ b/daxpay-platform/daxpay-platform-service/service-system/src/main/java/cn/daxpay/open/platform/system/param/config/security/PlatformPaySecurityConfigParam.java @@ -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}") diff --git a/daxpay-platform/daxpay-platform-service/service-system/src/main/java/cn/daxpay/open/platform/system/result/config/security/PlatformPaySecurityConfigResult.java b/daxpay-platform/daxpay-platform-service/service-system/src/main/java/cn/daxpay/open/platform/system/result/config/security/PlatformPaySecurityConfigResult.java index c45068809..dc69d5b9f 100644 --- a/daxpay-platform/daxpay-platform-service/service-system/src/main/java/cn/daxpay/open/platform/system/result/config/security/PlatformPaySecurityConfigResult.java +++ b/daxpay-platform/daxpay-platform-service/service-system/src/main/java/cn/daxpay/open/platform/system/result/config/security/PlatformPaySecurityConfigResult.java @@ -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; 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 d7f2ae605..34a488336 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 @@ -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); } } 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 3759bb589..17626d478 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 @@ -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) { diff --git a/daxpay-plugin/daxpay-plugin-risk/src/test/java/cn/daxpay/open/plugin/risk/strategy/DefaultPayRiskCheckerTest.java b/daxpay-plugin/daxpay-plugin-risk/src/test/java/cn/daxpay/open/plugin/risk/strategy/DefaultPayRiskCheckerTest.java index 3826e7cd0..b2d839f12 100644 --- a/daxpay-plugin/daxpay-plugin-risk/src/test/java/cn/daxpay/open/plugin/risk/strategy/DefaultPayRiskCheckerTest.java +++ b/daxpay-plugin/daxpay-plugin-risk/src/test/java/cn/daxpay/open/plugin/risk/strategy/DefaultPayRiskCheckerTest.java @@ -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); } }