feat(develop): 支付调试页新增路由/传值模式切换, 传值模式由能力反推支付方式

- 调试页商户信息支持路由模式(商户+方式+应用动态匹配)与传值模式(通道商户+能力直接决定)切换

- 传值模式 capability 必填, method 由后端从(通道商户, 能力)反推回填, 下游透明

- ProductStrategySupport 新增 methodForCapability 反推; resolveDirect 重构去 inferCapability 派生

- 调试候选接口去 method 参数, 通道商户/能力候选不再依赖支付方式
This commit is contained in:
DaxPay Dev
2026-06-27 10:07:44 +08:00
parent f092544c48
commit 64c1e7fb60
7 changed files with 142 additions and 35 deletions

View File

@@ -4,18 +4,24 @@ import cn.daxpay.open.payment.admin.param.develop.DevelopParam;
import cn.daxpay.open.payment.admin.result.develop.DevelopPayResult;
import cn.daxpay.open.payment.admin.result.develop.DevelopSignResult;
import cn.daxpay.open.payment.admin.service.develop.DevelopTradeService;
import cn.daxpay.open.payment.masterdata.constants.provider.result.PayProviderMethodResult;
import cn.daxpay.open.payment.unipay.param.trade.pay.PayParam;
import cn.daxpay.open.platform.core.annotation.PermCode;
import cn.daxpay.open.platform.core.rest.Res;
import cn.daxpay.open.platform.core.rest.dto.LabelValue;
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.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;
import java.util.List;
/// 交易开发调试(管理)
@PermCode(menuCode = "payment:develop:trade")
@Tag(name = "交易开发调试服务")
@@ -39,4 +45,27 @@ public class DevelopTradeController {
public Result<DevelopPayResult> pay(@RequestBody DevelopParam<PayParam> param) {
return Res.ok(developTradeService.pay(param));
}
@PermCode(code = "view", nameCn = "查看", nameEn = "View")
@Operation(summary = "已启用渠道支付方式目录")
@GetMapping("/method-directory")
public Result<List<PayProviderMethodResult>> methodDirectory() {
return Res.ok(developTradeService.listMethodDirectory());
}
@PermCode(code = "view", nameCn = "查看", nameEn = "View")
@Operation(summary = "传值模式通道商户候选")
@GetMapping("/channel-mch-candidates")
public Result<List<LabelValue>> channelMchCandidates(
@NotBlank(message = "{validation.field.mchNo.notBlank}") String mchNo) {
return Res.ok(developTradeService.listChannelMchCandidates(mchNo));
}
@PermCode(code = "view", nameCn = "查看", nameEn = "View")
@Operation(summary = "传值模式支付能力候选")
@GetMapping("/capability-candidates")
public Result<List<LabelValue>> capabilityCandidates(
@NotBlank(message = "{validation.field.channelMchNo.notBlank}") String channelMchNo) {
return Res.ok(developTradeService.listCapabilityCandidates(channelMchNo));
}
}

View File

@@ -6,15 +6,21 @@ import cn.daxpay.open.payment.admin.result.develop.DevelopSignResult;
import cn.daxpay.open.payment.common.context.PaymentScopeManager;
import cn.daxpay.open.payment.common.util.ObjectSignStrUtil;
import cn.daxpay.open.payment.common.util.PaySignUtil;
import cn.daxpay.open.payment.masterdata.constants.provider.result.PayProviderMethodResult;
import cn.daxpay.open.payment.masterdata.constants.provider.service.PayProviderMethodService;
import cn.daxpay.open.payment.merchant.service.route.support.PayRouteStrategyCapabilitySupport;
import cn.daxpay.open.payment.pay.service.NormalPayService;
import cn.daxpay.open.payment.unipay.param.trade.pay.PayParam;
import cn.daxpay.open.payment.unipay.result.trade.pay.PayResult;
import cn.daxpay.open.platform.common.json.util.JsonUtil;
import cn.daxpay.open.platform.core.rest.dto.LabelValue;
import cn.hutool.core.util.StrUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.List;
/// # 交易开发调试服务
///
/// 复用普通支付核心服务 NormalPayService, 在管理端以指定商户/应用身份真实发起一笔支付,
@@ -26,6 +32,8 @@ public class DevelopTradeService {
private final NormalPayService normalPayService;
private final PaymentScopeManager paymentScopeManager;
private final PayProviderMethodService payProviderMethodService;
private final PayRouteStrategyCapabilitySupport payRouteStrategyCapabilitySupport;
/// 生成支付参数签名(与正式签名逻辑一致)
public DevelopSignResult sign(DevelopParam<PayParam> param) {
@@ -56,4 +64,19 @@ public class DevelopTradeService {
.setSignInfo(signInfo)
.setPayResult(payResult);
}
/// 已启用渠道支付方式目录(供调试页支付方式下拉)
public List<PayProviderMethodResult> listMethodDirectory() {
return payProviderMethodService.listDirectoryFlat();
}
/// 传值模式:商户全部启用通道商户候选
public List<LabelValue> listChannelMchCandidates(String mchNo) {
return payRouteStrategyCapabilitySupport.listDirectChannelMchCandidates(mchNo);
}
/// 传值模式:按通道商户(产品)返回全部启用支付能力候选
public List<LabelValue> listCapabilityCandidates(String channelMchNo) {
return payRouteStrategyCapabilitySupport.listDirectCapabilityCandidates(channelMchNo);
}
}

View File

@@ -55,14 +55,6 @@ public class ProductStrategySupport {
return List.copyOf(capabilities);
}
/// 按支付方式编码获取策略声明的支付能力
public List<PayCapabilityEnum> capabilitiesForMethodCode(AbsProductStrategy strategy, String methodCode) {
if (StrUtil.isBlank(methodCode)) {
return List.of();
}
PayMethodEnum method = PayMethodEnum.findByCode(methodCode);
return capabilitiesForMethod(strategy, method);
}
/// 产品是否支持渠道目录中的某一支付方式
public boolean supportsDirectoryMethod(AbsProductStrategy strategy, PayMethodEnum method) {
@@ -78,7 +70,7 @@ public class ProductStrategySupport {
}
/// 按能力编码判断策略是否在该目录方式下声明支持
public boolean strategySupportsCapabilityCode(AbsProductStrategy strategy, String methodCode, String capabilityCode) {
public static boolean strategySupportsCapabilityCode(AbsProductStrategy strategy, String methodCode, String capabilityCode) {
if (StrUtil.isBlank(methodCode) || StrUtil.isBlank(capabilityCode)) {
return false;
}
@@ -87,8 +79,21 @@ public class ProductStrategySupport {
return strategySupportsCapability(strategy, method, capability);
}
/// 产品已声明能力是否覆盖渠道目录中的某一支付方式(兼容旧调用名)
public boolean capabilityCoversDirectoryMethod(AbsProductStrategy strategy, PayMethodEnum method) {
return supportsDirectoryMethod(strategy, method);
/// 反推: 给定策略与能力, 返回所属支付方式(多归属取首个, 无则 null)
public static PayMethodEnum methodForCapability(AbsProductStrategy strategy, PayCapabilityEnum capability) {
if (capability == null) {
return null;
}
Map<PayMethodEnum, List<PayCapabilityEnum>> mapping = strategy.methodCapabilityMapping();
if (CollUtil.isEmpty(mapping)) {
return null;
}
for (Map.Entry<PayMethodEnum, List<PayCapabilityEnum>> entry : mapping.entrySet()) {
if (entry.getValue() != null && entry.getValue().contains(capability)) {
return entry.getKey();
}
}
return null;
}
}

View File

@@ -6,6 +6,7 @@ import cn.daxpay.open.payment.merchant.dao.route.scene.PayRouteSceneConfigManage
import cn.daxpay.open.payment.merchant.dao.route.strategy.PayRouteStrategyManager;
import cn.daxpay.open.payment.merchant.service.route.basic.PayRouteBasicMatcher;
import cn.daxpay.open.payment.merchant.service.route.scene.PayRouteSceneMatcher;
import cn.daxpay.open.payment.merchant.service.route.support.PayRouteStrategyCapabilitySupport;
import cn.daxpay.open.payment.merchant.service.route.model.RouteHit;
import cn.daxpay.open.payment.merchant.entity.route.strategy.PayRouteStrategy;
import cn.daxpay.open.payment.merchant.service.route.model.PayRouteBundle;
@@ -13,6 +14,7 @@ import cn.daxpay.open.payment.old.pay.support.ProductStrategySupport;
import cn.daxpay.open.payment.pay.service.route.PayRouteFacade;
import cn.daxpay.open.payment.strategy.product.AbsProductStrategy;
import cn.daxpay.open.payment.unipay.param.trade.pay.PayParam;
import cn.daxpay.open.platform.common.i18n.util.I18nUtil;
import cn.daxpay.open.platform.core.enums.pay.channel.PayCapabilityEnum;
import cn.daxpay.open.platform.core.enums.pay.channel.PayMethodEnum;
import cn.daxpay.open.platform.core.code.CommonErrorCode;
@@ -43,6 +45,7 @@ public class PayRouteService implements PayRouteFacade {
private final PayRouteBasicConfigManager basicConfigManager;
private final PayRouteProductResolver productResolver;
private final PayRouteBasicMatcher basicMatcher;
private final PayRouteStrategyCapabilitySupport payRouteStrategyCapabilitySupport;
/// 实付路由解析:直定模式优先,其次兼容已指定 product最后按策略模式匹配
@Override
@@ -72,30 +75,38 @@ public class PayRouteService implements PayRouteFacade {
fillPayParam(payParam, hit);
}
/// 直定模式:由通道商户推导产品,校验或派生支付能力
/// 直定模式:capability 必填,由通道商户推导产品method 未传时由(通道商户, 能力)反推回填
private void resolveDirect(PayParam payParam) {
if (StrUtil.isBlank(payParam.getMethod())) {
// 场景模式下须选择支付方式
throw new BizInfoException(CommonErrorCode.VALIDATE_PARAMETERS_ERROR, "pay.route.error.sceneMethodRequired");
}
String channelMchNo = payParam.getChannelMchNo();
String capability = payParam.getCapability();
// 传值模式: 支付能力必填
if (StrUtil.isBlank(capability)) {
throw new BizInfoException(CommonErrorCode.VALIDATE_PARAMETERS_ERROR,
"pay.route.error.sceneCapabilityRequired");
}
String product = productResolver.productOfChannelMchNo(channelMchNo);
if (!PaymentStrategyFactory.existsByProduct(product, AbsProductStrategy.class)) {
// 支付产品策略不存在
throw new BizInfoException(CommonErrorCode.VALIDATE_PARAMETERS_ERROR,
"pay.route.error.productStrategyMissing");
}
PayMethodEnum methodEnum = PayMethodEnum.findByCode(payParam.getMethod());
String capability = payParam.getCapability();
if (StrUtil.isBlank(capability)) {
// 未传能力则按 (产品, 支付方式) 派生
capability = inferCapability(product, methodEnum);
String method = payParam.getMethod();
if (StrUtil.isBlank(method)) {
// 传值模式: 由(通道商户, 支付能力)反推支付方式, 供下游通道策略使用
String inferred = payRouteStrategyCapabilitySupport.inferMethodForCapability(channelMchNo, capability);
if (inferred == null) {
// 支付能力[{0}]与通道商户[{1}]不匹配
PayCapabilityEnum capEnum = PayCapabilityEnum.findByCode(capability);
String capLabel = capEnum != null ? I18nUtil.getEnumName(capEnum) : capability;
throw new BizInfoException(CommonErrorCode.VALIDATE_PARAMETERS_ERROR,
"pay.route.error.directCapabilityChannelMchMismatch", capLabel, channelMchNo);
}
payParam.setMethod(inferred);
} else {
// 已传能力则校验属于该(产品, 支付方式)候选
validateCapability(product, methodEnum, capability);
// 已传支付方式: 校验(产品, 方式, 能力)一致
validateCapability(product, PayMethodEnum.findByCode(method), capability);
}
payParam.setProduct(product);
payParam.setCapability(capability);
}
/// 按应用号从库加载路由数据包(无 Redis 缓存)
@@ -133,13 +144,6 @@ public class PayRouteService implements PayRouteFacade {
}
}
/// 派生支付能力:取产品策略声明的(方式→能力)首个
private String inferCapability(String product, PayMethodEnum method) {
AbsProductStrategy strategy = PaymentStrategyFactory.createByProduct(product, AbsProductStrategy.class);
List<PayCapabilityEnum> capabilities = ProductStrategySupport.capabilitiesForMethod(strategy, method);
return capabilities.isEmpty() ? null : capabilities.getFirst().getCode();
}
/// 校验指定能力属于该(产品, 支付方式)的策略声明候选
private void validateCapability(String product, PayMethodEnum method, String capability) {
AbsProductStrategy strategy = PaymentStrategyFactory.createByProduct(product, AbsProductStrategy.class);

View File

@@ -91,7 +91,6 @@ public class PayRouteStrategyCapabilitySupport {
/// 从商户全部通道商户中筛出启用且其产品支持该(provider,method)的候选
private List<LabelValue> filterChannelMchForDirectory(List<ChannelMerchant> mchants, String provider, String method) {
PayMethodEnum methodEnum = PayMethodEnum.findByCode(method);
List<LabelValue> results = new ArrayList<>();
for (ChannelMerchant mch : mchants) {
if (!Boolean.TRUE.equals(mch.getEnable())) {
@@ -136,6 +135,51 @@ public class PayRouteStrategyCapabilitySupport {
return null;
}
/// 传值模式: 商户全部启用通道商户候选(不按支付方式过滤)
public List<LabelValue> listDirectChannelMchCandidates(String mchNo) {
List<ChannelMerchant> mchants = channelMerchantManager.findAllByMchNo(mchNo);
List<LabelValue> results = new ArrayList<>();
for (ChannelMerchant mch : mchants) {
if (!Boolean.TRUE.equals(mch.getEnable())) {
continue;
}
String label = StrUtil.isNotBlank(mch.getChannelMerchantName())
? mch.getChannelMerchantName() : mch.getChannelMchNo();
if (results.stream().noneMatch(item -> Objects.equals(item.getValue(), mch.getChannelMchNo()))) {
results.add(new LabelValue(label, mch.getChannelMchNo()));
}
}
return results;
}
/// 传值模式: 按通道商户(产品)返回全部启用支付能力候选(不按支付方式过滤)
public List<LabelValue> listDirectCapabilityCandidates(String channelMchNo) {
String product = productOfChannelMchNo(channelMchNo);
if (StrUtil.isBlank(product) || !PaymentStrategyFactory.existsByProduct(product, AbsProductStrategy.class)) {
return List.of();
}
AbsProductStrategy strategy = PaymentStrategyFactory.createByProduct(product, AbsProductStrategy.class);
return ProductStrategySupport.supportedPayCapabilities(strategy).stream()
.filter(capability -> productCapabilityEnabled(product, capability.getCode()))
.map(capability -> new LabelValue(I18nUtil.getEnumName(capability), capability.getCode()))
.toList();
}
/// 传值模式: 由(通道商户, 支付能力)反推支付方式编码(策略 Map + DB 启用, 无则 null)
public String inferMethodForCapability(String channelMchNo, String capabilityCode) {
String product = productOfChannelMchNo(channelMchNo);
if (StrUtil.isBlank(product) || !PaymentStrategyFactory.existsByProduct(product, AbsProductStrategy.class)) {
return null;
}
AbsProductStrategy strategy = PaymentStrategyFactory.createByProduct(product, AbsProductStrategy.class);
PayCapabilityEnum capability = PayCapabilityEnum.findByCode(capabilityCode);
if (capability == null || !productCapabilityEnabled(product, capabilityCode)) {
return null;
}
PayMethodEnum method = ProductStrategySupport.methodForCapability(strategy, capability);
return method == null ? null : method.getCode();
}
/// 校验场景配置项所选能力在候选集合内
public void validateSceneCapability(String provider, String method, String channelMchNo, String capability) {
if (StrUtil.isBlank(capability)) {

View File

@@ -38,5 +38,6 @@
"duplicateSceneMethod": "Duplicate scene configuration for pay method [{0}]",
"sceneChannelMchCapabilityPair": "For pay method [{0}], select both channel merchant and capability, or leave both empty",
"basicChannelMchNotConfigured": "No channel merchant configured for pay brand [{0}]; configure it in basic route mode",
"sceneCapabilityChannelMchMismatch": "Capability [{0}] does not match channel merchant [{1}] and method [{2}]"
"sceneCapabilityChannelMchMismatch": "Capability [{0}] does not match channel merchant [{1}] and method [{2}]",
"directCapabilityChannelMchMismatch": "Capability [{0}] does not match channel merchant [{1}]"
}

View File

@@ -38,5 +38,6 @@
"duplicateSceneMethod": "场景模式下同一支付方式[{0}]存在多条配置",
"sceneChannelMchCapabilityPair": "支付方式[{0}]须同时选择通道商户与支付能力,或同时留空",
"basicChannelMchNotConfigured": "支付渠道[{0}]未配置通道商户,请在通道路由基础模式中完成配置",
"sceneCapabilityChannelMchMismatch": "支付能力[{0}]与通道商户[{1}]、支付方式[{2}]不匹配"
"sceneCapabilityChannelMchMismatch": "支付能力[{0}]与通道商户[{1}]、支付方式[{2}]不匹配",
"directCapabilityChannelMchMismatch": "支付能力[{0}]与通道商户[{1}]不匹配"
}