feat(payment): 通道商户沙箱标记与全局沙箱开关

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
DaxPay Dev
2026-07-09 17:46:41 +08:00
parent f8d5690f20
commit 6532e0166f
30 changed files with 166 additions and 15 deletions

View File

@@ -29,6 +29,9 @@ public class AlipayDirectChannelMerchant extends MchBaseEntity implements ToResu
/// 支付宝商家唯一识别码(2088开头的16位数字)
private String alipayUserId;
/// 是否沙箱环境商户
private boolean sandbox;
/// 转换
@Override
public AlipayDirectChannelMerchantResult toResult() {

View File

@@ -1,12 +1,16 @@
package cn.daxpay.open.channel.alipay.service.direct;
import cn.daxpay.open.channel.alipay.dao.direct.AlipayDirectAppManager;
import cn.daxpay.open.channel.alipay.dao.direct.AlipayDirectChannelMerchantManager;
import cn.daxpay.open.channel.alipay.client.credential.AlipaySdkCredential;
import cn.daxpay.open.channel.alipay.entity.direct.AlipayDirectApp;
import cn.daxpay.open.channel.alipay.entity.direct.AlipayDirectAppKeyConfig;
import cn.daxpay.open.channel.alipay.entity.direct.AlipayDirectChannelMerchant;
import cn.daxpay.open.payment.masterdata.constants.product.dao.PayProductConfigManager;
import cn.daxpay.open.platform.core.code.CommonErrorCode;
import cn.daxpay.open.platform.core.enums.pay.channel.ProductEnum;
import cn.daxpay.open.platform.core.enums.pay.config.PayEnvEnum;
import cn.daxpay.open.platform.core.exception.BizInfoException;
import cn.daxpay.open.platform.core.exception.DataNotExistException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -28,6 +32,7 @@ import java.util.Optional;
public class AlipayDirectConfigAssembler {
private final AlipayDirectAppManager alipayDirectAppManager;
private final AlipayDirectChannelMerchantManager alipayDirectChannelMerchantManager;
private final AlipayDirectAppKeyConfigService alipayDirectAppKeyConfigService;
private final AlipayDirectAppCapabilityService alipayDirectAppCapabilityService;
private final PayProductConfigManager payProductConfigManager;
@@ -45,6 +50,14 @@ public class AlipayDirectConfigAssembler {
boolean sandbox = payProductConfigManager.findByProduct(ProductEnum.ALIPAY.getCode())
.map(c -> PayEnvEnum.SANDBOX.getCode().equals(c.getActiveEnv()))
.orElse(false);
// 环境一致性校验: 通道商户绑定的沙箱标记需与产品当前生效环境一致
AlipayDirectChannelMerchant channelMerchant = alipayDirectChannelMerchantManager.lambdaQuery()
.eq(AlipayDirectChannelMerchant::getChannelMchNo, channelMchNo)
.oneOpt()
.orElseThrow(() -> new DataNotExistException("error.payment.channel.channelMerchantNotExist"));
if (channelMerchant.isSandbox() != sandbox) {
throw new BizInfoException(CommonErrorCode.VALIDATE_PARAMETERS_ERROR, "error.channel.envMismatch");
}
AlipayDirectAppKeyConfig keyConfig = alipayDirectAppKeyConfigService.findByAlipayDirectAppId(app.getId(), sandbox);
var credential = new AlipaySdkCredential();

View File

@@ -27,6 +27,9 @@ public class FuyouIsvChannelMerchant extends MchBaseEntity implements ToResult<F
/// @see cn.daxpay.open.platform.core.enums.pay.channel.ProductEnum
private String product;
/// 是否沙箱环境商户
private boolean sandbox;
/// 富友商户号(mchnt_cd)
private String fuyouMchNo;

View File

@@ -54,6 +54,10 @@ public class FuyouIsvConfigAssembler {
FuyouIsvChannelMerchant channelMerchant = fuyouIsvChannelMerchantManager.findByChannelMchNo(channelMchNo)
// 富友: 通道商户配置不存在
.orElseThrow(() -> new DataNotExistException("error.payment.channel.channelMerchantNotExist"));
// 环境一致性校验
if (channelMerchant.isSandbox() != sandbox) {
throw new BizInfoException(CommonErrorCode.VALIDATE_PARAMETERS_ERROR, "error.channel.envMismatch");
}
FuyouSdkCredential credential = new FuyouSdkCredential();
// 服务商身份与密钥

View File

@@ -27,6 +27,9 @@ public class HkrtIsvChannelMerchant extends MchBaseEntity implements ToResult<Hk
/// @see cn.daxpay.open.platform.core.enums.pay.channel.ProductEnum
private String product;
/// 是否沙箱环境商户
private boolean sandbox;
/// 海科商户号(merch_no)
private String merchNo;

View File

@@ -54,6 +54,10 @@ public class HkrtIsvConfigAssembler {
HkrtIsvChannelMerchant channelMerchant = hkrtIsvChannelMerchantManager.findByChannelMchNo(channelMchNo)
// 海科融通: 通道商户配置不存在
.orElseThrow(() -> new DataNotExistException("error.payment.channel.channelMerchantNotExist"));
// 环境一致性校验
if (channelMerchant.isSandbox() != sandbox) {
throw new BizInfoException(CommonErrorCode.VALIDATE_PARAMETERS_ERROR, "error.channel.envMismatch");
}
HkrtSdkCredential credential = new HkrtSdkCredential();
// 服务商身份与密钥

View File

@@ -27,6 +27,9 @@ public class HmpayIsvChannelMerchant extends MchBaseEntity implements ToResult<H
/// @see cn.daxpay.open.platform.core.enums.pay.channel.ProductEnum
private String product;
/// 是否沙箱环境商户
private boolean sandbox;
/// 杉德商户编号
private String merchantNo;

View File

@@ -6,8 +6,10 @@ import cn.daxpay.open.channel.hmpay.entity.isv.HmpayIsvChannelMerchant;
import cn.daxpay.open.channel.hmpay.entity.isv.HmpayIsvKeyConfig;
import cn.daxpay.open.payment.masterdata.constants.product.dao.PayProductConfigManager;
import cn.daxpay.open.payment.masterdata.constants.product.entity.PayProductConfig;
import cn.daxpay.open.platform.core.code.CommonErrorCode;
import cn.daxpay.open.platform.core.enums.pay.channel.ProductEnum;
import cn.daxpay.open.platform.core.enums.pay.config.PayEnvEnum;
import cn.daxpay.open.platform.core.exception.BizInfoException;
import cn.daxpay.open.platform.core.exception.DataNotExistException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -51,6 +53,11 @@ public class HmpayIsvConfigAssembler {
// 河马付: 通道商户配置不存在
.orElseThrow(() -> new DataNotExistException("payment.error.channel.channelMerchantNotExist"));
// 环境一致性校验
if (channelMerchant.isSandbox() != sandbox) {
throw new BizInfoException(CommonErrorCode.VALIDATE_PARAMETERS_ERROR, "error.channel.envMismatch");
}
HmpaySdkCredential credential = new HmpaySdkCredential();
// 服务商身份与密钥
credential.setSandAppId(keyConfig.getSandAppId());

View File

@@ -27,6 +27,9 @@ public class LakalaIsvChannelMerchant extends MchBaseEntity implements ToResult<
/// @see cn.daxpay.open.platform.core.enums.pay.channel.ProductEnum
private String product;
/// 是否沙箱环境商户
private boolean sandbox;
/// 拉卡拉商户编号(merchantNo)
private String lakalaMchNo;

View File

@@ -55,6 +55,10 @@ public class LakalaIsvConfigAssembler {
LakalaIsvChannelMerchant channelMerchant = lakalaIsvChannelMerchantManager.findByChannelMchNo(channelMchNo)
// 拉卡拉: 通道商户配置不存在
.orElseThrow(() -> new DataNotExistException("error.payment.channel.channelMerchantNotExist"));
// 环境一致性校验
if (channelMerchant.isSandbox() != sandbox) {
throw new BizInfoException(CommonErrorCode.VALIDATE_PARAMETERS_ERROR, "error.channel.envMismatch");
}
LakalaSdkCredential credential = new LakalaSdkCredential();
// 服务商身份与密钥

View File

@@ -27,6 +27,9 @@ public class LeshuaIsvChannelMerchant extends MchBaseEntity implements ToResult<
/// @see cn.daxpay.open.platform.core.enums.pay.channel.ProductEnum
private String product;
/// 是否沙箱环境商户
private boolean sandbox;
/// 乐刷商户编号(merchant_id)
private String lsMchNo;

View File

@@ -6,8 +6,10 @@ import cn.daxpay.open.channel.leshua.entity.isv.LeshuaIsvChannelMerchant;
import cn.daxpay.open.channel.leshua.entity.isv.LeshuaIsvKeyConfig;
import cn.daxpay.open.payment.masterdata.constants.product.dao.PayProductConfigManager;
import cn.daxpay.open.payment.masterdata.constants.product.entity.PayProductConfig;
import cn.daxpay.open.platform.core.code.CommonErrorCode;
import cn.daxpay.open.platform.core.enums.pay.channel.ProductEnum;
import cn.daxpay.open.platform.core.enums.pay.config.PayEnvEnum;
import cn.daxpay.open.platform.core.exception.BizInfoException;
import cn.daxpay.open.platform.core.exception.DataNotExistException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -47,6 +49,14 @@ public class LeshuaIsvConfigAssembler {
.orElse(false);
// 服务商密钥(按生效环境取对应环境密钥, 含 lsMchNo + tradeKey + signType)
LeshuaIsvKeyConfig keyConfig = leshuaIsvKeyConfigService.getByProductForPay(ProductEnum.LESHUA_PAY.getCode(), sandbox);
// 通道商户绑定(校验环境一致性)
LeshuaIsvChannelMerchant channelMerchant = leshuaIsvChannelMerchantManager.findByChannelMchNo(channelMchNo)
// 乐刷: 通道商户配置不存在
.orElseThrow(() -> new DataNotExistException("error.payment.channel.channelMerchantNotExist"));
// 环境一致性校验
if (channelMerchant.isSandbox() != sandbox) {
throw new BizInfoException(CommonErrorCode.VALIDATE_PARAMETERS_ERROR, "error.channel.envMismatch");
}
LeshuaSdkCredential credential = new LeshuaSdkCredential();
credential.setLsMchNo(keyConfig.getLsMchNo());

View File

@@ -27,6 +27,9 @@ public class VbillIsvChannelMerchant extends MchBaseEntity implements ToResult<V
/// @see cn.daxpay.open.platform.core.enums.pay.channel.ProductEnum
private String product;
/// 是否沙箱环境商户
private boolean sandbox;
/// 天阙商户号(mno)
private String vbillMchNo;

View File

@@ -56,6 +56,11 @@ public class VbillIsvConfigAssembler {
// 随行付: 通道商户配置不存在
.orElseThrow(() -> new DataNotExistException("error.payment.channel.channelMerchantNotExist"));
// 环境一致性校验
if (channelMerchant.isSandbox() != sandbox) {
throw new BizInfoException(CommonErrorCode.VALIDATE_PARAMETERS_ERROR, "error.channel.envMismatch");
}
VbillSdkCredential credential = new VbillSdkCredential();
// 服务商身份与密钥
credential.setOrgId(keyConfig.getOrgId());

View File

@@ -43,6 +43,9 @@ public class ChannelMerchant extends MchBaseEntity implements ToResult<ChannelMe
/// 申请单ID
private Long applyId;
/// 是否沙箱环境商户
private boolean sandbox;
public Boolean getEnable() {
return Objects.equals(enable, true);
}

View File

@@ -29,4 +29,8 @@ public class ChannelMerchantQuery {
@Schema(description = "所属商户")
private String mchNo;
/// 是否沙箱环境商户
@Schema(description = "是否沙箱环境商户")
private Boolean sandbox;
}

View File

@@ -40,7 +40,11 @@ public class ChannelMerchantResult extends MchTradeBaseResult {
@Schema(description = "是否启用")
private Boolean enable;
/// 当前生效环境(来自支付产品配置, 商户只读): prod/sandbox
/// 是否沙箱环境商户
@Schema(description = "是否沙箱环境商户")
private boolean sandbox;
/// 当前生效环境(来自商户 sandbox 字段, 商户只读): prod/sandbox
@Schema(description = "当前生效环境: prod/sandbox")
private String activeEnv;

View File

@@ -13,10 +13,9 @@ import cn.daxpay.open.payment.channel.param.mch.ChannelMerchantQuery;
import cn.daxpay.open.payment.channel.result.info.ChannelMerchantResult;
import cn.daxpay.open.payment.masterdata.constants.channel.service.PayChannelService;
import cn.daxpay.open.payment.masterdata.constants.channel.result.PayChannelResult;
import cn.daxpay.open.payment.masterdata.constants.product.dao.PayProductConfigManager;
import cn.daxpay.open.payment.masterdata.constants.product.dao.PayProductManager;
import cn.daxpay.open.payment.masterdata.constants.product.entity.PayProduct;
import cn.daxpay.open.payment.masterdata.constants.product.entity.PayProductConfig;
import cn.daxpay.open.platform.core.enums.pay.config.PayEnvEnum;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
@@ -37,7 +36,6 @@ public class ChannelMerchantService {
private final ChannelMerchantManager channelMerchantManager;
private final PayChannelService payChannelService;
private final MerchantPermissionService merchantPermissionService;
private final PayProductConfigManager payProductConfigManager;
private final PayProductManager payProductManager;
/// 分页
@@ -99,7 +97,8 @@ public class ChannelMerchantService {
return results;
}
/// 批量填充生效环境与沙箱支持标志(来自支付产品配置, 商户只读)
/// 批量填充生效环境与沙箱支持标志
/// activeEnv 从商户自身的 sandbox 字段推导, sandboxSupport 从支付产品表读取
private void fillEnvStatus(List<ChannelMerchantResult> results) {
if (results.isEmpty()) {
return;
@@ -111,20 +110,15 @@ public class ChannelMerchantService {
if (products.isEmpty()) {
return;
}
// 生效环境
Map<String, String> envMap = payProductConfigManager.lambdaQuery()
.in(PayProductConfig::getProduct, products)
.list()
.stream()
.collect(Collectors.toMap(PayProductConfig::getProduct, PayProductConfig::getActiveEnv, (a, b) -> a));
// 沙箱支持标志
// 沙箱支持标志(来自支付产品表, 决定前端是否显示环境标签)
Map<String, Boolean> sandboxMap = payProductManager.lambdaQuery()
.in(PayProduct::getCode, products)
.list()
.stream()
.collect(Collectors.toMap(PayProduct::getCode, p -> Boolean.TRUE.equals(p.getSandbox()), (a, b) -> a));
results.forEach(r -> {
r.setActiveEnv(envMap.get(r.getProduct()));
// activeEnv 从商户自身的 sandbox 字段推导
r.setActiveEnv(r.isSandbox() ? PayEnvEnum.SANDBOX.getCode() : PayEnvEnum.PROD.getCode());
r.setSandboxSupport(sandboxMap.getOrDefault(r.getProduct(), false));
});
}

View File

@@ -7,12 +7,17 @@ import cn.daxpay.open.payment.masterdata.constants.product.entity.PayProductConf
import cn.daxpay.open.payment.masterdata.constants.product.param.PayProductConfigParam;
import cn.daxpay.open.payment.masterdata.constants.product.result.PayProductConfigResult;
import cn.daxpay.open.payment.core.strategy.product.AbsProductStrategy;
import cn.daxpay.open.platform.common.config.properties.PlatformConfigProperties;
import cn.daxpay.open.platform.common.i18n.util.I18nUtil;
import cn.daxpay.open.platform.core.code.CommonErrorCode;
import cn.daxpay.open.platform.core.enums.pay.channel.ChannelEnum;
import cn.daxpay.open.platform.core.enums.pay.channel.ProductEnum;
import cn.daxpay.open.platform.core.enums.pay.config.PayEnvEnum;
import cn.daxpay.open.platform.core.exception.BizInfoException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -31,6 +36,8 @@ public class PayProductConfigService {
private final PayProductService payProductService;
private final PlatformConfigProperties platformConfigProperties;
/// 查询全部产品配置列表(卡片页使用)
/// 融合 PayProduct + pay_md_product_config 表 + 策略信息
public List<PayProductConfigResult> listAll() {
@@ -46,6 +53,11 @@ public class PayProductConfigService {
/// 切换产品的生效环境
@Transactional(rollbackFor = Exception.class)
public void switchEnv(String product, boolean sandbox) {
// 全局沙箱开关校验
if (sandbox && !platformConfigProperties.isSandboxEnabled()) {
throw new BizInfoException(CommonErrorCode.VALIDATE_PARAMETERS_ERROR, "pay.env.sandboxDisabled");
}
PayProductConfig config = payProductConfigManager.findByProduct(product)
.orElseGet(() -> createDefaultConfig(product));
@@ -60,6 +72,12 @@ public class PayProductConfigService {
/// 保存或更新配置
@Transactional(rollbackFor = Exception.class)
public void saveOrUpdate(PayProductConfigParam param) {
// 全局沙箱开关校验
if (PayEnvEnum.SANDBOX.getCode().equals(param.getActiveEnv())
&& !platformConfigProperties.isSandboxEnabled()) {
throw new BizInfoException(CommonErrorCode.VALIDATE_PARAMETERS_ERROR, "pay.env.sandboxDisabled");
}
PayProductConfig config = payProductConfigManager.findByProduct(param.getProduct())
.orElseGet(PayProductConfig::new);
@@ -71,6 +89,25 @@ public class PayProductConfigService {
payProductConfigManager.saveOrUpdate(config);
}
/// 应用启动后检查: 沙箱被全局禁用时, 将所有 activeEnv=sandbox 的产品强制重置为 prod
@EventListener(ApplicationReadyEvent.class)
@Transactional(rollbackFor = Exception.class)
public void checkSandboxOnStartup() {
if (platformConfigProperties.isSandboxEnabled()) {
return;
}
List<PayProductConfig> sandboxConfigs = payProductConfigManager.lambdaQuery()
.eq(PayProductConfig::getActiveEnv, PayEnvEnum.SANDBOX.getCode())
.list();
for (PayProductConfig config : sandboxConfigs) {
config.setActiveEnv(PayEnvEnum.PROD.getCode());
payProductConfigManager.updateById(config);
}
if (!sandboxConfigs.isEmpty()) {
log.info("沙箱环境已禁用(daxpay.pay.env.sandbox-enabled=false), {} 个产品从 sandbox 重置为 prod", sandboxConfigs.size());
}
}
/// PayProduct + 库表 + 策略合并为配置结果
private PayProductConfigResult toConfigResult(PayProduct payProduct, Map<String, PayProductConfig> configMap) {
var result = new PayProductConfigResult()

View File

@@ -74,6 +74,11 @@ public class NormalPayParam extends MerchantPaymentCommonParam {
@Schema(description = "用户标识OpenId")
private String openId;
/// 指定支付/认证使用的应用AppId, 优先级高于配置自动解析, 必须在系统中预先配置过
@Size(max = 128, message = "{validation.field.opAppId.size}")
@Schema(description = "指定应用AppId")
private String opAppId;
/// 付款码(被扫支付必填)
@Size(max = 128, message = "{validation.field.authCode.size}")
@Schema(description = "付款码")

View File

@@ -30,6 +30,9 @@ public class PlatformConfigProperties {
@Valid
private Encrypt encrypt = new Encrypt();
/// 沙箱环境全局开关(关闭后不可切换到沙箱, 已在沙箱的产品启动时自动重置为生产)
private boolean sandboxEnabled = true;
/// # 平台公私钥配置
///
@Slf4j

View File

@@ -0,0 +1,4 @@
{
"official_account": "Official Account",
"miniprogram": "Mini Program"
}

View File

@@ -0,0 +1,3 @@
{
"envMismatch": "Channel merchant environment does not match the product's active environment"
}

View File

@@ -0,0 +1,3 @@
{
"sandboxDisabled": "Sandbox environment is disabled by system"
}

View File

@@ -0,0 +1,4 @@
{
"official_account": "公众号",
"miniprogram": "小程序"
}

View File

@@ -0,0 +1,3 @@
{
"envMismatch": "通道商户环境与支付产品当前生效环境不一致"
}

View File

@@ -0,0 +1,3 @@
{
"sandboxDisabled": "沙箱环境已被系统关闭,无法切换"
}

View File

@@ -66,7 +66,7 @@ public class NotifySseService {
if (emitters.isEmpty()) {
return;
}
emitters.forEach((userId, set) -> sendAll(set, payload));
emitters.forEach((_, set) -> sendAll(set, payload));
}
/// 推送给指定用户(个人消息场景)

View File

@@ -109,7 +109,7 @@ daxpay:
# 查询模式: VECTOR_INDEX(推荐) 或 CACHE
search-type: VECTOR_INDEX
config:
# 证书配置0
# 平台公私钥(证书)配置, 用于接口请求/回调的 RSA 签名与验签
key-config:
# 私钥配置
private-key: '-----BEGIN PRIVATE KEY-----
@@ -150,11 +150,22 @@ daxpay:
qEYdxyjbqztxm0SmYwl885mcGPBNPTt9ApoRWR3CGRfE8pcQieuB0YdsdSh9+ZSa
IwIDAQAB
-----END PUBLIC KEY-----'
# 数据加密配置(数据库敏感字段透明加解密, AES-256-GCM, 由 MyBatis-Plus TypeHandler 自动处理)
encrypt:
# 是否启用数据加密; 启用后敏感字段写入自动加密、读取自动解密
# 注意: 启用并写入加密数据后不要关闭, 否则历史密文读取时不再解密(以密文原文返回)
enable: true
# 密钥列表(支持密钥轮换, 列表内版本号不可重复)
# 第一个为当前密钥(用于加密新数据), 其余为历史密钥(仅用于解密旧数据)
# 密文格式: v{版本号}:{base64(IV + AES-GCM 密文)}, 解密时按版本前缀匹配对应密钥
keys:
# 32 位 AES 密钥(必须正好 32 个字符); 轮换时生成新密钥置于列表首位并使用更大的版本号
- key: z0Vd8jDKB80pA6OOptGLO+qDVvWboEko
# 密钥版本号(整数, 列表内唯一)
version: 1
# 沙箱环境全局开关: false=生产模式(本机开发默认), true=沙箱模式
# 关闭后不可切换到沙箱; 若启动时已有产品处于沙箱, 将自动重置为生产环境
sandbox-enabled: false
# 通道适配子应用配置
# 按支付通道拆分的独立部署子服务, 主服务通过 HTTP 调用对应子服务完成通道对接。
# 拆分目的: 通道 SDK 依赖隔离(避免冲突) + 独立升级 + 弹性伸缩

View File

@@ -56,6 +56,8 @@
<ip2region.version>3.3.7</ip2region.version>
<wxjava.version>4.8.4.B</wxjava.version>
<commonmark.version>0.24.0</commonmark.version>
<!-- 支付宝开放平台 SDK (capability-alipay 使用) -->
<alipay-sdk.version>4.40.272.ALL</alipay-sdk.version>
</properties>
<build>