fix(callback): 支付宝/富友回调按 tradeType 分发容器, ISV 沙箱改读通用主表

- AlipayCallbackService.resolveCredentialByTradeNo 原硬编码查 NormalPayOrder,
  导致 tradeType=gateway 时跨表查不到容器(报"无法解析通道凭证");
  改走 PayTradeContainerFields 统一按 tradeType 分发到 normal/gateway 容器
- PayTradeContainerFields 新增 CredentialFields(product/channelMchNo/capability)
  与 resolveCredentialFields(),复用现有 isGateway 分发范式
- FuyouPayCallbackService 同源 bug:硬编码查 NormalPayOrderManager 反查,
  tradeType=gateway 时同样失败;简化为直接 findByRelationOrderNo,
  PayTrade.relationOrderNo 跨容器统一
- AlipayIsvConfigAssembler / DougongIsvConfigAssembler 沙箱标识原实时读
  payProductConfig.activeEnv,与 AGENTS.md 约定及 PayRouteService 兜底校验
  (读 channelMerchant.isSandbox 固化快照)不一致;改注入 ChannelMerchantManager
  按 channelMchNo 读通用主表 mch_channel_merchant.sandbox,单一事实源,
  与 Direct 通道(Yeepay/Ums/Adapay)完全对齐
This commit is contained in:
DaxPay Dev
2026-07-21 08:17:11 +08:00
parent 5ef233429d
commit b7e2e680f9
5 changed files with 62 additions and 36 deletions

View File

@@ -8,12 +8,11 @@ import cn.daxpay.open.channel.alipay.service.direct.AlipayDirectConfigAssembler;
import cn.daxpay.open.channel.alipay.service.isv.AlipayIsvConfigAssembler;
import cn.daxpay.open.payment.trade.runtime.bo.CallbackData;
import cn.daxpay.open.payment.trade.runtime.bo.RefundCallbackData;
import cn.daxpay.open.payment.trade.order.dao.NormalPayOrderManager;
import cn.daxpay.open.payment.trade.order.dao.RefundOrderManager;
import cn.daxpay.open.payment.trade.order.dao.PayTradeManager;
import cn.daxpay.open.payment.trade.order.entity.NormalPayOrder;
import cn.daxpay.open.payment.trade.order.entity.RefundOrder;
import cn.daxpay.open.payment.trade.order.entity.PayTrade;
import cn.daxpay.open.payment.trade.runtime.service.PayTradeContainerFields;
import cn.daxpay.open.payment.trade.runtime.service.callback.PayCallbackService;
import cn.daxpay.open.payment.trade.runtime.service.callback.RefundCallbackService;
import cn.daxpay.open.platform.core.enums.pay.channel.ProductEnum;
@@ -50,7 +49,7 @@ public class AlipayCallbackService {
private final AlipayDirectConfigAssembler alipayDirectConfigAssembler;
private final AlipayIsvConfigAssembler alipayIsvConfigAssembler;
private final PayTradeManager payTradeManager;
private final NormalPayOrderManager normalPayOrderManager;
private final PayTradeContainerFields payTradeContainerFields;
private final RefundOrderManager payRefundOrderManager;
private final PayCallbackService payCallbackService;
private final RefundCallbackService refundCallbackService;
@@ -166,18 +165,23 @@ public class AlipayCallbackService {
if (trade == null) {
return null;
}
// 从容器获取 product/channelMchNo/capability
NormalPayOrder order = normalPayOrderManager.findById(trade.getContainerId()).orElse(null);
if (order == null) {
// 按 tradeType 分发到对应容器(normal/gateway)读取通道路由字段
// 与 PayCallbackService.resolveProduct / PayTradeContainerFields.resolve 同范式,
// 避免硬编码 normalPayOrder 导致 tradeType=gateway 时跨表查不到容器
PayTradeContainerFields.CredentialFields fields = payTradeContainerFields.resolveCredentialFields(trade);
if (fields == null) {
// 容器记录不存在(含跨容器误查), 附 tradeType 便于排查
log.warn("支付宝回调: 容器记录不存在, tradeNo={}, tradeType={}, containerId={}",
tradeNo, trade.getTradeType(), trade.getContainerId());
return null;
}
// 按支付产品分发: 服务商 / 直连
if (ProductEnum.ALIPAY_ISV.getCode().equals(order.getProduct())) {
if (ProductEnum.ALIPAY_ISV.getCode().equals(fields.product())) {
return alipayIsvConfigAssembler.buildConfig(trade.getMchNo());
}
String channelMchNo = StrUtil.blankToDefault(order.getChannelMchNo(), pathChannelMchNo);
String channelMchNo = StrUtil.blankToDefault(fields.channelMchNo(), pathChannelMchNo);
return alipayDirectConfigAssembler.buildConfig(
trade.getMchNo(), channelMchNo, order.getCapability());
trade.getMchNo(), channelMchNo, fields.capability());
}
/// 提取 request 全部表单参数为 Map<String,String>(多值取首项)

View File

@@ -6,9 +6,8 @@ import cn.daxpay.open.channel.alipay.dao.isv.AlipayIsvChannelMerchantManager;
import cn.daxpay.open.channel.alipay.entity.isv.AlipayIsvApp;
import cn.daxpay.open.channel.alipay.entity.isv.AlipayIsvAppKeyConfig;
import cn.daxpay.open.channel.alipay.entity.isv.AlipayIsvChannelMerchant;
import cn.daxpay.open.payment.masterdata.dao.product.PayProductConfigManager;
import cn.daxpay.open.platform.core.enums.pay.channel.ProductEnum;
import cn.daxpay.open.platform.core.enums.pay.config.PayEnvEnum;
import cn.daxpay.open.payment.merchant.dao.channel.ChannelMerchantManager;
import cn.daxpay.open.payment.merchant.entity.channel.ChannelMerchant;
import cn.daxpay.open.platform.core.exception.DataNotExistException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -22,6 +21,10 @@ import org.springframework.stereotype.Service;
/// 服务商代调用模式: appId / 私钥 / 证书取自服务商应用, 应用授权令牌(appAuthToken)取自子商户授权绑定,
/// 子应用据此以服务商身份代子商户发起支付宝请求。
///
/// 沙箱标识读通用通道商户主表 [ChannelMerchant#isSandbox] (创建时按当时产品 activeEnv 固化, 不随产品切换改变),
/// 据此选择对应环境的网关地址, 与 Direct 通道(Yeepay/Ums/Adapay)及
/// [cn.daxpay.open.payment.route.service.runtime.PayRouteService#validateChannelMchEnvMatch] 单一事实源对齐。
///
/// 供服务商支付/同步/关闭策略组装通道调用凭证。
@Slf4j
@Service
@@ -31,7 +34,7 @@ public class AlipayIsvConfigAssembler {
private final AlipayIsvChannelMerchantManager alipayIsvChannelMerchantManager;
private final AlipayIsvAppManager alipayIsvAppManager;
private final AlipayIsvAppKeyConfigService alipayIsvAppKeyConfigService;
private final PayProductConfigManager payProductConfigManager;
private final ChannelMerchantManager channelMerchantManager;
/// 组装服务商模式的通道调用凭证(下发给子应用)
///
@@ -57,9 +60,10 @@ public class AlipayIsvConfigAssembler {
credential.setAlipayRootCert(keyConfig.getAlipayRootCert());
// 应用授权令牌(服务商代子商户调用接口的凭据)
credential.setAppAuthToken(isvMerchant.getAppAuthToken());
// 读取服务商支付产品当前生效环境, 判断是否沙箱
boolean sandbox = payProductConfigManager.findByProduct(ProductEnum.ALIPAY_ISV.getCode())
.map(c -> PayEnvEnum.SANDBOX.getCode().equals(c.getActiveEnv()))
// 沙箱标识读通用通道商户主表的固化快照(创建时按当时产品 activeEnv 写入, 不随产品切换改变)
// 单一事实源, 与 Direct 通道 + PayRouteService.validateChannelMchEnvMatch 完全一致
boolean sandbox = channelMerchantManager.findByChannelMchNo(isvMerchant.getChannelMchNo())
.map(ChannelMerchant::isSandbox)
.orElse(false);
credential.setSandbox(sandbox);
return credential;

View File

@@ -4,11 +4,10 @@ import cn.daxpay.open.channel.dougong.client.credential.DougongSdkCredential;
import cn.daxpay.open.channel.dougong.dao.isv.DougongIsvChannelMerchantManager;
import cn.daxpay.open.channel.dougong.entity.isv.DougongIsvChannelMerchant;
import cn.daxpay.open.channel.dougong.entity.isv.DougongIsvKeyConfig;
import cn.daxpay.open.payment.masterdata.dao.product.PayProductConfigManager;
import cn.daxpay.open.payment.masterdata.entity.product.PayProductConfig;
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.DataNotExistException;
import cn.daxpay.open.payment.merchant.dao.channel.ChannelMerchantManager;
import cn.daxpay.open.payment.merchant.entity.channel.ChannelMerchant;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
@@ -21,6 +20,10 @@ import org.springframework.stereotype.Service;
/// 字段映射:
/// - sysId/productId/privateKey/dgPublicKey ← [DougongIsvKeyConfig](服务商级, 全局唯一)
/// - merchantNo/appId ← [DougongIsvChannelMerchant](商户级)
/// - sandbox ← [ChannelMerchant#isSandbox] (通用通道商户主表, 创建时按当时产品 activeEnv 固化, 不随产品切换改变)
///
/// 沙箱来源与 Direct 通道(Yeepay/Ums/Adapay)及
/// [cn.daxpay.open.payment.route.service.runtime.PayRouteService#validateChannelMchEnvMatch] 单一事实源对齐。
///
/// 供支付策略([cn.daxpay.open.channel.dougong.strategy.*])组装通道调用凭证。
@Slf4j
@@ -30,7 +33,7 @@ public class DougongIsvConfigAssembler {
private final DougongIsvChannelMerchantManager dougongIsvChannelMerchantManager;
private final DougongIsvKeyConfigService dougongIsvKeyConfigService;
private final PayProductConfigManager payProductConfigManager;
private final ChannelMerchantManager channelMerchantManager;
/// 组装斗拱通道调用凭证(下发给子应用)
///
@@ -52,10 +55,10 @@ public class DougongIsvConfigAssembler {
credential.setProductId(keyConfig.getProductId());
credential.setPrivateKey(keyConfig.getPrivateKey());
credential.setDgPublicKey(keyConfig.getDgPublicKey());
// 沙箱状态读取支付产品配置的生效环境
boolean sandbox = payProductConfigManager.findByProduct(ProductEnum.DOUGONG_PAY.getCode())
.map(PayProductConfig::getActiveEnv)
.map(PayEnvEnum.SANDBOX.getCode()::equals)
// 沙箱标识读通用通道商户主表的固化快照(创建时按当时产品 activeEnv 写入, 不随产品切换改变)
// 单一事实源, 与 Direct 通道 + PayRouteService.validateChannelMchEnvMatch 完全一致
boolean sandbox = channelMerchantManager.findByChannelMchNo(channelMchNo)
.map(ChannelMerchant::isSandbox)
.orElse(false);
credential.setSandbox(sandbox);
// 子商户身份

View File

@@ -7,9 +7,7 @@ import cn.daxpay.open.channel.fuyou.client.resp.FuyouCallbackParseResp;
import cn.daxpay.open.channel.fuyou.dao.isv.FuyouIsvKeyConfigManager;
import cn.daxpay.open.channel.fuyou.entity.isv.FuyouIsvKeyConfig;
import cn.daxpay.open.payment.trade.runtime.bo.CallbackData;
import cn.daxpay.open.payment.trade.order.dao.NormalPayOrderManager;
import cn.daxpay.open.payment.trade.order.dao.PayTradeManager;
import cn.daxpay.open.payment.trade.order.entity.NormalPayOrder;
import cn.daxpay.open.payment.trade.order.entity.PayTrade;
import cn.daxpay.open.payment.trade.runtime.service.callback.PayCallbackService;
import cn.daxpay.open.platform.core.enums.pay.channel.ProductEnum;
@@ -42,7 +40,6 @@ public class FuyouPayCallbackService {
private final FuyouChannelClient fuyouChannelClient;
private final FuyouIsvKeyConfigManager fuyouIsvKeyConfigManager;
private final PayTradeManager payTradeManager;
private final NormalPayOrderManager normalPayOrderManager;
private final PayCallbackService payCallbackService;
/// 支付回调处理
@@ -67,16 +64,11 @@ public class FuyouPayCallbackService {
return RESP_FAIL;
}
// 3. 凭关联订单号(mchnt_order_no)反查容器, 再查关联交易
// 3. 凭关联订单号(mchnt_order_no)直接反查 PayTrade
// relationOrderNo 是 PayTrade 上的字段(回调/关退同步反查权威), 跨 tradeType(normal/gateway)统一,
// 避免硬编码查 NormalPayOrder 导致 tradeType=gateway 时跨表查不到容器(与支付宝回调同源 bug)
String relationOrderNo = resp.getOutTradeNo();
NormalPayOrder normalOrder = normalPayOrderManager.lambdaQuery()
.eq(NormalPayOrder::getRelationOrderNo, relationOrderNo)
.oneOpt()
.orElse(null);
PayTrade trade = null;
if (normalOrder != null) {
trade = payTradeManager.findByContainerId(normalOrder.getId()).orElse(null);
}
PayTrade trade = payTradeManager.findByRelationOrderNo(relationOrderNo).orElse(null);
if (trade == null) {
log.error("富友支付回调: 未找到关联订单 relationOrderNo={}", relationOrderNo);
return RESP_FAIL;

View File

@@ -23,6 +23,12 @@ public class PayTradeContainerFields {
/// 容器侧通道常用回执字段
public record Fields(String relationOrderNo, String tradeProduct) {}
/// 容器侧通道路由凭证字段(支付/退款回调组装通道凭证用)
///
/// [NormalPayOrder] / [GatewayPayOrder] 同名字段集合, 按 tradeType 分发到对应容器读取,
/// 供回调服务组装通道调用凭证(直连/服务商)。
public record CredentialFields(String product, String channelMchNo, String capability) {}
/// 按 tradeType 查一次容器,同时返回关联订单号与支付厂商/产品回执
public Fields resolve(PayTrade trade) {
if (isGateway(trade)) {
@@ -35,6 +41,23 @@ public class PayTradeContainerFields {
.orElse(new Fields(null, null));
}
/// 按 tradeType 查一次容器, 返回通道路由凭证字段(product/channelMchNo/capability)
///
/// 容器记录不存在(含跨容器误查)时返回 null, 调用方据此报"无法解析通道凭证"。
/// 当前仅支持 NORMAL/GATEWAY 容器, 其他 tradeType 按普通容器兜底(与 [resolve] 对齐)。
public CredentialFields resolveCredentialFields(PayTrade trade) {
if (isGateway(trade)) {
return gatewayPayOrderManager.findById(trade.getContainerId())
.map(order -> new CredentialFields(
order.getProduct(), order.getChannelMchNo(), order.getCapability()))
.orElse(null);
}
return normalPayOrderManager.findById(trade.getContainerId())
.map(order -> new CredentialFields(
order.getProduct(), order.getChannelMchNo(), order.getCapability()))
.orElse(null);
}
private boolean isGateway(PayTrade trade) {
return Objects.equals(trade.getTradeType(), PayTradeTypeEnum.GATEWAY.getCode());
}