mirror of
https://gitee.com/dromara/dax-pay
synced 2026-08-10 23:01:58 +08:00
feat(risk): 商户风控配置独立表与地理围栏三级策略改造, 新增交界城市预览演示
This commit is contained in:
@@ -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');
|
||||
|
||||
@@ -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 '主键ID(DB 自增, 纯关系表数据导入专用)';
|
||||
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
|
||||
-- ----------------------------
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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> {
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"strict": "Strict",
|
||||
"balanced": "Balanced",
|
||||
"loose": "Relaxed"
|
||||
}
|
||||
@@ -2,5 +2,6 @@
|
||||
"ip": "IP",
|
||||
"alipay_user": "Alipay user",
|
||||
"wechat_openid": "WeChat OpenId",
|
||||
"overseas_ip": "Overseas IP"
|
||||
"overseas_ip": "Overseas IP",
|
||||
"province": "Province"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"strict": "Ketat",
|
||||
"balanced": "Seimbang",
|
||||
"loose": "Longgar"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"strict": "厳格",
|
||||
"balanced": "バランス",
|
||||
"loose": "緩和"
|
||||
}
|
||||
@@ -2,5 +2,6 @@
|
||||
"ip": "IP",
|
||||
"alipay_user": "Alipay ユーザー",
|
||||
"wechat_openid": "WeChat OpenId",
|
||||
"overseas_ip": "海外IP"
|
||||
"overseas_ip": "海外IP",
|
||||
"province": "省"
|
||||
}
|
||||
|
||||
@@ -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文字以内です"
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"strict": "엄격",
|
||||
"balanced": "균형",
|
||||
"loose": "완화"
|
||||
}
|
||||
@@ -2,5 +2,6 @@
|
||||
"ip": "IP",
|
||||
"alipay_user": "알리페이 사용자",
|
||||
"wechat_openid": "위챗 OpenId",
|
||||
"overseas_ip": "해외 IP"
|
||||
"overseas_ip": "해외 IP",
|
||||
"province": "성(省)"
|
||||
}
|
||||
|
||||
@@ -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자를 초과할 수 없습니다"
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"strict": "Ketat",
|
||||
"balanced": "Seimbang",
|
||||
"loose": "Longgar"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"strict": "เข้มงวด",
|
||||
"balanced": "สมดุล",
|
||||
"loose": "ผ่อนปรน"
|
||||
}
|
||||
@@ -2,5 +2,6 @@
|
||||
"ip": "IP",
|
||||
"alipay_user": "ผู้ใช้ Alipay",
|
||||
"wechat_openid": "OpenId WeChat",
|
||||
"overseas_ip": "IP ต่างประเทศ"
|
||||
"overseas_ip": "IP ต่างประเทศ",
|
||||
"province": "จังหวัด"
|
||||
}
|
||||
|
||||
@@ -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": "ต้องระบุรหัสรายการแคชเชียร์"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"strict": "Nghiêm ngặt",
|
||||
"balanced": "Cân bằng",
|
||||
"loose": "Lỏng lẻo"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"strict": "严格",
|
||||
"balanced": "平衡",
|
||||
"loose": "宽松"
|
||||
}
|
||||
@@ -2,5 +2,6 @@
|
||||
"ip": "IP",
|
||||
"alipay_user": "支付宝用户",
|
||||
"wechat_openid": "微信 OpenId",
|
||||
"overseas_ip": "海外IP"
|
||||
"overseas_ip": "海外IP",
|
||||
"province": "省份"
|
||||
}
|
||||
|
||||
@@ -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位"
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"strict": "嚴格",
|
||||
"balanced": "平衡",
|
||||
"loose": "寬鬆"
|
||||
}
|
||||
@@ -2,5 +2,6 @@
|
||||
"ip": "IP",
|
||||
"alipay_user": "支付寶用戶",
|
||||
"wechat_openid": "微信 OpenId",
|
||||
"overseas_ip": "海外IP"
|
||||
"overseas_ip": "海外IP",
|
||||
"province": "省份"
|
||||
}
|
||||
|
||||
@@ -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位"
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"strict": "嚴格",
|
||||
"balanced": "平衡",
|
||||
"loose": "寬鬆"
|
||||
}
|
||||
@@ -2,5 +2,6 @@
|
||||
"ip": "IP",
|
||||
"alipay_user": "支付寶用戶",
|
||||
"wechat_openid": "微信 OpenId",
|
||||
"overseas_ip": "海外IP"
|
||||
"overseas_ip": "海外IP",
|
||||
"province": "省份"
|
||||
}
|
||||
|
||||
@@ -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位"
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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()));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -87,4 +87,7 @@ public class PayRiskHitResult extends BaseResult {
|
||||
|
||||
@Schema(description = "门店号")
|
||||
private String storeNo;
|
||||
|
||||
@Schema(description = "地理围栏命中时生效的策略")
|
||||
private String geoFenceStrategy;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user