feat(risk): 商户风控配置独立表与地理围栏三级策略改造, 新增交界城市预览演示

This commit is contained in:
daxpay
2026-08-06 14:49:38 +08:00
parent df997d0a28
commit 7a928c00f1
55 changed files with 1054 additions and 78 deletions

View File

@@ -3865,6 +3865,7 @@ INSERT INTO iam_perm_menu VALUES (91405, 91403, 'merchant:gateway-pay-config', '
INSERT INTO iam_perm_menu VALUES (409, 4, 'payment:app', 'admin', 'PaymentApp', 'menu.payment.app', 'lucide:layout-grid', false, false, NULL, '/payment/app', NULL, 30, false, true, false, 1, 1, 0, false, 'catalog', NULL, NULL, NULL, NULL, NULL, NULL, '2026-07-31 03:18:16.99898+00', '2026-07-31 03:18:16.99898+00');
INSERT INTO iam_perm_menu VALUES (91110, 91100, 'trade:pay-order', 'merchant', 'PayOrderCatalog', 'menu.trade.payOrder', 'lucide:receipt', false, false, NULL, '/trade/pay-order', '/trade/pay-order/normal', 3, false, true, false, 0, NULL, 0, false, 'catalog', NULL, NULL, NULL, NULL, NULL, NULL, '2026-08-01 01:15:14.072785+00', '2026-08-01 01:15:14.072785+00');
INSERT INTO iam_perm_menu VALUES (206, 2, 'demos:callback', 'admin', 'CallbackDemo', 'menu.demos.callback', 'lucide:webhook', false, false, '/demos/callback/CallbackDemo', '/demos/callback', NULL, 5, false, true, false, 0, NULL, 0, false, 'menu', NULL, NULL, NULL, NULL, NULL, NULL, '2026-08-01 16:00:00+00', '2026-08-01 16:00:00+00');
INSERT INTO iam_perm_menu VALUES (207, 2, 'demos:city-adjacent', 'admin', 'CityAdjacentDemo', 'menu.demos.cityAdjacent', 'lucide:map-pinned', false, false, '/demos/city-adjacent/CityAdjacentDemo', '/demos/city-adjacent', NULL, 6, false, true, false, 0, NULL, 0, false, 'menu', NULL, NULL, NULL, NULL, NULL, NULL, '2026-08-06 16:00:00+00', '2026-08-06 16:00:00+00');
INSERT INTO iam_perm_menu VALUES (40108, 409, 'payment:douyin:platform-app', 'admin', 'DyAppHub', 'menu.payment.douyin.app', 'lucide:music-2', false, false, '/payment/douyin/DyAppHub', '/payment/douyin/app', NULL, 20, false, true, false, 1, 1, 0, false, 'menu', NULL, NULL, NULL, NULL, NULL, NULL, '2026-07-28 14:00:00+00', '2026-07-31 03:18:17.0111+00');
INSERT INTO iam_perm_menu VALUES (91420, 91400, 'payment:douyin:mch-app', 'merchant', 'MchDyAppList', 'menu.payment.douyin.mchApp', 'lucide:music-2', false, false, '/payment/douyin/mch/MchDyAppList', '/mch/douyin-app', NULL, 4, false, false, false, 0, 1, 1, false, 'menu', NULL, NULL, NULL, NULL, '', '', '2026-07-28 14:00:00+00', '2026-07-28 14:00:00+00');
INSERT INTO iam_perm_menu VALUES (611, 6, 'trade:transfer', 'admin', 'TransferCatalog', 'menu.trade.transfer', 'lucide:send', false, true, NULL, '/trade/transfer', '/trade/transfer/wechat', 4, false, true, false, 1, 1, 0, false, 'catalog', NULL, NULL, NULL, NULL, NULL, NULL, '2026-08-04 16:00:00+00', '2026-08-04 16:00:00+00');

View File

@@ -1738,7 +1738,6 @@ 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),
@@ -1754,7 +1753,6 @@ 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 '创建时间';
@@ -1763,6 +1761,48 @@ COMMENT ON COLUMN "public"."mch_info"."last_modified_time" IS '最后修改时
COMMENT ON COLUMN "public"."mch_info"."version" IS '版本号(乐观锁)';
COMMENT ON TABLE "public"."mch_info" IS '商户信息表';
-- ----------------------------
-- Table structure for mch_risk_config
-- ----------------------------
DROP TABLE IF EXISTS "public"."mch_risk_config";
CREATE TABLE "public"."mch_risk_config" (
"id" int8 NOT NULL,
"mch_no" varchar(32) COLLATE "pg_catalog"."default" NOT NULL,
"geo_fence_enabled" bool NOT NULL DEFAULT false,
"deleted" bool NOT NULL DEFAULT false,
"creator" int8,
"create_time" timestamp(6),
"last_modifier" int8,
"last_modified_time" timestamp(6),
"version" int4 NOT NULL DEFAULT 0
)
;
COMMENT ON COLUMN "public"."mch_risk_config"."id" IS '主键';
COMMENT ON COLUMN "public"."mch_risk_config"."mch_no" IS '商户号';
COMMENT ON COLUMN "public"."mch_risk_config"."geo_fence_enabled" IS '是否启用地理围栏(商户级 opt-in';
COMMENT ON COLUMN "public"."mch_risk_config"."deleted" IS '删除标志';
COMMENT ON COLUMN "public"."mch_risk_config"."creator" IS '创建者ID';
COMMENT ON COLUMN "public"."mch_risk_config"."create_time" IS '创建时间';
COMMENT ON COLUMN "public"."mch_risk_config"."last_modifier" IS '最后修改者ID';
COMMENT ON COLUMN "public"."mch_risk_config"."last_modified_time" IS '最后修改时间';
COMMENT ON COLUMN "public"."mch_risk_config"."version" IS '版本号(乐观锁)';
COMMENT ON TABLE "public"."mch_risk_config" IS '商户风控配置表';
-- ----------------------------
-- Table structure for base_city_adjacent
-- ----------------------------
DROP TABLE IF EXISTS "public"."base_city_adjacent";
CREATE TABLE "public"."base_city_adjacent" (
"id" bigserial NOT NULL,
"city_code" varchar(4) COLLATE "pg_catalog"."default" NOT NULL,
"adjacent_city_code" varchar(4) COLLATE "pg_catalog"."default" NOT NULL
)
;
COMMENT ON COLUMN "public"."base_city_adjacent"."id" IS '主键IDDB 自增, 纯关系表数据导入专用)';
COMMENT ON COLUMN "public"."base_city_adjacent"."city_code" IS '城市编码base_city.code';
COMMENT ON COLUMN "public"."base_city_adjacent"."adjacent_city_code" IS '相邻城市编码base_city.code';
COMMENT ON TABLE "public"."base_city_adjacent" IS '城市接壤关系表(双向存储,围栏 balanced 邻市容错用)';
-- ----------------------------
-- Table structure for mch_notice_record
-- ----------------------------
@@ -3141,7 +3181,8 @@ CREATE TABLE "public"."pay_risk_hit" (
"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"
"store_no" varchar(64) COLLATE "pg_catalog"."default",
"geo_fence_strategy" varchar(16) COLLATE "pg_catalog"."default"
)
;
COMMENT ON COLUMN "public"."pay_risk_hit"."id" IS '主键';
@@ -3172,6 +3213,7 @@ 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 COLUMN "public"."pay_risk_hit"."geo_fence_strategy" IS '地理围栏命中时生效的策略strict/balanced/loose';
COMMENT ON TABLE "public"."pay_risk_hit" IS '支付风险命中记录(事前拦截与事后命中,供运营预警与处置)';
-- ----------------------------
@@ -5279,6 +5321,24 @@ COMMENT ON INDEX "public"."idx_mch_info_status" IS '状态筛选';
-- ----------------------------
ALTER TABLE "public"."mch_info" ADD CONSTRAINT "mch_info_pkey" PRIMARY KEY ("id");
-- ----------------------------
-- Primary Key / Indexes structure for table mch_risk_config
-- ----------------------------
ALTER TABLE "public"."mch_risk_config" ADD CONSTRAINT "mch_risk_config_pkey" PRIMARY KEY ("id");
CREATE UNIQUE INDEX "uk_mch_risk_config_mch_no" ON "public"."mch_risk_config" USING btree ("mch_no");
COMMENT ON INDEX "uk_mch_risk_config_mch_no" IS '同一商户风控配置唯一1:1 商户)';
-- ----------------------------
-- Primary Key structure for table base_city_adjacent
-- ----------------------------
ALTER TABLE "public"."base_city_adjacent" ADD CONSTRAINT "base_city_adjacent_pkey" PRIMARY KEY ("id");
-- ----------------------------
-- Indexes structure for table base_city_adjacent
-- ----------------------------
CREATE UNIQUE INDEX "uk_base_city_adjacent" ON "public"."base_city_adjacent" USING btree ("city_code", "adjacent_city_code");
COMMENT ON INDEX "uk_base_city_adjacent" IS '同一城市与其相邻城市关系唯一(防重复灌入)';
-- ----------------------------
-- Indexes structure for table mch_notice_record
-- ----------------------------

View File

@@ -0,0 +1,49 @@
package cn.daxpay.open.payment.admin.controller.merchant.config;
import cn.daxpay.open.payment.merchant.param.config.MchRiskConfigParam;
import cn.daxpay.open.payment.merchant.result.config.MchRiskConfigResult;
import cn.daxpay.open.payment.merchant.service.config.MchRiskConfigService;
import cn.daxpay.open.platform.core.annotation.PermCode;
import cn.daxpay.open.platform.core.code.PermCodes;
import cn.daxpay.open.platform.core.rest.Res;
import cn.daxpay.open.platform.core.rest.result.Result;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.constraints.NotBlank;
import lombok.RequiredArgsConstructor;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/// # 商户风控配置管理控制器(运营端)
///
/// 本期仅运营端可配置商户级风控(地理围栏),商户端暂不自助修改以防规避。
@PermCode(menuCode = PermCodes.Payment.Risk.MchConfig.MENU)
@Validated
@Tag(name = "商户风控配置管理")
@RestController
@RequestMapping("/admin/merchant/risk-config")
@RequiredArgsConstructor
public class MchRiskConfigAdminController {
private final MchRiskConfigService mchRiskConfigService;
@PermCode(code = PermCodes.Action.VIEW)
@Operation(summary = "根据商户号查询风控配置")
@GetMapping("/get-by-mch-no")
public Result<MchRiskConfigResult> findByMchNo(
@NotBlank(message = "{validation.field.mchNo.notBlank}") String mchNo) {
return Res.ok(mchRiskConfigService.findByMchNo(mchNo));
}
@PermCode(code = PermCodes.Action.MANAGE)
@Operation(summary = "保存或更新风控配置")
@PostMapping("/save-or-update")
public Result<Void> saveOrUpdate(@RequestBody @Validated MchRiskConfigParam param) {
mchRiskConfigService.saveOrUpdate(param);
return Res.ok();
}
}

View File

@@ -0,0 +1,29 @@
package cn.daxpay.open.payment.merchant.convert.config;
import cn.daxpay.open.payment.merchant.entity.config.MchRiskConfig;
import cn.daxpay.open.payment.merchant.param.config.MchRiskConfigParam;
import cn.daxpay.open.payment.merchant.result.config.MchRiskConfigResult;
import org.mapstruct.BeanMapping;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
import org.mapstruct.MappingTarget;
import org.mapstruct.NullValuePropertyMappingStrategy;
import org.mapstruct.factory.Mappers;
/// # 商户风控配置转换
///
/// 运营端更新时 mchNo 由 Service 显式 [MchRiskConfig#setMchNo] 写入(运营端不装载商户 PaymentContext,
/// 不能依赖 FieldFill), 故转实体时忽略 mchNo(避免 param 的 mchNo 覆盖, 统一在 Service 兜底)。
@Mapper
public interface MchRiskConfigConvert {
MchRiskConfigConvert CONVERT = Mappers.getMapper(MchRiskConfigConvert.class);
@Mapping(target = "mchNo", ignore = true)
MchRiskConfig toEntity(MchRiskConfigParam param);
MchRiskConfigResult toResult(MchRiskConfig entity);
@BeanMapping(nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE)
@Mapping(target = "mchNo", ignore = true)
void copy(MchRiskConfigParam param, @MappingTarget MchRiskConfig entity);
}

View File

@@ -0,0 +1,18 @@
package cn.daxpay.open.payment.merchant.dao.config;
import cn.daxpay.open.platform.common.mybatisplus.impl.BaseManager;
import cn.daxpay.open.payment.merchant.entity.config.MchRiskConfig;
import org.springframework.stereotype.Repository;
import java.util.Optional;
/// # 商户风控配置管理
///
@Repository
public class MchRiskConfigManager extends BaseManager<MchRiskConfigMapper, MchRiskConfig> {
/// 根据商户号查询风控配置
public Optional<MchRiskConfig> findByMchNo(String mchNo) {
return findByField(MchRiskConfig::getMchNo, mchNo);
}
}

View File

@@ -0,0 +1,11 @@
package cn.daxpay.open.payment.merchant.dao.config;
import cn.daxpay.open.payment.merchant.entity.config.MchRiskConfig;
import com.github.yulichang.base.MPJBaseMapper;
import org.apache.ibatis.annotations.Mapper;
/// # 商户风控配置
///
@Mapper
public interface MchRiskConfigMapper extends MPJBaseMapper<MchRiskConfig> {
}

View File

@@ -0,0 +1,31 @@
package cn.daxpay.open.payment.merchant.entity.config;
import cn.daxpay.open.payment.common.entity.MchBaseEntity;
import cn.daxpay.open.payment.merchant.convert.config.MchRiskConfigConvert;
import cn.daxpay.open.payment.merchant.result.config.MchRiskConfigResult;
import cn.daxpay.open.platform.common.mybatisplus.function.ToResult;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.experimental.Accessors;
/// # 商户风控配置
///
/// 商户级风控配置, 与商户 1:1。本期承载地理围栏 opt-in 开关, 预留扩展其他商户级风控。
/// 围栏两级门控: 平台总闸 [cn.daxpay.open.platform.system.entity.config.platform.security.PlatformPaySecurityConfig#getGeoFenceEnabled]
/// AND 本表 geoFenceEnabled(商户级 opt-in) 同时开启才生效; 围栏策略取平台全局配置
/// [cn.daxpay.open.platform.system.entity.config.platform.security.PlatformPaySecurityConfig#getGeoFenceStrategy]。
@EqualsAndHashCode(callSuper = true)
@Data
@Accessors(chain = true)
@TableName("mch_risk_config")
public class MchRiskConfig extends MchBaseEntity implements ToResult<MchRiskConfigResult> {
/// 是否启用地理围栏(商户级 opt-in, 默认关闭)
private Boolean geoFenceEnabled = Boolean.FALSE;
@Override
public MchRiskConfigResult toResult() {
return MchRiskConfigConvert.CONVERT.toResult(this);
}
}

View File

@@ -0,0 +1,23 @@
package cn.daxpay.open.payment.merchant.param.config;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
import lombok.experimental.Accessors;
/// # 商户风控配置参数
///
@Data
@Accessors(chain = true)
@Schema(title = "商户风控配置参数")
public class MchRiskConfigParam {
/// 商户号
@Schema(description = "商户号")
@NotBlank(message = "{validation.field.mchNo.notBlank}")
private String mchNo;
/// 是否启用地理围栏
@Schema(description = "是否启用地理围栏")
private Boolean geoFenceEnabled;
}

View File

@@ -0,0 +1,21 @@
package cn.daxpay.open.payment.merchant.result.config;
import cn.daxpay.open.payment.common.result.MchBaseResult;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.experimental.Accessors;
/// # 商户风控配置结果
///
/// 商户号 / 商户名称由父类 [MchBaseResult] 提供(mchNo + mchName @Trans 翻译)。
@EqualsAndHashCode(callSuper = true)
@Data
@Accessors(chain = true)
@Schema(title = "商户风控配置结果")
public class MchRiskConfigResult extends MchBaseResult {
/// 是否启用地理围栏
@Schema(description = "是否启用地理围栏")
private Boolean geoFenceEnabled;
}

View File

@@ -0,0 +1,93 @@
package cn.daxpay.open.payment.merchant.service.config;
import cn.daxpay.open.payment.merchant.convert.config.MchRiskConfigConvert;
import cn.daxpay.open.payment.merchant.dao.config.MchRiskConfigManager;
import cn.daxpay.open.payment.merchant.dao.info.MerchantInfoManager;
import cn.daxpay.open.payment.merchant.entity.config.MchRiskConfig;
import cn.daxpay.open.payment.merchant.param.config.MchRiskConfigParam;
import cn.daxpay.open.payment.merchant.result.config.MchRiskConfigResult;
import cn.daxpay.open.platform.core.exception.DataNotExistException;
import cn.hutool.core.util.StrUtil;
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.concurrent.TimeUnit;
/// # 商户风控配置服务
///
/// 商户级风控配置(1:1 商户)的读写。支付链路通过 [#getConfigForPayment] 高频读取(带 Caffeine 缓存),
/// 运营端通过 [#saveOrUpdate] 更新并失效缓存。
///
/// 围栏两级门控: 平台总闸 + 商户 geoFenceEnabled; 围栏策略为平台全局配置, 非商户级。
@Slf4j
@Service
@RequiredArgsConstructor
public class MchRiskConfigService {
private final MchRiskConfigManager mchRiskConfigManager;
private final MerchantInfoManager merchantInfoManager;
/// 商户风控配置支付链路读取缓存(短 TTL, 配置改动可接受短延迟)
private final Cache<String, MchRiskConfig> paymentReadCache = Caffeine.newBuilder()
.expireAfterWrite(60, TimeUnit.SECONDS)
.maximumSize(10_000)
.build();
/// 运营端: 根据商户号查询风控配置, 无记录返回默认对象(不落库)
public MchRiskConfigResult findByMchNo(String mchNo) {
return mchRiskConfigManager.findByMchNo(mchNo)
.map(MchRiskConfig::toResult)
.orElseGet(() -> defaultResult(mchNo));
}
/// 支付链路: 读取商户风控配置(带缓存), 无记录返回默认对象(不落库)
public MchRiskConfig getConfigForPayment(String mchNo) {
if (StrUtil.isBlank(mchNo)) {
return defaultConfig(null);
}
return paymentReadCache.get(mchNo, k -> mchRiskConfigManager.findByMchNo(k)
.orElseGet(() -> defaultConfig(k)));
}
/// 运营端: 保存或更新(按商户号 upsert), 失效支付链路缓存
@Transactional(rollbackFor = Exception.class)
public void saveOrUpdate(MchRiskConfigParam param) {
// 校验商户存在
merchantInfoManager.findByMchNo(param.getMchNo())
// 商户: 商户不存在
.orElseThrow(() -> new DataNotExistException("error.payment.merchant.merchantNotExist"));
var existing = mchRiskConfigManager.findByMchNo(param.getMchNo());
if (existing.isPresent()) {
MchRiskConfig entity = existing.get();
MchRiskConfigConvert.CONVERT.copy(param, entity);
mchRiskConfigManager.updateById(entity);
} else {
MchRiskConfig entity = MchRiskConfigConvert.CONVERT.toEntity(param);
// 运营端写 MchBaseEntity 必须显式 setMchNo(不装载商户 PaymentContext), 勿链式
entity.setMchNo(param.getMchNo());
mchRiskConfigManager.save(entity);
}
// 失效支付链路缓存
paymentReadCache.invalidate(param.getMchNo());
}
private MchRiskConfig defaultConfig(String mchNo) {
// MchRiskConfig 字段已有默认值(geoFenceEnabled=false), 仅补 mchNo
// setMchNo 返回父类类型, 单独赋值勿链式
MchRiskConfig config = new MchRiskConfig();
config.setMchNo(mchNo);
return config;
}
private MchRiskConfigResult defaultResult(String mchNo) {
// setMchNo 返回父类类型, 单独赋值勿链式
MchRiskConfigResult result = new MchRiskConfigResult();
result.setMchNo(mchNo);
result.setGeoFenceEnabled(false);
return result;
}
}

View File

@@ -0,0 +1,39 @@
package cn.daxpay.open.payment.strategy.risk;
import cn.daxpay.open.platform.core.i18n.I18nSupport;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import java.util.Arrays;
import java.util.Optional;
/// # 地理围栏策略
///
/// 控制门店市级地理围栏的容错范围, 由 [cn.daxpay.open.plugin.risk.strategy.DefaultPayRiskChecker#checkStoreGeoFence]
/// 读取平台全局配置后决定放行城市集合。
///
/// - [STRICT]: 仅允许门店所在市, 无容错(交界处 IP 误判会被拦截, 风控最严)
/// - [BALANCED]: 门店所在市 + 接壤邻市(吸收 IP 库在市边界的误判, 推荐默认)
/// - [LOOSE]: 门店所在市 + 邻市 + 同省所有市(跨市连锁 / 省级经营商户)
@Getter
@RequiredArgsConstructor
public enum GeoFenceStrategyEnum implements I18nSupport {
/// 严格: 仅允许门店所在市
STRICT("strict"),
/// 平衡: 门店市 + 邻市(推荐默认)
BALANCED("balanced"),
/// 宽松: 门店市 + 邻市 + 同省
LOOSE("loose");
private final String code;
@Override
public String getI18nPrefix() {
return "enum.geo_fence_strategy";
}
public static Optional<GeoFenceStrategyEnum> findByCode(String code) {
return Arrays.stream(values()).filter(e -> e.code.equals(code)).findFirst();
}
}

View File

@@ -0,0 +1,41 @@
package cn.daxpay.open.payment.strategy.risk;
import cn.hutool.core.util.StrUtil;
/// # 地理围栏工具
///
/// 门店城市与 IP 归属城市的名称归一化, 供 [cn.daxpay.open.payment.trade.runtime.service.pay.common.PayRiskAssistService]
/// 预算放行城市集合与 [cn.daxpay.open.plugin.risk.strategy.DefaultPayRiskChecker] 比对时复用, 保证两侧口径一致。
/// 归一化口径与 [cn.daxpay.open.platform.capability.audit.log.service.ip2region.IpRegion] 内私有 normalizeName 保持一致。
public final class GeoFenceUtil {
/// 行政区划后缀(由长到短), 迭代去除
private static final String[] REGION_SUFFIXES =
{"特别行政区", "维吾尔自治区", "回族自治区", "壮族自治区", "自治区", "", ""};
private GeoFenceUtil() {
}
/// 行政区划名称归一化: 迭代去掉省/市/自治区/特别行政区等常见后缀, 用于门店城市与 IP 归属城市对齐比对
///
/// 覆盖: 直辖市(北京市→北京)、普通地级市(深圳市→深圳)、自治区(内蒙古自治区→内蒙古)。
/// 自治州/盟保留全名(已知限制)。
public static String normalizeRegionName(String name) {
if (StrUtil.isBlank(name)) {
return "";
}
String result = name.trim();
boolean changed = true;
while (changed) {
changed = false;
for (String suffix : REGION_SUFFIXES) {
if (result.endsWith(suffix)) {
result = result.substring(0, result.length() - suffix.length());
changed = true;
break;
}
}
}
return result;
}
}

View File

@@ -3,6 +3,8 @@ package cn.daxpay.open.payment.strategy.risk;
import lombok.Data;
import lombok.experimental.Accessors;
import java.util.Set;
/// # 支付风控检查上下文
///
/// 字段均为快照供名单比对与命中落库core 不依赖插件表结构。
@@ -79,9 +81,14 @@ public class PayRiskCheckContext {
/// 是否启用地理围栏null/false=不检查true=启用门店市级围栏检测)
///
/// 第三层围栏全局开关快照, 由 [cn.daxpay.open.payment.trade.runtime.service.pay.common.PayRiskAssistService]
/// 读取平台配置后注入。开启后比对 IP 归属城市与门店所在城市, 不一致按 blockOnHit 处理
/// 读取平台配置后注入。两级门控: 平台总闸 AND 商户 mch_risk_config.geoFenceEnabled 同时开启才生效
private Boolean geoFenceEnabled;
/// 平台地理围栏策略快照(strict/balanced/loose)
///
/// @see GeoFenceStrategyEnum
private String geoFenceStrategy;
/// 门店号(围栏命中快照来源)
///
/// 由 [cn.daxpay.open.payment.trade.runtime.service.pay.common.PayRiskAssistService]
@@ -94,6 +101,12 @@ public class PayRiskCheckContext {
/// 按 storeNo 查门店 regionCode 反查 base_city 名称注入; 门店无地址时为 null围栏 fail-open
private String storeCity;
/// 门店围栏放行城市集合(按策略预解析并归一化的城市名集合)
///
/// strict={门店市}; balanced={门店市}+邻市; loose={门店市}+邻市+同省。
/// 由 [cn.daxpay.open.payment.trade.runtime.service.pay.common.PayRiskAssistService]
/// 按 geoFenceStrategy 预算注入, 检查器只需判定 IP 归属城市是否落在集合内。门店无地址时为 null围栏 fail-open
private Set<String> storeAllowedCities;
/// 客户端 IP 归属城市(命中落库快照, 由检查器解析 IP 后填入)
private String clientCity;
}

View File

@@ -1,8 +1,12 @@
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.config.MchRiskConfig;
import cn.daxpay.open.payment.merchant.entity.store.MchStoreInfo;
import cn.daxpay.open.payment.merchant.service.config.MchRiskConfigService;
import cn.daxpay.open.payment.strategy.pay.AbsNormalPayStrategy;
import cn.daxpay.open.payment.strategy.risk.GeoFenceStrategyEnum;
import cn.daxpay.open.payment.strategy.risk.GeoFenceUtil;
import cn.daxpay.open.payment.strategy.risk.PayRiskCheckContext;
import cn.daxpay.open.payment.strategy.risk.PayRiskChecker;
import cn.daxpay.open.payment.trade.enums.PayTradeTypeEnum;
@@ -18,14 +22,18 @@ 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.daxpay.open.platform.system.service.region.ChinaRegionAdjacencyService;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.StrUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.stereotype.Service;
import java.util.HashSet;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
/// # 支付风控辅助
///
@@ -42,6 +50,8 @@ public class PayRiskAssistService {
private final GatewayPayOrderManager gatewayPayOrderManager;
private final MchStoreInfoManager mchStoreInfoManager;
private final CityManager cityManager;
private final MchRiskConfigService mchRiskConfigService;
private final ChinaRegionAdjacencyService chinaRegionAdjacencyService;
/// 支付前检查(须在 [AbsNormalPayStrategy#doBeforePay] 之后调用,保证微信 channelAppId 已回填)
///
@@ -64,8 +74,8 @@ public class PayRiskAssistService {
ctx.setBlockOverseasIp(config.getBlockOverseasIp());
// 省级地区拦截开关(地域策略)
ctx.setProvinceBlacklistEnabled(config.getProvinceBlacklistEnabled());
// 地理围栏全局开关(第三层, Phase 2 实现检查逻辑
ctx.setGeoFenceEnabled(config.getGeoFenceEnabled());
// 地理围栏两级门控 + 策略 + 放行城市集合(第三层
applyGeoFence(ctx, config, payParam.getMchNo());
checker.checkBeforePay(ctx);
}
@@ -87,8 +97,8 @@ public class PayRiskAssistService {
ctx.setBlockOverseasIp(config.getBlockOverseasIp());
// 省级地区拦截开关(地域策略, 事后补录)
ctx.setProvinceBlacklistEnabled(config.getProvinceBlacklistEnabled());
// 地理围栏全局开关(第三层, Phase 2 实现检查逻辑
ctx.setGeoFenceEnabled(config.getGeoFenceEnabled());
// 地理围栏两级门控 + 策略 + 放行城市集合(第三层, 事后补录
applyGeoFence(ctx, config, trade.getMchNo());
// 黑名单拦截开关(第一层, 事后补录)
ctx.setBlacklistEnabled(config.getBlacklistEnabled());
if (StrUtil.isNotBlank(buyerId)) {
@@ -118,10 +128,9 @@ public class PayRiskAssistService {
.setChannelAppId(payParam.getChannelAppId())
.setBizOrderNo(payParam.getBizOrderNo());
fillChannelByProduct(ctx, payParam.getProduct());
// 门店号与门店城市(围栏比对基准: 从 terminal.storeNo 提取, 反查门店 regionCode 得城市名
// 门店号提取(围栏比对基准来源): 从 terminal.storeNo 提取; 门店城市与放行集合由 applyGeoFence 按策略预算
String storeNo = payParam.getTerminal() != null ? payParam.getTerminal().getStoreNo() : null;
ctx.setStoreNo(storeNo);
ctx.setStoreCity(resolveStoreCity(storeNo));
return ctx;
}
@@ -166,8 +175,7 @@ public class PayRiskAssistService {
if (StrUtil.isBlank(ctx.getChannel())) {
fillChannelByProduct(ctx, ctx.getProduct());
}
// 门店城市反查(围栏比对基准)
ctx.setStoreCity(resolveStoreCity(ctx.getStoreNo()));
// 门店号已从容器回填; 门店城市与放行集合由 applyGeoFence 按策略预算
return ctx;
}
@@ -195,11 +203,42 @@ public class PayRiskAssistService {
private static final Map<String, String> DIRECT_CITY_CODE_TO_NAME = Map.of(
"11", "北京市", "12", "天津市", "31", "上海市", "50", "重庆市");
/// 按 storeNo 查门店所在城市(中文城市名, 与 ip2region 返回格式对齐
/// 门店城市解析结果(围栏比对基准 + 放行集合构建输入
private record StoreCityInfo(String cityCode, String cityName, String provinceCode) {
}
/// 地理围栏两级门控 + 策略 + 放行城市集合预算
///
/// 门店 regionCode 为 6 位区县码: 直辖市用省名, 其余取前 4 位查 base_city。
/// 门店不存在或未录地址返回 null围栏 fail-open
private String resolveStoreCity(String storeNo) {
/// 平台总闸(config.geoFenceEnabled) AND 商户开关(mch_risk_config.geoFenceEnabled) 同时开启才生效;
/// 生效时按平台全局策略(strict/balanced/loose)预算门店放行城市集合注入 ctx, 检查器只需判定 IP 城市是否落集合内
/// 门店无地址时 storeCity/storeAllowedCities 留空 → 检查器 fail-open。
private void applyGeoFence(PayRiskCheckContext ctx, PlatformPaySecurityConfig config, String mchNo) {
if (!Boolean.TRUE.equals(config.getGeoFenceEnabled())) {
ctx.setGeoFenceEnabled(false);
return;
}
MchRiskConfig mchRisk = mchRiskConfigService.getConfigForPayment(mchNo);
boolean mchOn = Boolean.TRUE.equals(mchRisk.getGeoFenceEnabled());
ctx.setGeoFenceEnabled(mchOn);
if (!mchOn) {
return;
}
// 围栏策略为平台全局配置, 全商户共用
String strategy = config.getGeoFenceStrategy();
ctx.setGeoFenceStrategy(strategy);
StoreCityInfo info = resolveStoreCityInfo(ctx.getStoreNo());
if (info == null) {
// 门店不存在或未录地址 → fail-open(检查器按 storeCity 为空处理)
return;
}
ctx.setStoreCity(info.cityName());
ctx.setStoreAllowedCities(buildAllowedCities(info, strategy));
}
/// 按 storeNo 查门店所在城市信息(城市码/城市名/省码)
///
/// 门店 regionCode 为 6 位区县码; 直辖市用省名作为城市名, 其余查 base_city。
private StoreCityInfo resolveStoreCityInfo(String storeNo) {
if (StrUtil.isBlank(storeNo)) {
return null;
}
@@ -208,14 +247,63 @@ public class PayRiskAssistService {
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;
String cityCode = regionCode.substring(0, 4);
String provinceCode = regionCode.substring(0, 2);
String cityName = resolveCityName(cityCode);
if (StrUtil.isBlank(cityName)) {
return null;
}
// 普通省市: 前 4 位为城市码, 查 base_city 得城市名
return cityManager.findById(regionCode.substring(0, 4))
return new StoreCityInfo(cityCode, cityName, provinceCode);
}
/// 城市编码 → 城市名(与 ip2region 返回格式对齐)
///
/// 直辖市(省码 11/12/31/50)用省名(北京市), 因 base_city 对直辖市存"市辖区"; 其余查 base_city。
private String resolveCityName(String cityCode) {
if (StrUtil.isBlank(cityCode) || cityCode.length() < 2) {
return null;
}
String directName = DIRECT_CITY_CODE_TO_NAME.get(cityCode.substring(0, 2));
if (directName != null) {
return directName;
}
return cityManager.findById(cityCode)
.map(City::getName)
.orElse(null);
}
/// 按策略构建门店放行城市集合(归一化后的城市名)
///
/// strict={门店市}; balanced={门店市}+邻市; loose={门店市}+邻市+同省。
private Set<String> buildAllowedCities(StoreCityInfo info, String strategy) {
GeoFenceStrategyEnum st = GeoFenceStrategyEnum.findByCode(strategy)
.orElse(GeoFenceStrategyEnum.BALANCED);
Set<String> allowed = new HashSet<>();
allowed.add(GeoFenceUtil.normalizeRegionName(info.cityName()));
if (st == GeoFenceStrategyEnum.STRICT) {
return allowed;
}
// balanced: 接壤邻市
Set<String> adjCodes = chinaRegionAdjacencyService.findAdjacentCityCodes(info.cityCode());
if (CollUtil.isNotEmpty(adjCodes)) {
for (String adjCode : adjCodes) {
String name = resolveCityName(adjCode);
if (StrUtil.isNotBlank(name)) {
allowed.add(GeoFenceUtil.normalizeRegionName(name));
}
}
}
if (st == GeoFenceStrategyEnum.LOOSE) {
// 宽松: 同省所有市(直辖市同省即自身, 已在集合内)
String directName = DIRECT_CITY_CODE_TO_NAME.get(info.provinceCode());
if (directName == null) {
for (City city : cityManager.findAllByProvinceCode(info.provinceCode())) {
if (StrUtil.isNotBlank(city.getName())) {
allowed.add(GeoFenceUtil.normalizeRegionName(city.getName()));
}
}
}
}
return allowed;
}
}

View File

@@ -0,0 +1,5 @@
{
"strict": "Strict",
"balanced": "Balanced",
"loose": "Relaxed"
}

View File

@@ -2,5 +2,6 @@
"ip": "IP",
"alipay_user": "Alipay user",
"wechat_openid": "WeChat OpenId",
"overseas_ip": "Overseas IP"
"overseas_ip": "Overseas IP",
"province": "Province"
}

View File

@@ -796,6 +796,9 @@
"riskEnabled": {
"notNull": "Risk control master toggle cannot be empty"
},
"blacklistEnabled": {
"notNull": "Blacklist blocking toggle cannot be empty"
},
"riskBlockBeforePay": {
"notNull": "Block-on-hit toggle cannot be empty"
},
@@ -808,6 +811,15 @@
"blockOverseasIp": {
"notNull": "Overseas IP block toggle cannot be empty"
},
"provinceBlacklistEnabled": {
"notNull": "Province-level blocking toggle cannot be empty"
},
"geoFenceEnabled": {
"notNull": "Geo-fence toggle cannot be empty"
},
"geoFenceStrategy": {
"notNull": "Geo-fence strategy cannot be empty"
},
"reqId": {
"notBlank": "Request ID is required",
"size": "Request ID must not exceed 64 characters"

View File

@@ -0,0 +1,5 @@
{
"strict": "Ketat",
"balanced": "Seimbang",
"loose": "Longgar"
}

View File

@@ -2,5 +2,6 @@
"ip": "IP",
"alipay_user": "Pengguna Alipay",
"wechat_openid": "OpenId WeChat",
"overseas_ip": "IP Luar Negeri"
"overseas_ip": "IP Luar Negeri",
"province": "Provinsi"
}

View File

@@ -799,6 +799,9 @@
"riskEnabled": {
"notNull": "Status aktivasi pengelolaan risiko wajib diisi"
},
"blacklistEnabled": {
"notNull": "Saklar pemblokiran daftar hitam tidak boleh kosong"
},
"riskBlockBeforePay": {
"notNull": "Pilihan blokir sebelum pembayaran wajib diisi"
},
@@ -811,6 +814,15 @@
"blockOverseasIp": {
"notNull": "Saklar blokir IP luar negeri tidak boleh kosong"
},
"provinceBlacklistEnabled": {
"notNull": "Saklar pemblokiran tingkat provinsi tidak boleh kosong"
},
"geoFenceEnabled": {
"notNull": "Saklar geo-pagar tidak boleh kosong"
},
"geoFenceStrategy": {
"notNull": "Strategi geo-pagar tidak boleh kosong"
},
"itemId": {
"notBlank": "ID item kasir tidak boleh kosong"
},

View File

@@ -0,0 +1,5 @@
{
"strict": "厳格",
"balanced": "バランス",
"loose": "緩和"
}

View File

@@ -2,5 +2,6 @@
"ip": "IP",
"alipay_user": "Alipay ユーザー",
"wechat_openid": "WeChat OpenId",
"overseas_ip": "海外IP"
"overseas_ip": "海外IP",
"province": "省"
}

View File

@@ -792,6 +792,9 @@
"riskEnabled": {
"notNull": "リスク管理のメインスイッチは必須です"
},
"blacklistEnabled": {
"notNull": "ブラックリストブロックスイッチは必須です"
},
"riskBlockBeforePay": {
"notNull": "ヒット時ブロックスイッチは必須です"
},
@@ -804,6 +807,15 @@
"blockOverseasIp": {
"notNull": "海外 IP ブロック スイッチは必須です"
},
"provinceBlacklistEnabled": {
"notNull": "都道府県別ブロックスイッチは必須です"
},
"geoFenceEnabled": {
"notNull": "ジオフェンススイッチは必須です"
},
"geoFenceStrategy": {
"notNull": "ジオフェンス戦略は必須です"
},
"reqId": {
"notBlank": "リクエストIDは必須です",
"size": "リクエストIDは64文字以内です"

View File

@@ -0,0 +1,5 @@
{
"strict": "엄격",
"balanced": "균형",
"loose": "완화"
}

View File

@@ -2,5 +2,6 @@
"ip": "IP",
"alipay_user": "알리페이 사용자",
"wechat_openid": "위챗 OpenId",
"overseas_ip": "해외 IP"
"overseas_ip": "해외 IP",
"province": "성(省)"
}

View File

@@ -792,6 +792,9 @@
"riskEnabled": {
"notNull": "리스크 관리 마스터 스위치는 필수입니다"
},
"blacklistEnabled": {
"notNull": "블랙리스트 차단 스위치는 필수입니다"
},
"riskBlockBeforePay": {
"notNull": "히트 시 차단 스위치는 필수입니다"
},
@@ -804,6 +807,15 @@
"blockOverseasIp": {
"notNull": "해외 IP 차단 스위치는 필수입니다"
},
"provinceBlacklistEnabled": {
"notNull": "지역별 차단 스위치는 필수입니다"
},
"geoFenceEnabled": {
"notNull": "지오펜스 스위치는 필수입니다"
},
"geoFenceStrategy": {
"notNull": "지오펜스 전략은 필수입니다"
},
"reqId": {
"notBlank": "요청 ID는 필수입니다",
"size": "요청 ID는 64자를 초과할 수 없습니다"

View File

@@ -0,0 +1,5 @@
{
"strict": "Ketat",
"balanced": "Seimbang",
"loose": "Longgar"
}

View File

@@ -2,5 +2,6 @@
"ip": "IP",
"alipay_user": "Pengguna Alipay",
"wechat_openid": "OpenId WeChat",
"overseas_ip": "IP Luar Negara"
"overseas_ip": "IP Luar Negara",
"province": "Wilayah"
}

View File

@@ -799,6 +799,9 @@
"riskEnabled": {
"notNull": "Status pengaktifan pengurusan risiko tidak boleh kosong"
},
"blacklistEnabled": {
"notNull": "Saklar sekatan senarai hitam tidak boleh kosong"
},
"riskBlockBeforePay": {
"notNull": "Pilihan halang sebelum pembayaran tidak boleh kosong"
},
@@ -811,6 +814,15 @@
"blockOverseasIp": {
"notNull": "Suis sekatan IP luar negara tidak boleh kosong"
},
"provinceBlacklistEnabled": {
"notNull": "Saklar sekatan tahap wilayah tidak boleh kosong"
},
"geoFenceEnabled": {
"notNull": "Saklar pagar geo tidak boleh kosong"
},
"geoFenceStrategy": {
"notNull": "Strategi pagar geo tidak boleh kosong"
},
"itemId": {
"notBlank": "ID item kasir tidak boleh kosong"
},

View File

@@ -0,0 +1,5 @@
{
"strict": "เข้มงวด",
"balanced": "สมดุล",
"loose": "ผ่อนปรน"
}

View File

@@ -2,5 +2,6 @@
"ip": "IP",
"alipay_user": "ผู้ใช้ Alipay",
"wechat_openid": "OpenId WeChat",
"overseas_ip": "IP ต่างประเทศ"
"overseas_ip": "IP ต่างประเทศ",
"province": "จังหวัด"
}

View File

@@ -799,6 +799,9 @@
"riskEnabled": {
"notNull": "สถานะการเปิดใช้งานการจัดการความเสี่ยงต้องไม่เป็นค่าว่าง"
},
"blacklistEnabled": {
"notNull": "สวิตช์การบล็อกบัญชีดำต้องไม่ว่าง"
},
"riskBlockBeforePay": {
"notNull": "ตัวเลือกการบล็อกก่อนชำระเงินต้องไม่เป็นค่าว่าง"
},
@@ -811,6 +814,15 @@
"blockOverseasIp": {
"notNull": "สวิตช์บล็อก IP ต่างประเทศต้องไม่ว่าง"
},
"provinceBlacklistEnabled": {
"notNull": "สวิตช์การบล็อกระดับจังหวัดต้องไม่ว่าง"
},
"geoFenceEnabled": {
"notNull": "สวิตช์รั้วภูมิศาสตร์ต้องไม่ว่าง"
},
"geoFenceStrategy": {
"notNull": "กลยุทธ์รั้วภูมิศาสตร์ต้องไม่ว่าง"
},
"itemId": {
"notBlank": "ต้องระบุรหัสรายการแคชเชียร์"
},

View File

@@ -0,0 +1,5 @@
{
"strict": "Nghiêm ngặt",
"balanced": "Cân bằng",
"loose": "Lỏng lẻo"
}

View File

@@ -2,5 +2,6 @@
"ip": "IP",
"alipay_user": "Người dùng Alipay",
"wechat_openid": "OpenId WeChat",
"overseas_ip": "IP nước ngoài"
"overseas_ip": "IP nước ngoài",
"province": "Tỉnh"
}

View File

@@ -799,6 +799,9 @@
"riskEnabled": {
"notNull": "Trạng thái kích hoạt quản lý rủi ro là bắt buộc"
},
"blacklistEnabled": {
"notNull": "Công tắc chặn danh sách đen không được để trống"
},
"riskBlockBeforePay": {
"notNull": "Tùy chọn chặn trước thanh toán là bắt buộc"
},
@@ -811,6 +814,15 @@
"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"
},
"geoFenceEnabled": {
"notNull": "Công tắc hàng rào địa lý không được để trống"
},
"geoFenceStrategy": {
"notNull": "Chiến lược hàng rào địa lý không được để trống"
},
"itemId": {
"notBlank": "ID mục thanh toán quầy không được để trống"
},

View File

@@ -0,0 +1,5 @@
{
"strict": "严格",
"balanced": "平衡",
"loose": "宽松"
}

View File

@@ -2,5 +2,6 @@
"ip": "IP",
"alipay_user": "支付宝用户",
"wechat_openid": "微信 OpenId",
"overseas_ip": "海外IP"
"overseas_ip": "海外IP",
"province": "省份"
}

View File

@@ -796,6 +796,9 @@
"riskEnabled": {
"notNull": "风控总开关不能为空"
},
"blacklistEnabled": {
"notNull": "黑名单拦截开关不能为空"
},
"riskBlockBeforePay": {
"notNull": "命中阻断开关不能为空"
},
@@ -808,6 +811,15 @@
"blockOverseasIp": {
"notNull": "海外 IP 拦截开关不能为空"
},
"provinceBlacklistEnabled": {
"notNull": "省级地区拦截开关不能为空"
},
"geoFenceEnabled": {
"notNull": "地理围栏开关不能为空"
},
"geoFenceStrategy": {
"notNull": "地理围栏策略不能为空"
},
"reqId": {
"notBlank": "请求ID不可为空",
"size": "请求ID不可超过64位"

View File

@@ -0,0 +1,5 @@
{
"strict": "嚴格",
"balanced": "平衡",
"loose": "寬鬆"
}

View File

@@ -2,5 +2,6 @@
"ip": "IP",
"alipay_user": "支付寶用戶",
"wechat_openid": "微信 OpenId",
"overseas_ip": "海外IP"
"overseas_ip": "海外IP",
"province": "省份"
}

View File

@@ -792,6 +792,9 @@
"riskEnabled": {
"notNull": "風控總開關不能為空"
},
"blacklistEnabled": {
"notNull": "黑名單攔截開關不能為空"
},
"riskBlockBeforePay": {
"notNull": "命中阻斷開關不能為空"
},
@@ -804,6 +807,15 @@
"blockOverseasIp": {
"notNull": "海外 IP 攔截開關不能為空"
},
"provinceBlacklistEnabled": {
"notNull": "省級地區攔截開關不能為空"
},
"geoFenceEnabled": {
"notNull": "地理圍欄開關不能為空"
},
"geoFenceStrategy": {
"notNull": "地理圍欄策略不能為空"
},
"reqId": {
"notBlank": "請求ID不可為空",
"size": "請求ID不可超過64位"

View File

@@ -0,0 +1,5 @@
{
"strict": "嚴格",
"balanced": "平衡",
"loose": "寬鬆"
}

View File

@@ -2,5 +2,6 @@
"ip": "IP",
"alipay_user": "支付寶用戶",
"wechat_openid": "微信 OpenId",
"overseas_ip": "海外IP"
"overseas_ip": "海外IP",
"province": "省份"
}

View File

@@ -792,6 +792,9 @@
"riskEnabled": {
"notNull": "風控總開關不能為空"
},
"blacklistEnabled": {
"notNull": "黑名單攔截開關不能為空"
},
"riskBlockBeforePay": {
"notNull": "命中阻斷開關不能為空"
},
@@ -804,6 +807,15 @@
"blockOverseasIp": {
"notNull": "海外 IP 攔截開關不能為空"
},
"provinceBlacklistEnabled": {
"notNull": "省級地區攔截開關不能為空"
},
"geoFenceEnabled": {
"notNull": "地理圍欄開關不能為空"
},
"geoFenceStrategy": {
"notNull": "地理圍欄策略不能為空"
},
"reqId": {
"notBlank": "請求ID不可為空",
"size": "請求ID不可超過64位"

View File

@@ -258,6 +258,18 @@ public interface PermCodes {
/// 菜单编码
String MENU = "payment:risk:hit";
}
/// 支付安全配置(接口防重放 + 风控开关) menuCode=payment:risk:security
interface Security {
/// 菜单编码
String MENU = "payment:risk:security";
}
/// 商户风控配置(地理围栏等商户级风控) menuCode=payment:risk:mch-config
interface MchConfig {
/// 菜单编码
String MENU = "payment:risk:mch-config";
}
}
}

View File

@@ -1,5 +1,6 @@
package cn.daxpay.open.platform.system.controller.region;
import cn.daxpay.open.platform.system.result.region.GeoFencePreviewResult;
import cn.daxpay.open.platform.system.result.region.RegionResult;
import cn.daxpay.open.platform.system.service.region.ChinaRegionService;
import cn.daxpay.open.platform.core.annotation.IgnoreAuth;
@@ -62,5 +63,15 @@ public class ChinaRegionController {
public Result<List<RegionResult>> findAllRegionByParentCode(@NotBlank(message = "{validation.field.regionCode.notBlank}") String code) {
return Res.ok(chinaRegionService.findAllRegionByParentCode(code));
}
/// 地理围栏策略预览: 查询指定城市的交界邻市与同省全部城市, 用于演示页面模拟三级地理围栏策略放行范围
///
/// @param cityCode 城市编码(base_city.code, 4位)
/// @return 预览结果
@Operation(summary = "地理围栏策略预览")
@GetMapping("/geo-fence-preview")
public Result<GeoFencePreviewResult> previewGeoFence(@NotBlank(message = "{validation.field.regionCode.notBlank}") String cityCode) {
return Res.ok(chinaRegionService.previewGeoFence(cityCode));
}
}

View File

@@ -0,0 +1,48 @@
package cn.daxpay.open.platform.system.result.region;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.experimental.Accessors;
import java.util.List;
/// # 地理围栏策略预览结果
///
/// 供演示页面查看某个地级市的交界邻市, 并模拟风控三级地理围栏策略(strict/balanced/loose)的放行城市范围。
/// 三级策略的放行集合由前端按语义派生:
/// - strict(严格): 仅允许门店所在市本身
/// - balanced(平衡): 门店所在市 + 交界邻市
/// - loose(宽松): 门店所在省的全部地级市
@Data
@Accessors(chain = true)
@Schema(title = "地理围栏策略预览结果")
public class GeoFencePreviewResult {
@Schema(description = "选中的城市")
private CityInfo city;
@Schema(description = "交界城市列表(balanced 策略邻市, 可跨省)")
private List<CityInfo> adjacentCities;
@Schema(description = "同省全部城市(loose 策略用, 含选中市本身)")
private List<CityInfo> provinceCities;
/// # 城市信息
@Data
@Accessors(chain = true)
@Schema(title = "城市信息")
public static class CityInfo {
@Schema(description = "城市编码(base_city.code, 4位)")
private String code;
@Schema(description = "城市名称")
private String name;
@Schema(description = "所属省份编码(2位)")
private String provinceCode;
@Schema(description = "所属省份名称")
private String provinceName;
}
}

View File

@@ -9,7 +9,9 @@ import cn.daxpay.open.platform.system.entity.region.City;
import cn.daxpay.open.platform.system.entity.region.Province;
import cn.daxpay.open.platform.system.entity.region.Street;
import cn.daxpay.open.platform.system.enums.ChinaRegionEnum;
import cn.daxpay.open.platform.system.result.region.GeoFencePreviewResult;
import cn.daxpay.open.platform.system.result.region.RegionResult;
import cn.daxpay.open.platform.core.exception.DataNotExistException;
import cn.daxpay.open.platform.core.util.TreeBuildUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -17,6 +19,8 @@ import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
/// # 中国行政区划
@@ -34,6 +38,8 @@ public class ChinaRegionService {
private final StreetManager streetManager;
private final ChinaRegionAdjacencyService chinaRegionAdjacencyService;
/// 根据区划代码获取下级行政区划的列表
public List<RegionResult> findAllRegionByParentCode(String parentCode) {
if (parentCode.length() == ChinaRegionEnum.IMPORT_TYPE_PROVINCE.getLength()) {
@@ -86,4 +92,43 @@ public class ChinaRegionService {
return TreeBuildUtil.build(regions, null, RegionResult::getCode, RegionResult::getParentCode, RegionResult::setChildren);
}
/// 地理围栏策略预览: 查询指定城市的交界邻市与同省全部城市, 供演示页面模拟三级策略放行范围
///
/// @param cityCode 城市编码(base_city.code, 4位)
/// @return 预览结果, 含选中市、交界市、同省全部市
public GeoFencePreviewResult previewGeoFence(String cityCode) {
// 选中城市本身
City city = cityManager.findById(cityCode)
.orElseThrow(DataNotExistException::new);
// 省份编码 → 名称 映射(交界市可能跨省, 需按各自省份解析名称)
Map<String, String> provinceNameMap = provinceManager.findAll().stream()
.collect(Collectors.toMap(Province::getCode, Province::getName, (a, b) -> a));
// 交界邻市编码集合 → 批量解析为城市实体(交界关系可能跨省)
Set<String> adjacentCodes = chinaRegionAdjacencyService.findAdjacentCityCodes(cityCode);
List<City> adjacentCities = cityManager.findAllByIds(adjacentCodes);
// 同省全部城市(loose 策略范围, 含自身)
List<City> provinceCities = cityManager.findAllByProvinceCode(city.getProvinceCode());
GeoFencePreviewResult result = new GeoFencePreviewResult();
result.setCity(toCityInfo(city, provinceNameMap));
result.setAdjacentCities(adjacentCities.stream()
.map(c -> toCityInfo(c, provinceNameMap))
.toList());
result.setProvinceCities(provinceCities.stream()
.map(c -> toCityInfo(c, provinceNameMap))
.toList());
return result;
}
/// 将城市实体转换为预览用的城市信息(附带省份名称)
private GeoFencePreviewResult.CityInfo toCityInfo(City c, Map<String, String> provinceNameMap) {
return new GeoFencePreviewResult.CityInfo()
.setCode(c.getCode())
.setName(c.getName())
.setProvinceCode(c.getProvinceCode())
.setProvinceName(provinceNameMap.get(c.getProvinceCode()));
}
}

View File

@@ -87,6 +87,9 @@ public class PayRiskHit extends MpBaseEntity implements ToResult<PayRiskHitResul
/// 门店号(围栏命中快照)
private String storeNo;
/// 地理围栏命中时生效的策略strict/balanced/loose, 围栏命中快照)
private String geoFenceStrategy;
@Override
public PayRiskHitResult toResult() {
return PayRiskHitConvert.CONVERT.toResult(this);

View File

@@ -20,7 +20,11 @@ public enum PayBlacklistTypeEnum implements I18nSupport {
/// 微信 OpenId绑定平台支付应用
WECHAT_OPENID("wechat_openid"),
/// 海外 IP地域策略命中, 非黑名单来源)
OVERSEAS_IP("overseas_ip");
OVERSEAS_IP("overseas_ip"),
/// IP 归属省份省级地区名单value 存省行政区划编码,如"44"
PROVINCE("province"),
/// IP 归属城市市级地区名单value 存市行政区划编码,如"4403";直辖市城市名单存省编码)
CITY("city");
private final String code;

View File

@@ -87,4 +87,7 @@ public class PayRiskHitResult extends BaseResult {
@Schema(description = "门店号")
private String storeNo;
@Schema(description = "地理围栏命中时生效的策略")
private String geoFenceStrategy;
}

View File

@@ -157,7 +157,8 @@ public class PayBlacklistService {
return;
}
if (PayBlacklistTypeEnum.IP.getCode().equals(type)
|| PayBlacklistTypeEnum.ALIPAY_USER.getCode().equals(type)) {
|| PayBlacklistTypeEnum.ALIPAY_USER.getCode().equals(type)
|| PayBlacklistTypeEnum.PROVINCE.getCode().equals(type)) {
param.setWxAppId(null);
return;
}

View File

@@ -68,10 +68,11 @@ public class PayRiskHitService {
.setOpenid(ctx.getOpenId())
.setBuyerId(ctx.getBuyerId())
.setScene(StrUtil.blankToDefault(ctx.getScene(), PayRiskHitSceneEnum.UNKNOWN.getCode()))
// 围栏命中位置快照IP 城市 / 门店城市 / 门店号)
// 围栏命中位置快照IP 城市 / 门店城市 / 门店号 / 策略
.setClientCity(ctx.getClientCity())
.setStoreCity(ctx.getStoreCity())
.setStoreNo(ctx.getStoreNo());
.setStoreNo(ctx.getStoreNo())
.setGeoFenceStrategy(ctx.getGeoFenceStrategy());
payRiskHitManager.save(hit);
}

View File

@@ -1,7 +1,9 @@
package cn.daxpay.open.plugin.risk.strategy;
import cn.daxpay.open.payment.strategy.risk.GeoFenceUtil;
import cn.daxpay.open.payment.strategy.risk.PayRiskCheckContext;
import cn.daxpay.open.payment.strategy.risk.PayRiskChecker;
import cn.daxpay.open.payment.strategy.risk.RegionCodeResolver;
import cn.daxpay.open.platform.core.code.PayErrorCode;
import cn.daxpay.open.platform.core.enums.pay.channel.ChannelEnum;
import cn.daxpay.open.platform.capability.audit.log.service.ip2region.IpRegion;
@@ -12,6 +14,7 @@ import cn.daxpay.open.plugin.risk.enums.PayBlacklistTypeEnum;
import cn.daxpay.open.plugin.risk.enums.PayRiskHitPhaseEnum;
import cn.daxpay.open.plugin.risk.service.PayBlacklistService;
import cn.daxpay.open.plugin.risk.service.PayRiskHitService;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.lang.Validator;
import cn.hutool.core.net.NetUtil;
import cn.hutool.core.util.StrUtil;
@@ -36,6 +39,7 @@ public class DefaultPayRiskChecker implements PayRiskChecker {
private final PayBlacklistService payBlacklistService;
private final PayRiskHitService payRiskHitService;
private final IpToRegionService ipToRegionService;
private final RegionCodeResolver regionCodeResolver;
/// 用户标识黑名单存在性缓存(短 TTL 30s
///
@@ -71,12 +75,16 @@ public class DefaultPayRiskChecker implements PayRiskChecker {
ctx.getTradeType(), ctx.getMethod(), ctx.getMchNo(), ctx.getClientIp());
}
}
// 海外 IP 拦截(地域策略, 非黑名单)
// IP 海外拦截(地域策略, 非黑名单)
if (Boolean.TRUE.equals(ctx.getBlockOverseasIp())) {
checkOverseasIp(ctx, throwOnHit);
}
// IP 省级地区黑名单(第二层全局地区名单, 受 provinceBlacklistEnabled 开关控制)
checkProvinceBlacklist(ctx, throwOnHit);
// IP 省级地区黑名单(第二层全局地区名单, 受 provinceBlacklistEnabled 开关控制; 省命中后不再执行市级检查
boolean provinceHit = checkProvinceBlacklist(ctx, throwOnHit);
// IP 市级地区黑名单(第二层, 受 cityBlacklistEnabled 开关控制, 与省级开关独立)
if (!provinceHit && Boolean.TRUE.equals(ctx.getCityBlacklistEnabled())) {
checkCityBlacklist(ctx, throwOnHit);
}
// L3 地理围栏(门店市级: IP 归属城市与门店城市比对)
if (Boolean.TRUE.equals(ctx.getGeoFenceEnabled())) {
checkStoreGeoFence(ctx, throwOnHit);
@@ -103,8 +111,11 @@ public class DefaultPayRiskChecker implements PayRiskChecker {
if (Boolean.TRUE.equals(ctx.getBlockOverseasIp())) {
checkOverseasIp(ctx, false);
}
// IP 省级地区黑名单(事后补录, 受 provinceBlacklistEnabled 开关控制)
checkProvinceBlacklist(ctx, false);
// IP 地区黑名单(事后补录, 受 provinceBlacklistEnabled 开关控制; 省命中后不再补录市级
boolean provinceHit = checkProvinceBlacklist(ctx, false);
if (!provinceHit && Boolean.TRUE.equals(ctx.getCityBlacklistEnabled())) {
checkCityBlacklist(ctx, false);
}
// L3 地理围栏(事后补录: IP 归属城市与门店城市比对)
if (Boolean.TRUE.equals(ctx.getGeoFenceEnabled())) {
checkStoreGeoFence(ctx, false);
@@ -164,13 +175,45 @@ public class DefaultPayRiskChecker implements PayRiskChecker {
/// IP 省级地区黑名单(第二层全局地区名单)
///
/// IP 归属省份在省级黑名单中则命中, value 存省名(与 ip2region 返回格式对齐)
/// 需平台配置 provinceBlacklistEnabled=true 才执行; IPv6 / 内网 / 解析失败 → fail-open 放行。
private void checkProvinceBlacklist(PayRiskCheckContext ctx, boolean throwOnHit) {
/// IP 归属省份在省级黑名单中则命中, value 存省行政区划编码(2位, 如"44")
/// 需平台配置 provinceBlacklistEnabled=true 才执行; IPv6 / 内网 / 解析失败 / 无法映射编码 → fail-open 放行。
/// @return 是否命中(用于外层短路市级检查)
private boolean checkProvinceBlacklist(PayRiskCheckContext ctx, boolean throwOnHit) {
// 省级拦截开关关闭时跳过
if (!Boolean.TRUE.equals(ctx.getProvinceBlacklistEnabled())) {
return;
return false;
}
String ip = ctx.getClientIp();
if (StrUtil.isBlank(ip)) {
return false;
}
// IPv6 暂不参与地区判定
if (Validator.isIpv6(ip)) {
return false;
}
// 内网/回环地址直通放行
if (NetUtil.isInnerIP(ip)) {
return false;
}
IpRegion region = ipToRegionService.getRegionByIp(ip);
// 解析失败 → fail-open
if (region == null) {
return false;
}
// IP 归属省份 → 行政区划编码(港澳台/"0"段/未知无法映射 → fail-open)
String provinceCode = regionCodeResolver.resolveProvinceCode(region.getProvince());
if (StrUtil.isBlank(provinceCode)) {
return false;
}
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;
@@ -184,11 +227,16 @@ public class DefaultPayRiskChecker implements PayRiskChecker {
return;
}
IpRegion region = ipToRegionService.getRegionByIp(ip);
// 解析失败或无省份信息 → fail-open
if (region == null || StrUtil.isBlank(region.getProvince())) {
// 解析失败 → fail-open
if (region == null) {
return;
}
rejectIfBlocked(ctx, PayBlacklistTypeEnum.PROVINCE.getCode(), region.getProvince(), null, throwOnHit);
// IP 归属城市 → 行政区划编码(直辖市回落省编码; "0"段/未知无法映射 → fail-open)
String cityCode = regionCodeResolver.resolveCityCode(region.getProvince(), region.getCity());
if (StrUtil.isBlank(cityCode)) {
return;
}
rejectIfBlocked(ctx, PayBlacklistTypeEnum.CITY.getCode(), cityCode, null, throwOnHit);
}
/// 海外 IP 地域拦截country≠中国, 港澳台放行; 未知/内网 fail-open
@@ -232,15 +280,17 @@ public class DefaultPayRiskChecker implements PayRiskChecker {
/// 门店市级地理围栏
///
/// IP 归属城市与门店所在城市不一致则命中; 城市名比对前做后缀归一化(去"市/省")
/// 直辖市用 [IpRegion#isProvinceLevel] 归一(省份即城市)。
/// 门店无地址(storeCity 为空)时 fail-open + 告警; IPv6/内网/解析失败 fail-open
/// IP 归属城市是否落在门店放行城市集合([PayRiskCheckContext#getStoreAllowedCities])内, 不在则命中
/// 放行集合由 [cn.daxpay.open.payment.trade.runtime.service.pay.common.PayRiskAssistService] 按平台策略
/// (strict 仅门店市 / balanced +邻市 / loose +同省)预算并归一化, 直辖市用 [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());
// 门店无地址或放行集合缺失: fail-open
if (StrUtil.isBlank(ctx.getStoreCity()) || CollUtil.isEmpty(ctx.getStoreAllowedCities())) {
if (ctx.getStoreNo() != null && StrUtil.isNotBlank(ctx.getStoreCity())) {
// 门店城市但放行集合为空(策略数据缺失), 降级放行并告警
log.warn("围栏开启但放行城市集合为空, storeNo={}, mchNo={}, strategy={}, 跳过围栏校验",
ctx.getStoreNo(), ctx.getMchNo(), ctx.getGeoFenceStrategy());
}
return;
}
@@ -270,10 +320,9 @@ public class DefaultPayRiskChecker implements PayRiskChecker {
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)) {
// 归一化后判定是否落在门店放行集合内
String clientNorm = GeoFenceUtil.normalizeRegionName(clientCity);
if (!ctx.getStoreAllowedCities().contains(clientNorm)) {
// 记录围栏命中(非黑名单来源, blacklistId=null)
try {
payRiskHitService.recordHit(ctx, HIT_TYPE_GEO_FENCE, clientCity, null);
@@ -287,18 +336,4 @@ public class DefaultPayRiskChecker implements PayRiskChecker {
}
}
}
/// 行政区划名称归一化: 去掉省/市等常见后缀, 用于门店城市与 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;
}
}

View File

@@ -57,6 +57,7 @@ class DefaultPayRiskCheckerTest {
private static final String ALIPAY_USER_TYPE = PayBlacklistTypeEnum.ALIPAY_USER.getCode();
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 BLACKLIST_MSG_KEY = "pay.error.risk.blacklist";
@Mock
@@ -253,8 +254,8 @@ class DefaultPayRiskCheckerTest {
}
@Test
@DisplayName("海外检查: blockOverseasIp=null 时不触发地域检查")
void overseasIp_switchNull_shouldNotInvokeLookup() {
@DisplayName("海外检查: blockOverseasIp=null 时不触发海外命中")
void overseasIp_switchNull_shouldNotTriggerOverseasCheck() {
PayRiskCheckContext ctx = new PayRiskCheckContext()
.setClientIp("8.8.8.8")
.setBlockOverseasIp(null)
@@ -262,8 +263,30 @@ class DefaultPayRiskCheckerTest {
assertDoesNotThrow(() -> checker.checkBeforePay(ctx));
verify(ipToRegionService, never()).getRegionByIp(anyString());
verify(payRiskHitService, never()).recordHit(any(), anyString(), anyString(), any());
// 海外 IP 开关 null → 不产生 overseas_ip 命中
verify(payRiskHitService, never()).recordHit(any(), eq(OVERSEAS_IP_TYPE), anyString(), any());
}
// ==================== 黑名单开关 ====================
@Test
@DisplayName("黑名单开关: blacklistEnabled 关闭(null)时不触发 IP/用户标识检查")
void blacklist_switchOff_shouldNotCheck() {
PayRiskCheckContext ctx = new PayRiskCheckContext()
.setClientIp("1.2.3.4")
.setChannel(ChannelEnum.ALIPAY.getCode())
.setOpenId("uid-1")
.setBlockOverseasIp(false)
.setBlacklistEnabled(null)
.setProvinceBlacklistEnabled(false)
.setBlockOnHit(true);
assertDoesNotThrow(() -> checker.checkBeforePay(ctx));
// 黑名单开关关闭 → 不查 IP/用户标识名单
verify(payBlacklistService, never()).findActive(eq(IP_TYPE), anyString(), any());
verify(payBlacklistService, never()).findActive(eq(ALIPAY_USER_TYPE), anyString(), any());
verify(payBlacklistService, never()).findActive(eq(WECHAT_OPENID_TYPE), anyString(), any());
}
// ==================== 黑名单 - IP ====================
@@ -418,6 +441,92 @@ class DefaultPayRiskCheckerTest {
verify(payRiskHitService, never()).recordHit(any(), eq(ALIPAY_USER_TYPE), eq("uid-a"), any());
}
// ==================== 省级地区黑名单 ====================
@Test
@DisplayName("省级名单: provinceBlacklistEnabled 关闭(null/false)时不触发检查")
void provinceBlacklist_switchOff_shouldNotCheck() {
when(ipToRegionService.getRegionByIp("114.114.114.114"))
.thenReturn(regionWithProvince("中国", "广东省", "电信"));
PayRiskCheckContext ctx = new PayRiskCheckContext()
.setClientIp("114.114.114.114")
.setBlockOverseasIp(false)
.setProvinceBlacklistEnabled(null)
.setBlockOnHit(true);
assertDoesNotThrow(() -> checker.checkBeforePay(ctx));
// 开关关闭 → 不调 ip2region, 不查省级名单
verify(ipToRegionService, never()).getRegionByIp(anyString());
verify(payBlacklistService, never()).findActive(eq(PROVINCE_TYPE), anyString(), any());
}
@Test
@DisplayName("省级名单: IP 归属省在黑名单中命中阻断")
void provinceBlacklist_hit_shouldThrow() {
when(ipToRegionService.getRegionByIp("114.114.114.114"))
.thenReturn(regionWithProvince("中国", "广东省", "电信"));
when(payBlacklistService.findActive(eq(PROVINCE_TYPE), eq("广东省"), eq(null)))
.thenReturn(Optional.of(bl(55L)));
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(PROVINCE_TYPE), eq("广东省"), eq(55L));
}
@Test
@DisplayName("省级名单: IP 归属省不在黑名单中放行")
void provinceBlacklist_miss_shouldPass() {
when(ipToRegionService.getRegionByIp("114.114.114.114"))
.thenReturn(regionWithProvince("中国", "广东省", "电信"));
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());
}
@Test
@DisplayName("省级名单: 内网 IP 不查省份")
void provinceBlacklist_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());
}
@Test
@DisplayName("省级名单: ip2region 返回 null fail-open")
void provinceBlacklist_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(payRiskHitService, never()).recordHit(any(), eq(PROVINCE_TYPE), anyString(), any());
}
@Test
@DisplayName("省级名单: checkAfterPay 事后补录仅记录不阻断")
void provinceBlacklist_afterPay_shouldRecordOnly() {
when(ipToRegionService.getRegionByIp("114.114.114.114"))
.thenReturn(regionWithProvince("中国", "广东省", "电信"));
when(payBlacklistService.findActive(eq(PROVINCE_TYPE), eq("广东省"), 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));
}
// ==================== hasOpenIdBlacklist 缓存 ====================
@Test
@@ -457,6 +566,8 @@ class DefaultPayRiskCheckerTest {
private static PayRiskCheckContext noOverseasCtx() {
return new PayRiskCheckContext()
.setBlockOverseasIp(false)
.setBlacklistEnabled(true)
.setProvinceBlacklistEnabled(true)
.setBlockOnHit(true);
}
@@ -472,4 +583,9 @@ class DefaultPayRiskCheckerTest {
private static IpRegion region(String country, String isp) {
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);
}
}