fix(wechat): 服务商密钥缺失fail-fast校验 + 子商户应用解析只读显式配置

This commit is contained in:
DaxPay Dev
2026-07-05 13:29:20 +08:00
parent dbc80f3498
commit 76cb758e70
5 changed files with 35 additions and 28 deletions

View File

@@ -27,7 +27,7 @@ import java.util.Optional;
/// - sub_appid ← [WechatIsvMchApp.wxAppId] (子商户应用AppId, 可选; 未配置留空)
///
/// 服务商应用解析(委托 [WechatIsvAppCapabilityService.resolveApp]): 显式配置 > appType自动推导 > 首个兜底。
/// 子商户应用解析(委托 [WechatIsvMchAppCapabilityService.resolveApp]): 同优先级, 未命中返回 empty(sub_appid 留空)。
/// 子商户应用解析(委托 [WechatIsvMchAppCapabilityService.resolveApp]): 仅显式配置, 未配置返回 empty(sub_appid 留空)。
///
/// 供支付策略([cn.daxpay.open.channel.wechat.strategy.isv.*])组装通道调用凭证。
@Slf4j
@@ -47,8 +47,8 @@ public class WechatIsvConfigAssembler {
/// @param capability 支付能力编码(用于解析应用)
/// @return 微信 SDK 凭证(服务商模式, subMchId/subAppId 已填充)
public WechatSdkCredential buildConfig(String mchNo, String channelMchNo, String capability) {
// 服务商密钥(全局唯一, 含 sp_mchid 与证书)
WechatIsvKeyConfig keyConfig = wechatIsvKeyConfigService.findByProduct(ProductEnum.WECHAT_ISV.getCode());
// 服务商密钥(全局唯一, 含 sp_mchid 与证书; 缺失或关键字段为空时 fail-fast)
WechatIsvKeyConfig keyConfig = wechatIsvKeyConfigService.getByProductForPay(ProductEnum.WECHAT_ISV.getCode());
// 特约商户绑定(取 sub_mchid)
WechatIsvChannelMerchant channelMerchant = wechatIsvChannelMerchantManager.findByChannelMchNo(channelMchNo)
// 微信: 通道商户配置不存在

View File

@@ -4,6 +4,8 @@ import cn.daxpay.open.channel.wechat.convert.isv.WechatIsvKeyConfigConvert;
import cn.daxpay.open.channel.wechat.dao.isv.WechatIsvKeyConfigManager;
import cn.daxpay.open.channel.wechat.entity.isv.WechatIsvKeyConfig;
import cn.daxpay.open.channel.wechat.param.isv.WechatIsvKeyConfigParam;
import cn.daxpay.open.platform.core.exception.BizInfoException;
import cn.hutool.core.util.StrUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
@@ -34,6 +36,21 @@ public class WechatIsvKeyConfigService {
return config;
}
/// 支付场景查询服务商密钥(必填校验, 不创建记录)
///
/// 与 [findByProduct] 的 upsert 语义不同, 此方法只读不写:
/// 记录不存在或关键字段(wxMchId/apiKeyV3/privateKey/certSerialNo)任一为空时 fail-fast,
/// 避免空凭证下发到子应用后子应用才发现问题。
public WechatIsvKeyConfig getByProductForPay(String product) {
WechatIsvKeyConfig config = wechatIsvKeyConfigManager.findByProduct(product)
.orElseThrow(() -> new BizInfoException("error.channel.wechat.isvKeyNotConfigured"));
if (StrUtil.hasBlank(config.getWxMchId(), config.getApiKeyV3(),
config.getPrivateKey(), config.getCertSerialNo())) {
throw new BizInfoException("error.channel.wechat.isvKeyNotConfigured");
}
return config;
}
/// 保存服务商密钥配置
/// 注意: 微信服务商模式不支持沙箱环境
@Transactional(rollbackFor = Exception.class)

View File

@@ -1,6 +1,5 @@
package cn.daxpay.open.channel.wechat.service.isv;
import cn.daxpay.open.channel.wechat.code.WechatAppTypeCode;
import cn.daxpay.open.channel.wechat.convert.isv.WechatIsvMchAppCapabilityConvert;
import cn.daxpay.open.channel.wechat.dao.isv.WechatIsvMchAppCapabilityManager;
import cn.daxpay.open.channel.wechat.dao.isv.WechatIsvMchAppManager;
@@ -37,8 +36,8 @@ import java.util.stream.Collectors;
/// 由调用方(未来的微信服务商支付策略/配置组装器)回退到全局服务商应用配置
/// ([cn.daxpay.open.channel.wechat.service.isv.WechatIsvAppCapabilityService])。
///
/// 支付时通过 [resolveApp] 解析当前能力对应的应用:显式配置 > appType自动推导 > 通道商户首个兜底,
/// 三者均未命中返回 empty(语义为"使用全局服务商配置")。
/// 支付时通过 [resolveApp] 解析当前能力对应的应用:仅读取显式配置, 未配置返回 empty
/// (语义为"该能力未在子商户维度配置, sub_appid 留空, 走纯服务商应用模式")。
///
@Slf4j
@Service
@@ -109,33 +108,22 @@ public class WechatIsvMchAppCapabilityService {
capabilityManager.deleteByWechatIsvMchAppId(wechatIsvMchAppId);
}
/// 支付时解析当前支付能力对应的子商户应用:显式配置 > appType自动推导 > 通道商户首个兜底
/// 支付时解析当前支付能力对应的子商户应用(仅读取显式配置)
///
/// 三者均未命中返回 empty,语义为"该能力未在子商户维度配置,请回退到全局服务商应用配置"。
/// 调用方(未来的微信服务商配置组装器)应在此基础上回退 [WechatIsvAppCapabilityService.resolveApp]
/// 子商户应用(sub_appid)为可选项, 不做 appType 推导与首个兜底, 避免取到不相关的子应用
/// 导致 openid 体系不匹配。未配置返回 empty, 由调用方留空 sub_appid, 走纯服务商应用模式
///
/// @param channelMchNo 通道商户号(服务商特约商户)
/// @param capability 支付能力编码
/// @return 命中的子商户应用;未命中返回 empty(应回退全局服务商配置)
/// @param capability 支付能力编码
/// @return 命中的子商户应用;未配置返回 empty(sub_appid 留空)
public Optional<WechatIsvMchApp> resolveApp(String channelMchNo, String capability) {
if (StrUtil.hasBlank(channelMchNo, capability)) {
return Optional.empty();
}
// 1. 显式配置优先
var rel = capabilityManager.findOne(channelMchNo, capability);
if (rel.isPresent()) {
return wechatIsvMchAppManager.findById(rel.get().getWechatIsvMchAppId());
}
// 2. 未配置则按能力 → appType 自动推导
String appType = WechatAppTypeCode.resolveAppType(capability);
if (appType != null) {
Optional<WechatIsvMchApp> byType = wechatIsvMchAppManager.findFirstByChannelMchNoAndAppType(channelMchNo, appType);
if (byType.isPresent()) {
return byType;
}
}
// 3. 最终兜底:按通道商户号取首个子商户应用;仍未命中返回 empty(回退全局)
return wechatIsvMchAppManager.findFirstByChannelMchNo(channelMchNo);
// 子商户应用(sub_appid)为可选项, 仅读取显式配置, 未配置则返回 empty
// (sub_appid 留空时走纯服务商应用 sp_appid + sub_mchid 模式)
return capabilityManager.findOne(channelMchNo, capability)
.flatMap(rel -> wechatIsvMchAppManager.findById(rel.getWechatIsvMchAppId()));
}
/// 查询微信服务商产品支持的支付能力候选列表(含国际化名称)

View File

@@ -49,5 +49,6 @@
"unsupportedPayMethod": "Unsupported WeChat payment method: {0}",
"accessTokenLockFailed": "Failed to get AccessToken: cannot acquire distributed lock",
"refreshAccessTokenFailed": "Failed to refresh AccessToken: {0}",
"refreshAccessTokenEmpty": "Failed to refresh AccessToken: returned token is empty"
"refreshAccessTokenEmpty": "Failed to refresh AccessToken: returned token is empty",
"isvKeyNotConfigured": "WeChat ISV key is not configured or incomplete, please configure the ISV key first"
}

View File

@@ -49,5 +49,6 @@
"unsupportedPayMethod": "暂不支持的微信支付方式: {0}",
"accessTokenLockFailed": "获取AccessToken失败无法获取分布式锁",
"refreshAccessTokenFailed": "刷新AccessToken失败: {0}",
"refreshAccessTokenEmpty": "刷新AccessToken失败返回的Token为空"
"refreshAccessTokenEmpty": "刷新AccessToken失败返回的Token为空",
"isvKeyNotConfigured": "微信服务商密钥未配置或不完整,请先完成服务商密钥配置"
}