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:
daxpay
2026-08-06 10:37:33 +08:00
parent 2bd3d497be
commit 600b567759
8 changed files with 263 additions and 18 deletions

View File

@@ -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 '支付风险命中记录(事前拦截与事后命中,供运营预警与处置)';
-- ----------------------------

View File

@@ -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;
/// 是否拦截海外 IPnull/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;
}

View File

@@ -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);
}
}

View File

@@ -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;
}

View File

@@ -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);

View File

@@ -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;
}

View File

@@ -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);
}

View File

@@ -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;
}
}