feat(risk): 增加拦截级别开关与海外IP拦截占位字段

- 新增 PayRiskOpenIdLevelEnum 枚举(NORMAL/ENHANCED), 控制对 H5/主扫等免 openId 方式是否强制 OAuth
- PlatformPaySecurityConfig 增加 riskOpenIdLevel 与 blockOverseasIp 字段(后者占位, 后续接入 IP 地理位置库)
- AggregatePayService/CashierPayService/CodePayAssistService 在 hasOpenIdBlacklist 调用前先判 level=NORMAL 短路
- 同步 10 语种 validation 与新增 enum/pay_risk_open_id_level.json
This commit is contained in:
DaxPay Dev
2026-07-20 15:50:56 +08:00
parent 3300d94722
commit 68fb2b9cf9
28 changed files with 204 additions and 8 deletions

View File

@@ -15,6 +15,8 @@ import cn.daxpay.open.payment.unipay.result.trade.pay.NormalPayResult;
import cn.daxpay.open.platform.common.spring.util.WebServletUtil;
import cn.daxpay.open.platform.core.code.CommonErrorCode;
import cn.daxpay.open.platform.core.exception.BizInfoException;
import cn.daxpay.open.platform.system.enums.PayRiskOpenIdLevelEnum;
import cn.daxpay.open.platform.system.service.config.security.PlatformSecurityConfigService;
import cn.hutool.core.util.StrUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -37,6 +39,8 @@ public class AggregatePayService {
private final GatewayAggregateConfigManager aggregateConfigManager;
/// 风控检查器(可选 SPI用于判断是否存在 openId 黑名单, 决定是否触发强制 OAuth
private final ObjectProvider<PayRiskChecker> payRiskCheckerProvider;
/// 平台安全配置(读取 openId 拦截级别, 决定 NORMAL 模式下不触发强制 OAuth
private final PlatformSecurityConfigService platformSecurityConfigService;
/// H5 聚合元数据: autoLaunch / needOpenId(不下发敏感路由字段)
public AggregatePayMetaResult getMeta(String orderNo, String clientEnvCode, String runtimeCode) {
@@ -79,16 +83,23 @@ public class AggregatePayService {
/// openId 触发判定
///
/// 1. JSAPI/MINI 类方式: 业务必需, 永远 true与历史行为一致
/// 2. 主扫/H5 等免 openId 方式: 仅当存在 openId 黑名单且当前 clientEnv 可 OAuth 时 true,
/// 2. 主扫/H5 等免 openId 方式: 仅当 openId 拦截级别为 ENHANCED,
/// 且存在 openId 黑名单, 且当前 clientEnv 可 OAuth 时 true,
/// 实现 openId 黑名单在聚合网关内的全局拦截
private boolean resolveNeedOpenId(String method, ClientEnvEnum clientEnv) {
if (PayMethodOpenIdSupport.needsOpenId(method)) {
return true;
}
PayRiskChecker checker = payRiskCheckerProvider.getIfAvailable();
if (checker == null || !checker.hasOpenIdBlacklist()) {
if (checker == null || !isEnhancedOpenIdLevel() || !checker.hasOpenIdBlacklist()) {
return false;
}
return PayMethodOpenIdSupport.canAcquireOpenId(method, clientEnv);
}
/// openId 拦截级别是否为增强模式NORMAL 时跳过强制 OAuth, 保留用户体验)
private boolean isEnhancedOpenIdLevel() {
String level = platformSecurityConfigService.getPaySecurityConfig().getRiskOpenIdLevel();
return PayRiskOpenIdLevelEnum.ENHANCED.getCode().equals(level);
}
}

View File

@@ -16,6 +16,8 @@ import cn.daxpay.open.platform.common.spring.util.WebServletUtil;
import cn.daxpay.open.platform.core.code.CommonErrorCode;
import cn.daxpay.open.platform.core.exception.BizInfoException;
import cn.daxpay.open.platform.core.exception.DataNotExistException;
import cn.daxpay.open.platform.system.enums.PayRiskOpenIdLevelEnum;
import cn.daxpay.open.platform.system.service.config.security.PlatformSecurityConfigService;
import cn.hutool.core.util.StrUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -48,6 +50,8 @@ public class CashierPayService {
private final GatewayCashierItemManager gatewayCashierItemManager;
/// 风控检查器(可选 SPI用于判断是否存在 openId 黑名单, 决定是否触发强制 OAuth
private final ObjectProvider<PayRiskChecker> payRiskCheckerProvider;
/// 平台安全配置(读取 openId 拦截级别, 决定 NORMAL 模式下不触发强制 OAuth
private final PlatformSecurityConfigService platformSecurityConfigService;
/// 公开支付项列表(落地页展示)
public List<CashierItemPublicResult> listPublicItems(String orderNo, String cashierType, String clientEnv) {
@@ -168,15 +172,22 @@ public class CashierPayService {
/// openId 触发判定(与聚合/码牌同源逻辑)
///
/// 1. JSAPI/MINI 类方式: 业务必需, 永远 true
/// 2. 主扫/H5 等免 openId 方式: 仅当存在 openId 黑名单且当前 clientEnv 可 OAuth 时 true
/// 2. 主扫/H5 等免 openId 方式: 仅当 openId 拦截级别为 ENHANCED,
/// 且存在 openId 黑名单且当前 clientEnv 可 OAuth 时 true
private boolean resolveItemNeedOpenId(String method, ClientEnvEnum clientEnv) {
if (PayMethodOpenIdSupport.needsOpenId(method)) {
return true;
}
PayRiskChecker checker = payRiskCheckerProvider.getIfAvailable();
if (checker == null || !checker.hasOpenIdBlacklist()) {
if (checker == null || !isEnhancedOpenIdLevel() || !checker.hasOpenIdBlacklist()) {
return false;
}
return PayMethodOpenIdSupport.canAcquireOpenId(method, clientEnv);
}
/// openId 拦截级别是否为增强模式NORMAL 时跳过强制 OAuth, 保留用户体验)
private boolean isEnhancedOpenIdLevel() {
String level = platformSecurityConfigService.getPaySecurityConfig().getRiskOpenIdLevel();
return PayRiskOpenIdLevelEnum.ENHANCED.getCode().equals(level);
}
}

View File

@@ -37,6 +37,8 @@ import cn.daxpay.open.platform.core.exception.BizInfoException;
import cn.daxpay.open.platform.core.exception.DataNotExistException;
import cn.daxpay.open.platform.core.exception.operation.OperationFailException;
import cn.daxpay.open.platform.core.util.TradeNoGenerateUtil;
import cn.daxpay.open.platform.system.enums.PayRiskOpenIdLevelEnum;
import cn.daxpay.open.platform.system.service.config.security.PlatformSecurityConfigService;
import cn.hutool.core.util.StrUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -64,6 +66,8 @@ public class CodePayAssistService {
private final NormalPayOrderManager normalPayOrderManager;
/// 风控检查器(可选 SPI用于判断是否存在 openId 黑名单, 决定是否触发强制 OAuth
private final ObjectProvider<PayRiskChecker> payRiskCheckerProvider;
/// 平台安全配置(读取 openId 拦截级别, 决定 NORMAL 模式下不触发强制 OAuth
private final PlatformSecurityConfigService platformSecurityConfigService;
/// 根据码牌编码查询支付信息(公开接口, 脱敏返回)
///
@@ -268,19 +272,26 @@ public class CodePayAssistService {
/// openId 触发判定
///
/// 1. JSAPI/MINI 类方式: 业务必需, 永远 true与历史行为一致
/// 2. 主扫/H5 等免 openId 方式: 仅当存在 openId 黑名单且当前 clientEnv 可 OAuth 时 true,
/// 2. 主扫/H5 等免 openId 方式: 仅当 openId 拦截级别为 ENHANCED,
/// 且存在 openId 黑名单且当前 clientEnv 可 OAuth 时 true,
/// 实现 openId 黑名单在码牌场景的全局拦截
private boolean resolveNeedOpenId(String method, ClientEnvEnum clientEnv) {
if (PayMethodOpenIdSupport.needsOpenId(method)) {
return true;
}
PayRiskChecker checker = payRiskCheckerProvider.getIfAvailable();
if (checker == null || !checker.hasOpenIdBlacklist()) {
if (checker == null || !isEnhancedOpenIdLevel() || !checker.hasOpenIdBlacklist()) {
return false;
}
return PayMethodOpenIdSupport.canAcquireOpenId(method, clientEnv);
}
/// openId 拦截级别是否为增强模式NORMAL 时跳过强制 OAuth, 保留用户体验)
private boolean isEnhancedOpenIdLevel() {
String level = platformSecurityConfigService.getPaySecurityConfig().getRiskOpenIdLevel();
return PayRiskOpenIdLevelEnum.ENHANCED.getCode().equals(level);
}
/// 分端页 returnPath: /h/wechat|{alipay}/:code?authed=1
private String buildReturnPath(ClientEnvEnum clientEnv, String code) {
String segment = switch (clientEnv) {

View File

@@ -0,0 +1,4 @@
{
"normal": "Normal",
"enhanced": "Enhanced"
}

View File

@@ -791,6 +791,12 @@
"riskCheckAfterPay": {
"notNull": "Post-pay logging toggle cannot be empty"
},
"riskOpenIdLevel": {
"notNull": "openId interception level cannot be empty"
},
"blockOverseasIp": {
"notNull": "Overseas IP block toggle cannot be empty"
},
"reqId": {
"notBlank": "Request ID is required",
"size": "Request ID must not exceed 64 characters"

View File

@@ -0,0 +1,4 @@
{
"normal": "Blokir Normal",
"enhanced": "Blokir Ditingkatkan"
}

View File

@@ -797,6 +797,12 @@
},
"riskCheckAfterPay": {
"notNull": "Pilihan pemeriksaan setelah pembayaran wajib diisi"
},
"riskOpenIdLevel": {
"notNull": "Level intercept openId tidak boleh kosong"
},
"blockOverseasIp": {
"notNull": "Saklar blokir IP luar negeri tidak boleh kosong"
}
}
}

View File

@@ -0,0 +1,4 @@
{
"normal": "通常ブロック",
"enhanced": "強化ブロック"
}

View File

@@ -791,6 +791,12 @@
"riskCheckAfterPay": {
"notNull": "決済後記録スイッチは必須です"
},
"riskOpenIdLevel": {
"notNull": "openId ブロック レベルは必須です"
},
"blockOverseasIp": {
"notNull": "海外 IP ブロック スイッチは必須です"
},
"reqId": {
"notBlank": "リクエストIDは必須です",
"size": "リクエストIDは64文字以内です"

View File

@@ -0,0 +1,4 @@
{
"normal": "일반 차단",
"enhanced": "강화 차단"
}

View File

@@ -791,6 +791,12 @@
"riskCheckAfterPay": {
"notNull": "결제 후 기록 스위치는 필수입니다"
},
"riskOpenIdLevel": {
"notNull": "openId 차단 레벨은 필수입니다"
},
"blockOverseasIp": {
"notNull": "해외 IP 차단 스위치는 필수입니다"
},
"reqId": {
"notBlank": "요청 ID는 필수입니다",
"size": "요청 ID는 64자를 초과할 수 없습니다"

View File

@@ -0,0 +1,4 @@
{
"normal": "Sekatan Biasa",
"enhanced": "Sekatan Diperkuat"
}

View File

@@ -797,6 +797,12 @@
},
"riskCheckAfterPay": {
"notNull": "Pilihan semakan selepas pembayaran tidak boleh kosong"
},
"riskOpenIdLevel": {
"notNull": "Tahap sekatan openId tidak boleh kosong"
},
"blockOverseasIp": {
"notNull": "Suis sekatan IP luar negara tidak boleh kosong"
}
}
}

View File

@@ -0,0 +1,4 @@
{
"normal": "บล็อกปกติ",
"enhanced": "บล็อกเข้มข้น"
}

View File

@@ -797,6 +797,12 @@
},
"riskCheckAfterPay": {
"notNull": "ตัวเลือกการตรวจสอบหลังชำระเงินต้องไม่เป็นค่าว่าง"
},
"riskOpenIdLevel": {
"notNull": "ระดับการบล็อก openId ต้องไม่ว่าง"
},
"blockOverseasIp": {
"notNull": "สวิตช์บล็อก IP ต่างประเทศต้องไม่ว่าง"
}
}
}

View File

@@ -0,0 +1,4 @@
{
"normal": "Chặn Bình Thường",
"enhanced": "Chặn Tăng Cường"
}

View File

@@ -797,6 +797,12 @@
},
"riskCheckAfterPay": {
"notNull": "Tùy chọn kiểm tra sau thanh toán là bắt buộc"
},
"riskOpenIdLevel": {
"notNull": "Cấp độ chặn openId không được để trống"
},
"blockOverseasIp": {
"notNull": "Công tắc chặn IP nước ngoài không được để trống"
}
}
}

View File

@@ -0,0 +1,4 @@
{
"normal": "正常拦截",
"enhanced": "增强拦截"
}

View File

@@ -791,6 +791,12 @@
"riskCheckAfterPay": {
"notNull": "事后补录开关不能为空"
},
"riskOpenIdLevel": {
"notNull": "openId 拦截级别不能为空"
},
"blockOverseasIp": {
"notNull": "海外 IP 拦截开关不能为空"
},
"reqId": {
"notBlank": "请求ID不可为空",
"size": "请求ID不可超过64位"

View File

@@ -0,0 +1,4 @@
{
"normal": "正常攔截",
"enhanced": "增強攔截"
}

View File

@@ -791,6 +791,12 @@
"riskCheckAfterPay": {
"notNull": "事後補錄開關不能為空"
},
"riskOpenIdLevel": {
"notNull": "openId 攔截級別不能為空"
},
"blockOverseasIp": {
"notNull": "海外 IP 攔截開關不能為空"
},
"reqId": {
"notBlank": "請求ID不可為空",
"size": "請求ID不可超過64位"

View File

@@ -0,0 +1,4 @@
{
"normal": "正常攔截",
"enhanced": "增強攔截"
}

View File

@@ -791,6 +791,12 @@
"riskCheckAfterPay": {
"notNull": "事後補錄開關不能為空"
},
"riskOpenIdLevel": {
"notNull": "openId 攔截級別不能為空"
},
"blockOverseasIp": {
"notNull": "海外 IP 攔截開關不能為空"
},
"reqId": {
"notBlank": "請求ID不可為空",
"size": "請求ID不可超過64位"

View File

@@ -1,5 +1,6 @@
package cn.daxpay.open.platform.system.entity.config.platform.security;
import cn.daxpay.open.platform.system.enums.PayRiskOpenIdLevelEnum;
import lombok.Data;
import lombok.experimental.Accessors;
@@ -12,6 +13,8 @@ import lombok.experimental.Accessors;
/// - 风控总开关: 关闭后支付主链路跳过所有风控检查
/// - 命中阻断: 支付前命中黑名单是否拒绝下单(关闭则仅记录不拦截)
/// - 事后补录: 支付成功后是否补充记录命中(用于事后分析,不阻断资金态)
/// - openId 拦截级别: 是否对 H5 / 主扫等免 openId 方式强制 OAuth, 见 [PayRiskOpenIdLevelEnum]
/// - 海外 IP 拦截: 占位字段, 后续接入 IP 地理位置库后实现
@Data
@Accessors(chain = true)
public class PlatformPaySecurityConfig {
@@ -24,4 +27,11 @@ public class PlatformPaySecurityConfig {
/// 支付成功后是否补录命中(用于事后分析)
private Boolean riskCheckAfterPay = Boolean.TRUE;
/// openId 拦截级别(默认增强拦截, 保持历史行为)
/// @see PayRiskOpenIdLevelEnum
private String riskOpenIdLevel = PayRiskOpenIdLevelEnum.ENHANCED.getCode();
/// 海外 IP 拦截(占位字段, 默认关闭, 后续接入地理位置库后实现)
private Boolean blockOverseasIp = Boolean.FALSE;
}

View File

@@ -0,0 +1,36 @@
package cn.daxpay.open.platform.system.enums;
import cn.daxpay.open.platform.core.i18n.I18nSupport;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import java.util.Arrays;
import java.util.Optional;
/// # openId 拦截级别
///
/// 控制 openId 黑名单对「原本免 openId 支付方式H5 / 主扫)」是否触发强制 OAuth。
/// JSAPI / 小程序原生方式业务本身就要 openId, 不受此开关影响。
///
/// - [NORMAL]: 仅做 IP + 事后补录, 不为风控额外触发 OAuth保留用户体验
/// - [ENHANCED]: 存在 openId 名单时, 对 H5 / 主扫等可 OAuth 方式强制静默授权
@Getter
@RequiredArgsConstructor
public enum PayRiskOpenIdLevelEnum implements I18nSupport {
/// 正常拦截(仅 IP + 事后补录)
NORMAL("normal"),
/// 增强拦截(对 H5 / 主扫强制 OAuth
ENHANCED("enhanced");
private final String code;
@Override
public String getI18nPrefix() {
return "enum.pay_risk_open_id_level";
}
public static Optional<PayRiskOpenIdLevelEnum> findByCode(String code) {
return Arrays.stream(values()).filter(e -> e.code.equals(code)).findFirst();
}
}

View File

@@ -23,4 +23,12 @@ public class PlatformPaySecurityConfigParam {
@Schema(description = "支付成功后是否补录命中(用于事后分析)")
@NotNull(message = "{validation.field.riskCheckAfterPay.notNull}")
private Boolean riskCheckAfterPay;
@Schema(description = "openId 拦截级别normal=正常拦截 / enhanced=增强拦截)")
@NotNull(message = "{validation.field.riskOpenIdLevel.notNull}")
private String riskOpenIdLevel;
@Schema(description = "海外 IP 拦截(占位字段, 默认关闭, 后续接入)")
@NotNull(message = "{validation.field.blockOverseasIp.notNull}")
private Boolean blockOverseasIp;
}

View File

@@ -22,4 +22,10 @@ public class PlatformPaySecurityConfigResult extends BaseResult {
@Schema(description = "支付成功后是否补录命中(用于事后分析)")
private Boolean riskCheckAfterPay;
@Schema(description = "openId 拦截级别normal=正常拦截 / enhanced=增强拦截)")
private String riskOpenIdLevel;
@Schema(description = "海外 IP 拦截(占位字段, 默认关闭, 后续接入)")
private Boolean blockOverseasIp;
}

View File

@@ -3,6 +3,7 @@ package cn.daxpay.open.platform.system.service.config.security;
import cn.daxpay.open.platform.common.json.util.JacksonUtil;
import cn.daxpay.open.platform.system.convert.config.security.PlatformSecurityConfigConvert;
import cn.daxpay.open.platform.system.entity.config.platform.security.*;
import cn.daxpay.open.platform.system.enums.PayRiskOpenIdLevelEnum;
import cn.daxpay.open.platform.system.enums.PlatformConfigTypeEnum;
import cn.daxpay.open.platform.system.param.config.security.*;
import cn.daxpay.open.platform.system.result.config.security.*;
@@ -257,12 +258,14 @@ public class PlatformSecurityConfigService {
return config == null ? defaultPaySecurityConfig() : config;
}
/// 支付安全配置默认值: 默认开启风控、命中阻断、事后补录
/// 支付安全配置默认值: 默认开启风控、命中阻断、事后补录、增强拦截
private PlatformPaySecurityConfig defaultPaySecurityConfig() {
return new PlatformPaySecurityConfig()
.setRiskEnabled(true)
.setRiskBlockBeforePay(true)
.setRiskCheckAfterPay(true);
.setRiskCheckAfterPay(true)
.setRiskOpenIdLevel(PayRiskOpenIdLevelEnum.ENHANCED.getCode())
.setBlockOverseasIp(false);
}
/// 获取支付安全配置(结果)