diff --git a/_config/sql/data.sql b/_config/sql/data.sql index fe257e016..05565ecda 100644 --- a/_config/sql/data.sql +++ b/_config/sql/data.sql @@ -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'); diff --git a/_config/sql/table.sql b/_config/sql/table.sql index 350ed5ab7..104f888d3 100644 --- a/_config/sql/table.sql +++ b/_config/sql/table.sql @@ -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 -- ---------------------------- diff --git a/daxpay-payment/daxpay-payment-admin/src/main/java/cn/daxpay/open/payment/admin/controller/merchant/config/MchRiskConfigAdminController.java b/daxpay-payment/daxpay-payment-admin/src/main/java/cn/daxpay/open/payment/admin/controller/merchant/config/MchRiskConfigAdminController.java new file mode 100644 index 000000000..65c1d39ce --- /dev/null +++ b/daxpay-payment/daxpay-payment-admin/src/main/java/cn/daxpay/open/payment/admin/controller/merchant/config/MchRiskConfigAdminController.java @@ -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 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 saveOrUpdate(@RequestBody @Validated MchRiskConfigParam param) { + mchRiskConfigService.saveOrUpdate(param); + return Res.ok(); + } +} diff --git a/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/merchant/convert/config/MchRiskConfigConvert.java b/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/merchant/convert/config/MchRiskConfigConvert.java new file mode 100644 index 000000000..2510eac90 --- /dev/null +++ b/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/merchant/convert/config/MchRiskConfigConvert.java @@ -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); +} diff --git a/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/merchant/dao/config/MchRiskConfigManager.java b/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/merchant/dao/config/MchRiskConfigManager.java new file mode 100644 index 000000000..d6785014a --- /dev/null +++ b/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/merchant/dao/config/MchRiskConfigManager.java @@ -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 { + + /// 根据商户号查询风控配置 + public Optional findByMchNo(String mchNo) { + return findByField(MchRiskConfig::getMchNo, mchNo); + } +} diff --git a/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/merchant/dao/config/MchRiskConfigMapper.java b/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/merchant/dao/config/MchRiskConfigMapper.java new file mode 100644 index 000000000..2b4b9c9c4 --- /dev/null +++ b/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/merchant/dao/config/MchRiskConfigMapper.java @@ -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 { +} diff --git a/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/merchant/entity/config/MchRiskConfig.java b/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/merchant/entity/config/MchRiskConfig.java new file mode 100644 index 000000000..7b8a10e67 --- /dev/null +++ b/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/merchant/entity/config/MchRiskConfig.java @@ -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 { + + /// 是否启用地理围栏(商户级 opt-in, 默认关闭) + private Boolean geoFenceEnabled = Boolean.FALSE; + + @Override + public MchRiskConfigResult toResult() { + return MchRiskConfigConvert.CONVERT.toResult(this); + } +} diff --git a/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/merchant/param/config/MchRiskConfigParam.java b/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/merchant/param/config/MchRiskConfigParam.java new file mode 100644 index 000000000..e4b554587 --- /dev/null +++ b/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/merchant/param/config/MchRiskConfigParam.java @@ -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; +} diff --git a/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/merchant/result/config/MchRiskConfigResult.java b/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/merchant/result/config/MchRiskConfigResult.java new file mode 100644 index 000000000..8b8d3a6a4 --- /dev/null +++ b/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/merchant/result/config/MchRiskConfigResult.java @@ -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; +} diff --git a/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/merchant/service/config/MchRiskConfigService.java b/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/merchant/service/config/MchRiskConfigService.java new file mode 100644 index 000000000..6fc29e86c --- /dev/null +++ b/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/merchant/service/config/MchRiskConfigService.java @@ -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 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; + } +} diff --git a/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/strategy/risk/GeoFenceStrategyEnum.java b/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/strategy/risk/GeoFenceStrategyEnum.java new file mode 100644 index 000000000..8b18f6f5f --- /dev/null +++ b/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/strategy/risk/GeoFenceStrategyEnum.java @@ -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 findByCode(String code) { + return Arrays.stream(values()).filter(e -> e.code.equals(code)).findFirst(); + } +} diff --git a/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/strategy/risk/GeoFenceUtil.java b/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/strategy/risk/GeoFenceUtil.java new file mode 100644 index 000000000..70f83471a --- /dev/null +++ b/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/strategy/risk/GeoFenceUtil.java @@ -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; + } +} diff --git a/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/strategy/risk/PayRiskCheckContext.java b/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/strategy/risk/PayRiskCheckContext.java index c617f7ce7..804eb6458 100644 --- a/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/strategy/risk/PayRiskCheckContext.java +++ b/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/strategy/risk/PayRiskCheckContext.java @@ -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 storeAllowedCities; /// 客户端 IP 归属城市(命中落库快照, 由检查器解析 IP 后填入) private String clientCity; } diff --git a/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/trade/runtime/service/pay/common/PayRiskAssistService.java b/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/trade/runtime/service/pay/common/PayRiskAssistService.java index 168c8cfbb..85cac72d5 100644 --- a/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/trade/runtime/service/pay/common/PayRiskAssistService.java +++ b/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/trade/runtime/service/pay/common/PayRiskAssistService.java @@ -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 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 buildAllowedCities(StoreCityInfo info, String strategy) { + GeoFenceStrategyEnum st = GeoFenceStrategyEnum.findByCode(strategy) + .orElse(GeoFenceStrategyEnum.BALANCED); + Set allowed = new HashSet<>(); + allowed.add(GeoFenceUtil.normalizeRegionName(info.cityName())); + if (st == GeoFenceStrategyEnum.STRICT) { + return allowed; + } + // balanced: 接壤邻市 + Set 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; + } } diff --git a/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/en-US/enum/geo_fence_strategy.json b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/en-US/enum/geo_fence_strategy.json new file mode 100644 index 000000000..9d91df7b2 --- /dev/null +++ b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/en-US/enum/geo_fence_strategy.json @@ -0,0 +1,5 @@ +{ + "strict": "Strict", + "balanced": "Balanced", + "loose": "Relaxed" +} diff --git a/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/en-US/enum/pay_blacklist_type.json b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/en-US/enum/pay_blacklist_type.json index a9fb43a74..b890de2dc 100644 --- a/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/en-US/enum/pay_blacklist_type.json +++ b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/en-US/enum/pay_blacklist_type.json @@ -2,5 +2,6 @@ "ip": "IP", "alipay_user": "Alipay user", "wechat_openid": "WeChat OpenId", - "overseas_ip": "Overseas IP" + "overseas_ip": "Overseas IP", + "province": "Province" } diff --git a/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/en-US/validation.json b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/en-US/validation.json index d8ed520cf..7fbe76d16 100644 --- a/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/en-US/validation.json +++ b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/en-US/validation.json @@ -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" diff --git a/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/id-ID/enum/geo_fence_strategy.json b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/id-ID/enum/geo_fence_strategy.json new file mode 100644 index 000000000..9378527d5 --- /dev/null +++ b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/id-ID/enum/geo_fence_strategy.json @@ -0,0 +1,5 @@ +{ + "strict": "Ketat", + "balanced": "Seimbang", + "loose": "Longgar" +} diff --git a/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/id-ID/enum/pay_blacklist_type.json b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/id-ID/enum/pay_blacklist_type.json index 8d3541b7c..0a1ddb5ab 100644 --- a/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/id-ID/enum/pay_blacklist_type.json +++ b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/id-ID/enum/pay_blacklist_type.json @@ -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" } diff --git a/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/id-ID/validation.json b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/id-ID/validation.json index fd627b4fa..250841bf5 100644 --- a/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/id-ID/validation.json +++ b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/id-ID/validation.json @@ -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" }, diff --git a/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/ja-JP/enum/geo_fence_strategy.json b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/ja-JP/enum/geo_fence_strategy.json new file mode 100644 index 000000000..8b5dc677d --- /dev/null +++ b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/ja-JP/enum/geo_fence_strategy.json @@ -0,0 +1,5 @@ +{ + "strict": "厳格", + "balanced": "バランス", + "loose": "緩和" +} diff --git a/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/ja-JP/enum/pay_blacklist_type.json b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/ja-JP/enum/pay_blacklist_type.json index ccbd93a73..726b9a51b 100644 --- a/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/ja-JP/enum/pay_blacklist_type.json +++ b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/ja-JP/enum/pay_blacklist_type.json @@ -2,5 +2,6 @@ "ip": "IP", "alipay_user": "Alipay ユーザー", "wechat_openid": "WeChat OpenId", - "overseas_ip": "海外IP" + "overseas_ip": "海外IP", + "province": "省" } diff --git a/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/ja-JP/validation.json b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/ja-JP/validation.json index 8e90ee823..ad066a664 100644 --- a/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/ja-JP/validation.json +++ b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/ja-JP/validation.json @@ -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文字以内です" diff --git a/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/ko-KR/enum/geo_fence_strategy.json b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/ko-KR/enum/geo_fence_strategy.json new file mode 100644 index 000000000..b4586673c --- /dev/null +++ b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/ko-KR/enum/geo_fence_strategy.json @@ -0,0 +1,5 @@ +{ + "strict": "엄격", + "balanced": "균형", + "loose": "완화" +} diff --git a/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/ko-KR/enum/pay_blacklist_type.json b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/ko-KR/enum/pay_blacklist_type.json index ebc344e51..07f8c9f63 100644 --- a/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/ko-KR/enum/pay_blacklist_type.json +++ b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/ko-KR/enum/pay_blacklist_type.json @@ -2,5 +2,6 @@ "ip": "IP", "alipay_user": "알리페이 사용자", "wechat_openid": "위챗 OpenId", - "overseas_ip": "해외 IP" + "overseas_ip": "해외 IP", + "province": "성(省)" } diff --git a/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/ko-KR/validation.json b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/ko-KR/validation.json index b3d72bc8e..7cbeeda89 100644 --- a/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/ko-KR/validation.json +++ b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/ko-KR/validation.json @@ -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자를 초과할 수 없습니다" diff --git a/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/ms-MY/enum/geo_fence_strategy.json b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/ms-MY/enum/geo_fence_strategy.json new file mode 100644 index 000000000..9378527d5 --- /dev/null +++ b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/ms-MY/enum/geo_fence_strategy.json @@ -0,0 +1,5 @@ +{ + "strict": "Ketat", + "balanced": "Seimbang", + "loose": "Longgar" +} diff --git a/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/ms-MY/enum/pay_blacklist_type.json b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/ms-MY/enum/pay_blacklist_type.json index 84f1b9630..c7ab82c25 100644 --- a/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/ms-MY/enum/pay_blacklist_type.json +++ b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/ms-MY/enum/pay_blacklist_type.json @@ -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" } diff --git a/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/ms-MY/validation.json b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/ms-MY/validation.json index 61939eff8..0f90f040a 100644 --- a/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/ms-MY/validation.json +++ b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/ms-MY/validation.json @@ -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" }, diff --git a/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/th-TH/enum/geo_fence_strategy.json b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/th-TH/enum/geo_fence_strategy.json new file mode 100644 index 000000000..27dac647a --- /dev/null +++ b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/th-TH/enum/geo_fence_strategy.json @@ -0,0 +1,5 @@ +{ + "strict": "เข้มงวด", + "balanced": "สมดุล", + "loose": "ผ่อนปรน" +} diff --git a/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/th-TH/enum/pay_blacklist_type.json b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/th-TH/enum/pay_blacklist_type.json index 573fbf587..c8881725a 100644 --- a/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/th-TH/enum/pay_blacklist_type.json +++ b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/th-TH/enum/pay_blacklist_type.json @@ -2,5 +2,6 @@ "ip": "IP", "alipay_user": "ผู้ใช้ Alipay", "wechat_openid": "OpenId WeChat", - "overseas_ip": "IP ต่างประเทศ" + "overseas_ip": "IP ต่างประเทศ", + "province": "จังหวัด" } diff --git a/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/th-TH/validation.json b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/th-TH/validation.json index 7620c945b..814deda49 100644 --- a/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/th-TH/validation.json +++ b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/th-TH/validation.json @@ -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": "ต้องระบุรหัสรายการแคชเชียร์" }, diff --git a/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/vi-VN/enum/geo_fence_strategy.json b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/vi-VN/enum/geo_fence_strategy.json new file mode 100644 index 000000000..0022935dc --- /dev/null +++ b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/vi-VN/enum/geo_fence_strategy.json @@ -0,0 +1,5 @@ +{ + "strict": "Nghiêm ngặt", + "balanced": "Cân bằng", + "loose": "Lỏng lẻo" +} diff --git a/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/vi-VN/enum/pay_blacklist_type.json b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/vi-VN/enum/pay_blacklist_type.json index 8acc59747..3bee09f0c 100644 --- a/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/vi-VN/enum/pay_blacklist_type.json +++ b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/vi-VN/enum/pay_blacklist_type.json @@ -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" } diff --git a/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/vi-VN/validation.json b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/vi-VN/validation.json index 9af8c7871..61795adaa 100644 --- a/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/vi-VN/validation.json +++ b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/vi-VN/validation.json @@ -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" }, diff --git a/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/zh-CN/enum/geo_fence_strategy.json b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/zh-CN/enum/geo_fence_strategy.json new file mode 100644 index 000000000..21f5d40c6 --- /dev/null +++ b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/zh-CN/enum/geo_fence_strategy.json @@ -0,0 +1,5 @@ +{ + "strict": "严格", + "balanced": "平衡", + "loose": "宽松" +} diff --git a/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/zh-CN/enum/pay_blacklist_type.json b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/zh-CN/enum/pay_blacklist_type.json index 7d0172269..ab48a1c90 100644 --- a/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/zh-CN/enum/pay_blacklist_type.json +++ b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/zh-CN/enum/pay_blacklist_type.json @@ -2,5 +2,6 @@ "ip": "IP", "alipay_user": "支付宝用户", "wechat_openid": "微信 OpenId", - "overseas_ip": "海外IP" + "overseas_ip": "海外IP", + "province": "省份" } diff --git a/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/zh-CN/validation.json b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/zh-CN/validation.json index 4c7fdc8a7..076123c6a 100644 --- a/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/zh-CN/validation.json +++ b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/zh-CN/validation.json @@ -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位" diff --git a/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/zh-HK/enum/geo_fence_strategy.json b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/zh-HK/enum/geo_fence_strategy.json new file mode 100644 index 000000000..6ce019e99 --- /dev/null +++ b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/zh-HK/enum/geo_fence_strategy.json @@ -0,0 +1,5 @@ +{ + "strict": "嚴格", + "balanced": "平衡", + "loose": "寬鬆" +} diff --git a/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/zh-HK/enum/pay_blacklist_type.json b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/zh-HK/enum/pay_blacklist_type.json index 08c48b60b..65ab4d5a6 100644 --- a/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/zh-HK/enum/pay_blacklist_type.json +++ b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/zh-HK/enum/pay_blacklist_type.json @@ -2,5 +2,6 @@ "ip": "IP", "alipay_user": "支付寶用戶", "wechat_openid": "微信 OpenId", - "overseas_ip": "海外IP" + "overseas_ip": "海外IP", + "province": "省份" } diff --git a/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/zh-HK/validation.json b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/zh-HK/validation.json index 25bc9fa2a..23c689b70 100644 --- a/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/zh-HK/validation.json +++ b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/zh-HK/validation.json @@ -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位" diff --git a/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/zh-TW/enum/geo_fence_strategy.json b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/zh-TW/enum/geo_fence_strategy.json new file mode 100644 index 000000000..6ce019e99 --- /dev/null +++ b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/zh-TW/enum/geo_fence_strategy.json @@ -0,0 +1,5 @@ +{ + "strict": "嚴格", + "balanced": "平衡", + "loose": "寬鬆" +} diff --git a/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/zh-TW/enum/pay_blacklist_type.json b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/zh-TW/enum/pay_blacklist_type.json index 08c48b60b..65ab4d5a6 100644 --- a/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/zh-TW/enum/pay_blacklist_type.json +++ b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/zh-TW/enum/pay_blacklist_type.json @@ -2,5 +2,6 @@ "ip": "IP", "alipay_user": "支付寶用戶", "wechat_openid": "微信 OpenId", - "overseas_ip": "海外IP" + "overseas_ip": "海外IP", + "province": "省份" } diff --git a/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/zh-TW/validation.json b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/zh-TW/validation.json index 0fdfceabd..ff1845dfd 100644 --- a/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/zh-TW/validation.json +++ b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/zh-TW/validation.json @@ -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位" diff --git a/daxpay-platform/daxpay-platform-core/src/main/java/cn/daxpay/open/platform/core/code/PermCodes.java b/daxpay-platform/daxpay-platform-core/src/main/java/cn/daxpay/open/platform/core/code/PermCodes.java index 0d5cb3427..f6f51c931 100644 --- a/daxpay-platform/daxpay-platform-core/src/main/java/cn/daxpay/open/platform/core/code/PermCodes.java +++ b/daxpay-platform/daxpay-platform-core/src/main/java/cn/daxpay/open/platform/core/code/PermCodes.java @@ -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"; + } } } diff --git a/daxpay-platform/daxpay-platform-service/service-system/src/main/java/cn/daxpay/open/platform/system/controller/region/ChinaRegionController.java b/daxpay-platform/daxpay-platform-service/service-system/src/main/java/cn/daxpay/open/platform/system/controller/region/ChinaRegionController.java index 75d0d0291..8f0c6db9a 100644 --- a/daxpay-platform/daxpay-platform-service/service-system/src/main/java/cn/daxpay/open/platform/system/controller/region/ChinaRegionController.java +++ b/daxpay-platform/daxpay-platform-service/service-system/src/main/java/cn/daxpay/open/platform/system/controller/region/ChinaRegionController.java @@ -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> 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 previewGeoFence(@NotBlank(message = "{validation.field.regionCode.notBlank}") String cityCode) { + return Res.ok(chinaRegionService.previewGeoFence(cityCode)); + } } diff --git a/daxpay-platform/daxpay-platform-service/service-system/src/main/java/cn/daxpay/open/platform/system/result/region/GeoFencePreviewResult.java b/daxpay-platform/daxpay-platform-service/service-system/src/main/java/cn/daxpay/open/platform/system/result/region/GeoFencePreviewResult.java new file mode 100644 index 000000000..84191eac6 --- /dev/null +++ b/daxpay-platform/daxpay-platform-service/service-system/src/main/java/cn/daxpay/open/platform/system/result/region/GeoFencePreviewResult.java @@ -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 adjacentCities; + + @Schema(description = "同省全部城市(loose 策略用, 含选中市本身)") + private List 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; + } +} diff --git a/daxpay-platform/daxpay-platform-service/service-system/src/main/java/cn/daxpay/open/platform/system/service/region/ChinaRegionService.java b/daxpay-platform/daxpay-platform-service/service-system/src/main/java/cn/daxpay/open/platform/system/service/region/ChinaRegionService.java index 9472fdf90..df443ee04 100644 --- a/daxpay-platform/daxpay-platform-service/service-system/src/main/java/cn/daxpay/open/platform/system/service/region/ChinaRegionService.java +++ b/daxpay-platform/daxpay-platform-service/service-system/src/main/java/cn/daxpay/open/platform/system/service/region/ChinaRegionService.java @@ -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 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 provinceNameMap = provinceManager.findAll().stream() + .collect(Collectors.toMap(Province::getCode, Province::getName, (a, b) -> a)); + + // 交界邻市编码集合 → 批量解析为城市实体(交界关系可能跨省) + Set adjacentCodes = chinaRegionAdjacencyService.findAdjacentCityCodes(cityCode); + List adjacentCities = cityManager.findAllByIds(adjacentCodes); + + // 同省全部城市(loose 策略范围, 含自身) + List 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 provinceNameMap) { + return new GeoFencePreviewResult.CityInfo() + .setCode(c.getCode()) + .setName(c.getName()) + .setProvinceCode(c.getProvinceCode()) + .setProvinceName(provinceNameMap.get(c.getProvinceCode())); + } + } diff --git a/daxpay-plugin/daxpay-plugin-risk/src/main/java/cn/daxpay/open/plugin/risk/entity/PayRiskHit.java b/daxpay-plugin/daxpay-plugin-risk/src/main/java/cn/daxpay/open/plugin/risk/entity/PayRiskHit.java index af30afc79..b628d5272 100644 --- a/daxpay-plugin/daxpay-plugin-risk/src/main/java/cn/daxpay/open/plugin/risk/entity/PayRiskHit.java +++ b/daxpay-plugin/daxpay-plugin-risk/src/main/java/cn/daxpay/open/plugin/risk/entity/PayRiskHit.java @@ -87,6 +87,9 @@ public class PayRiskHit extends MpBaseEntity implements ToResult 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); + } }