refactor(route): 通道路由场景模式重构为通道商户+支付能力定位

场景/基础模式配置以 channelMchNo 替代 product, 解决一商户多通道商户无法唯一定位; PayMethodEnum 加 provider 字段由支付方式直接解析, 移除从 product 反推; PayParam 新增 capability 支持路由回填与直定模式(指定 channelMchNo 跳过路由); 候选数据源改为商户实际开通的 ChannelMerchant, 修复原配置不考虑开通情况; 删除 dead code(PayRouteSceneRouteResolver/PayRouteCapabilityService) 并重建两表 DDL
This commit is contained in:
DaxPay Dev
2026-06-26 21:03:11 +08:00
parent f09b8a4d6c
commit f092544c48
28 changed files with 541 additions and 476 deletions

View File

@@ -181,3 +181,79 @@ COMMENT ON COLUMN iam_user_two_factor.deleted IS '逻辑删除标志';
-- 用户ID唯一索引(未删除范围内, 一个用户最多一条绑定记录)
CREATE UNIQUE INDEX IF NOT EXISTS uk_iam_user_two_factor_user_id
ON iam_user_two_factor (user_id) WHERE deleted = false;
-- ===================================
-- 支付通道路由: 场景模式配置表 + 基础模式配置表
-- 重构: 以通道商户号(channel_mch_no)替代支付产品(product)定位,场景模式新增支付能力(capability)
-- ===================================
DROP TABLE IF EXISTS pay_route_scene_config;
DROP TABLE IF EXISTS pay_route_basic_config;
-- 支付通道路由场景模式配置(每支付方式一条: 通道商户 + 支付能力)
CREATE TABLE IF NOT EXISTS pay_route_scene_config (
id bigint NOT NULL,
strategy_id bigint NOT NULL,
provider varchar(32),
channel varchar(32),
method varchar(32) NOT NULL,
channel_mch_no varchar(32) NOT NULL,
capability varchar(32) NOT NULL,
creator bigint,
create_time timestamptz(6),
last_modifier bigint,
last_modified_time timestamptz(6),
version int NOT NULL DEFAULT 0,
deleted boolean NOT NULL DEFAULT false,
CONSTRAINT pay_route_scene_config_pkey PRIMARY KEY (id)
);
COMMENT ON TABLE pay_route_scene_config IS '支付通道路由场景模式配置';
COMMENT ON COLUMN pay_route_scene_config.id IS '主键';
COMMENT ON COLUMN pay_route_scene_config.strategy_id IS '路由策略ID';
COMMENT ON COLUMN pay_route_scene_config.provider IS '支付渠道(派生自支付方式)';
COMMENT ON COLUMN pay_route_scene_config.channel IS '通道编码(派生自通道商户绑定的产品)';
COMMENT ON COLUMN pay_route_scene_config.method IS '支付方式编码';
COMMENT ON COLUMN pay_route_scene_config.channel_mch_no IS '通道商户号(唯一绑定支付产品)';
COMMENT ON COLUMN pay_route_scene_config.capability IS '支付能力编码';
COMMENT ON COLUMN pay_route_scene_config.creator IS '创建人ID';
COMMENT ON COLUMN pay_route_scene_config.create_time IS '创建时间';
COMMENT ON COLUMN pay_route_scene_config.last_modifier IS '最后修改人ID';
COMMENT ON COLUMN pay_route_scene_config.last_modified_time IS '最后修改时间';
COMMENT ON COLUMN pay_route_scene_config.version IS '版本号';
COMMENT ON COLUMN pay_route_scene_config.deleted IS '逻辑删除';
-- 策略 + 支付方式 唯一索引(未删除范围内)
CREATE UNIQUE INDEX IF NOT EXISTS uk_pay_route_scene_config_method
ON pay_route_scene_config (strategy_id, method) WHERE deleted = false;
-- 支付通道路由基础模式配置(每支付渠道一条: 通道商户)
CREATE TABLE IF NOT EXISTS pay_route_basic_config (
id bigint NOT NULL,
strategy_id bigint NOT NULL,
provider varchar(32) NOT NULL,
channel_mch_no varchar(32) NOT NULL,
creator bigint,
create_time timestamptz(6),
last_modifier bigint,
last_modified_time timestamptz(6),
version int NOT NULL DEFAULT 0,
deleted boolean NOT NULL DEFAULT false,
CONSTRAINT pay_route_basic_config_pkey PRIMARY KEY (id)
);
COMMENT ON TABLE pay_route_basic_config IS '支付通道路由基础模式配置';
COMMENT ON COLUMN pay_route_basic_config.id IS '主键';
COMMENT ON COLUMN pay_route_basic_config.strategy_id IS '路由策略ID';
COMMENT ON COLUMN pay_route_basic_config.provider IS '支付渠道';
COMMENT ON COLUMN pay_route_basic_config.channel_mch_no IS '通道商户号(唯一绑定支付产品)';
COMMENT ON COLUMN pay_route_basic_config.creator IS '创建人ID';
COMMENT ON COLUMN pay_route_basic_config.create_time IS '创建时间';
COMMENT ON COLUMN pay_route_basic_config.last_modifier IS '最后修改人ID';
COMMENT ON COLUMN pay_route_basic_config.last_modified_time IS '最后修改时间';
COMMENT ON COLUMN pay_route_basic_config.version IS '版本号';
COMMENT ON COLUMN pay_route_basic_config.deleted IS '逻辑删除';
-- 策略 + 支付渠道 唯一索引(未删除范围内)
CREATE UNIQUE INDEX IF NOT EXISTS uk_pay_route_basic_config_provider
ON pay_route_basic_config (strategy_id, provider) WHERE deleted = false;

View File

@@ -78,15 +78,15 @@ public class PayRouteAdminController {
}
@PermCode(code = "view", nameCn = "通道路由查看", nameEn = "Pay Route View")
@Operation(summary = "通道路由白名单目录下全部产品候选(批量)")
@GetMapping("/scene-config/product-candidates-batch")
public Result<Map<String, List<LabelValue>>> listSceneProductCandidatesBatch(
@Operation(summary = "通道路由白名单目录下全部通道商户候选(批量)")
@GetMapping("/scene-config/channel-mch-candidates-batch")
public Result<Map<String, List<LabelValue>>> listSceneChannelMchCandidatesBatch(
@NotBlank(message = "{validation.field.appId.notBlank}") String appId) {
return Res.ok(configService.listSceneProductCandidatesBatch(appId));
return Res.ok(configService.listSceneChannelMchCandidatesBatch(appId));
}
@PermCode(code = "view", nameCn = "通道路由查看", nameEn = "Pay Route View")
@Operation(summary = "按目录项与产品批量返回支付能力候选")
@Operation(summary = "按目录项与通道商户批量返回支付能力候选")
@PostMapping("/scene-config/capability-candidates-batch")
public Result<Map<String, List<LabelValue>>> listSceneCapabilityCandidatesBatch(
@Valid @RequestBody PayRouteSceneCapabilityBatchParam param) {
@@ -94,35 +94,35 @@ public class PayRouteAdminController {
}
@PermCode(code = "view", nameCn = "通道路由查看", nameEn = "Pay Route View")
@Operation(summary = "目录项下商户可用支付产品候选")
@GetMapping("/scene-config/product-candidates")
public Result<List<LabelValue>> listSceneProductCandidates(
@Operation(summary = "目录项下商户已开通的通道商户候选")
@GetMapping("/scene-config/channel-mch-candidates")
public Result<List<LabelValue>> listSceneChannelMchCandidates(
@NotBlank(message = "{validation.field.appId.notBlank}") String appId,
@NotBlank(message = "{validation.field.provider.notBlank}") String provider,
@NotBlank(message = "{validation.field.method.notBlank}") String method) {
return Res.ok(configService.listSceneProductCandidatesForMethod(appId, provider, method));
return Res.ok(configService.listSceneChannelMchCandidatesForMethod(appId, provider, method));
}
@PermCode(code = "view", nameCn = "通道路由查看", nameEn = "Pay Route View")
@Operation(summary = "目录项与产品下支付能力候选")
@Operation(summary = "目录项与通道商户下支付能力候选")
@GetMapping("/scene-config/capability-candidates")
public Result<List<LabelValue>> listSceneCapabilityCandidates(
@NotBlank(message = "{validation.field.appId.notBlank}") String appId,
@NotBlank(message = "{validation.field.provider.notBlank}") String provider,
@NotBlank(message = "{validation.field.method.notBlank}") String method,
@NotBlank(message = "{validation.field.product.notBlank}") String product) {
return Res.ok(configService.listSceneCapabilityCandidatesForMethod(appId, provider, method, product));
@NotBlank(message = "{validation.field.channelMchNo.notBlank}") String channelMchNo) {
return Res.ok(configService.listSceneCapabilityCandidatesForMethod(appId, provider, method, channelMchNo));
}
@PermCode(code = "view", nameCn = "通道路由查看", nameEn = "Pay Route View")
@Operation(summary = "推断目录项与产品下唯一支付能力(仅供回显)")
@Operation(summary = "推断目录项与通道商户下唯一支付能力(仅供回显)")
@GetMapping("/scene-config/infer-capability")
public Result<String> inferSceneCapability(
@NotBlank(message = "{validation.field.appId.notBlank}") String appId,
@NotBlank(message = "{validation.field.provider.notBlank}") String provider,
@NotBlank(message = "{validation.field.method.notBlank}") String method,
@NotBlank(message = "{validation.field.product.notBlank}") String product) {
return Res.ok(configService.inferSceneCapability(appId, provider, method, product));
@NotBlank(message = "{validation.field.channelMchNo.notBlank}") String channelMchNo) {
return Res.ok(configService.inferSceneCapability(appId, provider, method, channelMchNo));
}
@PermCode(code = "view", nameCn = "通道路由查看", nameEn = "Pay Route View")

View File

@@ -13,6 +13,7 @@ import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Optional;
/// # 通道商户管理
///
@@ -51,4 +52,12 @@ public class ChannelMerchantManager extends BaseManager<ChannelMerchantMapper, C
.eq(ChannelMerchant::getMchNo, mchNo)
.list();
}
/// 根据商户号与通道商户号查询唯一通道商户(不存在返回 empty)
public Optional<ChannelMerchant> findByMchNoAndChannelMchNo(String mchNo, String channelMchNo){
return this.lambdaQuery()
.eq(ChannelMerchant::getMchNo, mchNo)
.eq(ChannelMerchant::getChannelMchNo, channelMchNo)
.oneOpt();
}
}

View File

@@ -63,6 +63,12 @@ public class PayParam extends MerchantPaymentCommonParam {
@Size(max = 32, message = "{validation.field.method.size}")
private String method;
/// 支付能力编码:路由模式由路由引擎回填;直定模式(已传 channelMchNo)时作为输入参与校验
/// @see cn.daxpay.open.platform.core.enums.pay.channel.PayCapabilityEnum
@Schema(description = "支付能力编码")
@Size(max = 32, message = "{validation.field.capability.size}")
private String capability;
// ===== 通道专属参数(策略层校验)=====
/// 用户标识 OpenId微信 jsapi/mini 场景必填)

View File

@@ -13,5 +13,7 @@ public interface PayRouteBasicConfigConvert {
PayRouteBasicConfigConvert CONVERT = Mappers.getMapper(PayRouteBasicConfigConvert.class);
/// channelMchants(可选通道商户列表)由 service 按商户开通情况装配,转换时忽略
@Mapping(target = "channelMchants", ignore = true)
PayRouteBasicConfigResult toResult(PayRouteBasicConfig entity);
}

View File

@@ -26,8 +26,8 @@ public class PayRouteBasicConfig extends MpBaseEntity implements ToResult<PayRou
/// @see PayProviderEnum
private String provider;
/// 支付产品编码
private String product;
/// 通道商户号(唯一绑定一个支付产品,替代旧版 product 字段)
private String channelMchNo;
@Override
public PayRouteBasicConfigResult toResult() {

View File

@@ -31,8 +31,12 @@ public class PayRouteSceneConfig extends MpBaseEntity implements ToResult<PayRou
/// 支付方式编码
private String method;
/// 产品编码
private String product;
/// 通道商户号(唯一绑定一个支付产品,替代旧版 product 字段)
private String channelMchNo;
/// 支付能力编码(商户为该支付方式+通道商户选定的能力)
/// @see cn.daxpay.open.platform.core.enums.pay.channel.PayCapabilityEnum
private String capability;
@Override
public PayRouteSceneConfigResult toResult() {

View File

@@ -16,6 +16,6 @@ public class PayRouteBasicConfigItem {
@Schema(description = "支付渠道: wechat/alipay/union_pay")
private String provider;
@Schema(description = "支付产品编码,空表示清除该场景配置")
private String product;
@Schema(description = "通道商户号,空表示清除该渠道配置")
private String channelMchNo;
}

View File

@@ -19,7 +19,7 @@ public class PayRouteSceneCapabilityBatchItem {
@Schema(description = "支付方式")
private String method;
@NotBlank(message = "{validation.field.product.notBlank}")
@Schema(description = "支付产品")
private String product;
@NotBlank(message = "{validation.field.channelMchNo.notBlank}")
@Schema(description = "通道商户号")
private String channelMchNo;
}

View File

@@ -13,10 +13,10 @@ public class PayRouteSceneConfigItem {
@Schema(description = "支付渠道")
private String provider;
@Schema(description = "支付产品")
private String product;
@Schema(description = "通道商户号(场景模式定位通道商户,由其推导支付产品)")
private String channelMchNo;
@Schema(description = "支付能力(场景保存校验用,不落库)")
@Schema(description = "支付能力")
private String capability;
@Schema(description = "支付通道")

View File

@@ -1,6 +1,7 @@
package cn.daxpay.open.payment.merchant.result.route.basic;
import cn.daxpay.open.platform.core.result.BaseResult;
import cn.daxpay.open.platform.core.rest.dto.LabelValue;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
@@ -21,10 +22,10 @@ public class PayRouteBasicConfigResult extends BaseResult {
@Schema(description = "支付渠道: wechat/alipay/union_pay")
private String provider;
@Schema(description = "支付产品编码")
private String product;
@Schema(description = "通道商户号")
private String channelMchNo;
/// 该渠道下可选支付产品编码列表
@Schema(description = "可选支付产品编码")
private List<String> products = new ArrayList<>();
/// 该渠道下可选通道商户列表(名称/号码)
@Schema(description = "可选通道商户列表")
private List<LabelValue> channelMchants = new ArrayList<>();
}

View File

@@ -25,6 +25,9 @@ public class PayRouteSceneConfigResult extends BaseResult {
@Schema(description = "支付方式编码")
private String method;
@Schema(description = "产品编码")
private String product;
@Schema(description = "通道商户号")
private String channelMchNo;
@Schema(description = "支付能力编码")
private String capability;
}

View File

@@ -1,5 +1,7 @@
package cn.daxpay.open.payment.merchant.service.route.basic;
import cn.daxpay.open.payment.channel.dao.mch.ChannelMerchantManager;
import cn.daxpay.open.payment.channel.entity.mch.ChannelMerchant;
import cn.daxpay.open.payment.common.util.PaymentStrategyFactory;
import cn.daxpay.open.payment.merchant.dao.appinfo.MchAppInfoManager;
import cn.daxpay.open.payment.merchant.dao.route.basic.PayRouteBasicConfigManager;
@@ -11,24 +13,23 @@ import cn.daxpay.open.payment.merchant.param.route.basic.PayRouteBasicConfigItem
import cn.daxpay.open.payment.merchant.result.route.basic.PayRouteBasicConfigResult;
import cn.daxpay.open.payment.merchant.service.route.support.PayRouteConfigProviders;
import cn.daxpay.open.payment.strategy.product.AbsProductStrategy;
import cn.daxpay.open.platform.core.code.CommonErrorCode;
import cn.daxpay.open.platform.core.enums.pay.channel.PayProviderEnum;
import cn.daxpay.open.platform.core.enums.pay.channel.ProductEnum;
import cn.daxpay.open.platform.core.exception.BizInfoException;
import cn.daxpay.open.platform.core.exception.DataNotExistException;
import cn.daxpay.open.platform.core.code.CommonErrorCode;
import cn.daxpay.open.platform.core.rest.dto.LabelValue;
import cn.hutool.core.util.StrUtil;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
/// # 通道路由基础模式配置
///
/// 按支付渠道配置默认支付产品,保存前校验产品策略能力
/// 按支付渠道配置默认通道商户(唯一绑定支付产品),保存前校验通道商户归属与渠道支持
@Service
@RequiredArgsConstructor
public class PayRouteBasicConfigService {
@@ -36,79 +37,84 @@ public class PayRouteBasicConfigService {
private final PayRouteStrategyManager strategyManager;
private final PayRouteBasicConfigManager basicConfigManager;
private final MchAppInfoManager mchAppInfoManager;
private final ChannelMerchantManager channelMerchantManager;
/// 查询基础模式面板数据(已保存 product + 各渠道可选 products
/// 查询基础模式面板数据(已保存通道商户号 + 各渠道可选通道商户列表
public List<PayRouteBasicConfigResult> listBasicByAppId(String appId) {
String mchNo = mchAppInfoManager.requireMchNoByAppIdNotTenant(appId);
PayRouteStrategy strategy = requireStrategy(appId);
Map<String, String> productMap = basicConfigManager.findByStrategyId(strategy.getId()).stream()
Map<String, String> mchMap = basicConfigManager.findByStrategyId(strategy.getId()).stream()
.filter(config -> StrUtil.isNotBlank(config.getProvider()))
.collect(Collectors.toMap(PayRouteBasicConfig::getProvider, PayRouteBasicConfig::getProduct, (a, b) -> a));
.collect(Collectors.toMap(PayRouteBasicConfig::getProvider,
PayRouteBasicConfig::getChannelMchNo, (a, b) -> a));
return PayRouteConfigProviders.enumsInWhitelistOrder().stream()
.map(provider -> toPanelResult(provider, productMap.get(provider.getCode())))
.map(provider -> toPanelResult(mchNo, provider, mchMap.get(provider.getCode())))
.toList();
}
/// 批量保存基础模式配置(先删后插)
@Transactional(rollbackFor = Exception.class)
public void saveBasicBatch(PayRouteBasicConfigBatchParam param) {
String mchNo = mchAppInfoManager.requireMchNoByAppIdNotTenant(param.getAppId());
PayRouteStrategy strategy = requireStrategy(param.getAppId());
basicConfigManager.deleteByStrategyId(strategy.getId());
for (PayRouteBasicConfigItem item : param.getItems()) {
if (StrUtil.isBlank(item.getProduct())) {
if (StrUtil.isBlank(item.getChannelMchNo())) {
continue;
}
PayProviderEnum provider = validateBasicPayProviderCode(item.getProvider());
validateBasicProduct(item.getProduct(), provider);
// 校验通道商户属本商户且启用
ChannelMerchant mch = channelMerchantManager
.findByMchNoAndChannelMchNo(mchNo, item.getChannelMchNo())
.orElseThrow(() -> new BizInfoException(CommonErrorCode.VALIDATE_PARAMETERS_ERROR,
"pay.route.error.channelMchNotExist", item.getChannelMchNo()));
if (!Boolean.TRUE.equals(mch.getEnable())) {
// 通道商户[{0}]未启用
throw new BizInfoException(CommonErrorCode.VALIDATE_PARAMETERS_ERROR,
"pay.route.error.channelMchDisabled", item.getChannelMchNo());
}
if (!PaymentStrategyFactory.productSupportsProvider(mch.getProduct(), provider)) {
// 支付渠道[{0}]下无可用支付产品
throw new BizInfoException(CommonErrorCode.VALIDATE_PARAMETERS_ERROR,
"pay.route.error.basicProductNotAvailable", provider.getCode());
}
if (!PaymentStrategyFactory.existsByProduct(mch.getProduct(), AbsProductStrategy.class)) {
// 支付产品策略不存在
throw new BizInfoException(CommonErrorCode.VALIDATE_PARAMETERS_ERROR,
"pay.route.error.productStrategyMissing");
}
PayRouteBasicConfig config = new PayRouteBasicConfig();
config.setStrategyId(strategy.getId());
config.setProvider(item.getProvider());
config.setProduct(item.getProduct());
config.setChannelMchNo(item.getChannelMchNo());
basicConfigManager.save(config);
}
}
/// 校验产品存在且支持指定支付渠道
public void validateBasicProduct(String product, PayProviderEnum provider) {
ProductEnum productEnum = ProductEnum.findByCode(product);
if (productEnum == null) {
throw new BizInfoException(CommonErrorCode.VALIDATE_PARAMETERS_ERROR,
"pay.route.error.productInvalid", product);
}
if (!PaymentStrategyFactory.productSupportsProvider(product, provider)) {
throw new BizInfoException(CommonErrorCode.VALIDATE_PARAMETERS_ERROR,
"pay.route.error.basicProductNotAvailable", provider.getCode());
}
if (!PaymentStrategyFactory.existsByProduct(product, AbsProductStrategy.class)) {
throw new BizInfoException(CommonErrorCode.VALIDATE_PARAMETERS_ERROR,
"pay.route.error.productStrategyMissing");
}
}
/// 校验支付渠道编码合法且在通道路由白名单内
private PayProviderEnum validateBasicPayProviderCode(String providerCode) {
PayProviderEnum provider = PayProviderEnum.findByCode(providerCode);
if (provider == null || !PayRouteConfigProviders.contains(providerCode)) {
// 支付渠道无效
throw new BizInfoException(CommonErrorCode.VALIDATE_PARAMETERS_ERROR, "pay.route.error.basicProviderInvalid");
}
return provider;
}
/// 组装单渠道基础模式面板行:已保存 product + 该渠道下可选 products
private PayRouteBasicConfigResult toPanelResult(PayProviderEnum provider, String savedProduct) {
List<String> products = productsForProvider(provider);
/// 组装单渠道基础模式面板行:已保存通道商户号 + 该渠道下可选通道商户列表
private PayRouteBasicConfigResult toPanelResult(String mchNo, PayProviderEnum provider, String savedChannelMchNo) {
List<LabelValue> mchants = channelMerchantManager.findAllByMchNo(mchNo).stream()
.filter(mch -> Boolean.TRUE.equals(mch.getEnable()))
.filter(mch -> PaymentStrategyFactory.productSupportsProvider(mch.getProduct(), provider))
.map(mch -> new LabelValue(
StrUtil.isNotBlank(mch.getChannelMerchantName())
? mch.getChannelMerchantName() : mch.getChannelMchNo(),
mch.getChannelMchNo()))
.toList();
return new PayRouteBasicConfigResult()
.setProvider(provider.getCode())
.setProduct(savedProduct)
.setProducts(products);
}
/// 从产品枚举中筛出支持指定支付渠道的产品编码
private List<String> productsForProvider(PayProviderEnum provider) {
return Arrays.stream(ProductEnum.values())
.map(ProductEnum::getCode)
.filter(product -> PaymentStrategyFactory.productSupportsProvider(product, provider))
.distinct()
.toList();
.setChannelMchNo(savedChannelMchNo)
.setChannelMchants(mchants);
}
/// 按应用号加载路由策略,不存在则抛业务异常
@@ -116,4 +122,4 @@ public class PayRouteBasicConfigService {
return strategyManager.findByAppId(appId)
.orElseThrow(() -> new DataNotExistException("pay.route.error.routeStrategyNotExist"));
}
}
}

View File

@@ -2,16 +2,17 @@ package cn.daxpay.open.payment.merchant.service.route.basic;
import cn.daxpay.open.payment.common.util.PaymentStrategyFactory;
import cn.daxpay.open.payment.merchant.entity.route.basic.PayRouteBasicConfig;
import cn.daxpay.open.payment.merchant.service.route.support.PayRouteCapabilityService;
import cn.daxpay.open.payment.merchant.service.route.model.RouteHit;
import cn.daxpay.open.payment.merchant.service.route.runtime.PayRouteProductResolver;
import cn.daxpay.open.payment.merchant.service.route.support.PayRouteI18nHelper;
import cn.daxpay.open.platform.core.enums.pay.channel.ProductEnum;
import cn.daxpay.open.payment.old.pay.support.ProductStrategySupport;
import cn.daxpay.open.payment.strategy.product.AbsProductStrategy;
import cn.daxpay.open.payment.unipay.param.trade.pay.PayParam;
import cn.daxpay.open.platform.core.code.CommonErrorCode;
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.enums.pay.channel.PayProviderEnum;
import cn.daxpay.open.platform.core.exception.BizInfoException;
import cn.daxpay.open.platform.core.code.CommonErrorCode;
import cn.hutool.core.util.StrUtil;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
@@ -21,75 +22,67 @@ import java.util.Objects;
/// # 基础模式通道路由匹配器
///
/// 按已配置的支付渠道与支付产品,结合产品策略解析通道与支付方式
/// 按「支付渠道 → 通道商户」配置,结合支付方式解析通道商户、产品与能力
/// 支付渠道由支付方式(PayMethodEnum)自带渠道属性解析,不再从支付产品反推。
@Component
@RequiredArgsConstructor
public class PayRouteBasicMatcher {
private final PayRouteProductResolver productResolver;
private final PayRouteCapabilityService payRouteCapabilityService;
/// 基础模式匹配
public RouteHit match(List<PayRouteBasicConfig> basicConfigs, PayParam payParam) {
String providerCode = providerFromProduct(payParam);
if (StrUtil.isBlank(providerCode)) {
if (StrUtil.isBlank(payParam.getMethod())) {
// 场景模式下须选择支付方式
throw new BizInfoException(CommonErrorCode.VALIDATE_PARAMETERS_ERROR, "pay.route.error.sceneMethodRequired");
}
PayMethodEnum methodEnum = PayMethodEnum.findByCode(payParam.getMethod());
// 支付方式自带渠道属性(OTHER 等无归属时报错)
PayProviderEnum provider = methodEnum.getProvider();
if (provider == null) {
// 未指定支付产品时,支付渠道不能为空
throw new BizInfoException(CommonErrorCode.VALIDATE_PARAMETERS_ERROR, "pay.route.error.providerRequired");
}
PayProviderEnum provider = PayProviderEnum.findByCode(providerCode);
if (provider == null) {
throw new BizInfoException(CommonErrorCode.VALIDATE_PARAMETERS_ERROR, "pay.route.error.basicProviderInvalid");
}
String product = findConfiguredProduct(basicConfigs, providerCode);
if (StrUtil.isBlank(product)) {
String channelMchNo = findConfiguredChannelMchNo(basicConfigs, provider.getCode());
if (StrUtil.isBlank(channelMchNo)) {
// 支付渠道[{0}]未配置通道商户,请在通道路由基础模式中完成配置
throw new BizInfoException(CommonErrorCode.VALIDATE_PARAMETERS_ERROR,
"pay.route.error.basicProductNotConfigured", PayRouteI18nHelper.provider(providerCode));
"pay.route.error.basicChannelMchNotConfigured", PayRouteI18nHelper.provider(provider.getCode()));
}
assertProductConfigured(product, provider);
String product = productResolver.productOfChannelMchNo(channelMchNo);
if (!PaymentStrategyFactory.existsByProduct(product, AbsProductStrategy.class)) {
// 支付产品策略不存在
throw new BizInfoException(CommonErrorCode.VALIDATE_PARAMETERS_ERROR,
"pay.route.error.productStrategyMissing");
}
List<String> methodCandidates = payRouteCapabilityService.methodsForProductPayProvider(
product, providerCode);
if (methodCandidates.isEmpty()) {
if (!PaymentStrategyFactory.productSupportsProvider(product, provider)) {
// 支付渠道[{0}]下无可用支付产品
throw new BizInfoException(CommonErrorCode.VALIDATE_PARAMETERS_ERROR,
"pay.route.error.methodNotSupportedForProduct",
PayRouteI18nHelper.product(product));
"pay.route.error.basicProductNotAvailable", PayRouteI18nHelper.provider(provider.getCode()));
}
String method = methodCandidates.getFirst();
String channel = productResolver.channelOfProduct(product);
return new RouteHit(channel, method, product, null, null);
// 基础模式不存能力,由 (产品, 支付方式) 派生
String capability = resolveCapability(product, methodEnum);
return new RouteHit(channel, methodEnum.getCode(), product, channelMchNo, capability, null, null);
}
/// 从产品编码推导 Provider临时兼容待路由重构后移除
private static String providerFromProduct(PayParam payParam) {
if (StrUtil.isNotBlank(payParam.getProduct())) {
ProductEnum product = ProductEnum.findByCode(payParam.getProduct());
if (product != null) {
return product.getChannel();
}
}
return null;
}
/// 从基础配置中取指定支付渠道已绑定的产品编码
private String findConfiguredProduct(List<PayRouteBasicConfig> basicConfigs, String providerCode) {
/// 从基础配置中取指定支付渠道已绑定的通道商户号
private String findConfiguredChannelMchNo(List<PayRouteBasicConfig> basicConfigs, String providerCode) {
if (basicConfigs == null) {
return null;
}
return basicConfigs.stream()
.filter(config -> Objects.equals(config.getProvider(), providerCode))
.map(PayRouteBasicConfig::getProduct)
.map(PayRouteBasicConfig::getChannelMchNo)
.filter(StrUtil::isNotBlank)
.findFirst()
.orElse(null);
}
/// 校验产品策略支持该支付渠道
private void assertProductConfigured(String product, PayProviderEnum provider) {
if (!PaymentStrategyFactory.productSupportsProvider(product, provider)) {
throw new BizInfoException(CommonErrorCode.VALIDATE_PARAMETERS_ERROR,
"pay.route.error.basicProductNotAvailable", PayRouteI18nHelper.provider(provider.getCode()));
}
/// 基础模式支付能力由产品策略声明的(方式→能力)映射取首个
private String resolveCapability(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();
}
}
}

View File

@@ -77,30 +77,30 @@ public class PayRouteConfigService {
sceneConfigService.saveSceneBatch(param);
}
/// 通道路由白名单目录下全部产品候选(批量)
public Map<String, List<LabelValue>> listSceneProductCandidatesBatch(String appId) {
return sceneConfigService.listSceneProductCandidatesBatch(appId);
/// 通道路由白名单目录下全部通道商户候选(批量)
public Map<String, List<LabelValue>> listSceneChannelMchCandidatesBatch(String appId) {
return sceneConfigService.listSceneChannelMchCandidatesBatch(appId);
}
/// 按目录项与产品批量返回支付能力候选
/// 按目录项与通道商户批量返回支付能力候选
public Map<String, List<LabelValue>> listSceneCapabilityCandidatesBatch(PayRouteSceneCapabilityBatchParam param) {
return sceneConfigService.listSceneCapabilityCandidatesBatch(param);
}
/// 目录项下商户可用支付产品候选
public List<LabelValue> listSceneProductCandidatesForMethod(String appId, String provider, String method) {
return sceneConfigService.listSceneProductCandidatesForMethod(appId, provider, method);
/// 目录项下商户已开通的通道商户候选
public List<LabelValue> listSceneChannelMchCandidatesForMethod(String appId, String provider, String method) {
return sceneConfigService.listSceneChannelMchCandidatesForMethod(appId, provider, method);
}
/// 目录项与产品下支付能力候选
/// 目录项与通道商户下支付能力候选
public List<LabelValue> listSceneCapabilityCandidatesForMethod(
String appId, String provider, String method, String product) {
return sceneConfigService.listSceneCapabilityCandidatesForMethod(appId, provider, method, product);
String appId, String provider, String method, String channelMchNo) {
return sceneConfigService.listSceneCapabilityCandidatesForMethod(appId, provider, method, channelMchNo);
}
/// 推断目录项与产品下唯一支付能力(仅供回显)
public String inferSceneCapability(String appId, String provider, String method, String product) {
return sceneConfigService.inferSceneCapability(appId, provider, method, product);
/// 推断目录项与通道商户下唯一支付能力(仅供回显)
public String inferSceneCapability(String appId, String provider, String method, String channelMchNo) {
return sceneConfigService.inferSceneCapability(appId, provider, method, channelMchNo);
}
/// 查询基础模式各支付渠道对应产品

View File

@@ -4,17 +4,21 @@ import cn.daxpay.open.payment.merchant.entity.route.scene.PayRouteSceneConfig;
/// # 通道路由命中结果
///
/// 匹配成功后得到的通道、方式、产品及命中来源 ID,供填充 PayParam。
/// 匹配成功后得到的通道、方式、产品、通道商户及能力,供填充 PayParam。
///
/// @param channel 支付通道编码
/// @param method 支付方式编码
/// @param product 支付产品编码(可为空,由运行时解析)
/// @param channelMchNo 通道商户号(场景/基础模式命中的核心定位字段)
/// @param capability 支付能力编码(场景模式命中的能力声明;基础模式可为空)
/// @param hitRuleId 精细模式命中的规则 ID预留字段当前实现恒为 null
/// @param hitConfigId 场景模式命中的场景配置 ID基础模式为 null
public record RouteHit(String channel, String method, String product, Long hitRuleId, Long hitConfigId) {
public record RouteHit(String channel, String method, String product, String channelMchNo,
String capability, Long hitRuleId, Long hitConfigId) {
/// 由场景模式配置构造命中结果
public static RouteHit fromScene(PayRouteSceneConfig config) {
return new RouteHit(config.getChannel(), config.getMethod(), config.getProduct(), null, config.getId());
return new RouteHit(config.getChannel(), config.getMethod(), null,
config.getChannelMchNo(), config.getCapability(), null, config.getId());
}
}

View File

@@ -1,5 +1,7 @@
package cn.daxpay.open.payment.merchant.service.route.runtime;
import cn.daxpay.open.payment.channel.dao.mch.ChannelMerchantManager;
import cn.daxpay.open.payment.channel.entity.mch.ChannelMerchant;
import cn.daxpay.open.payment.merchant.service.route.support.PayRouteStrategyCapabilitySupport;
import cn.daxpay.open.platform.core.enums.pay.channel.PayMethodEnum;
import cn.daxpay.open.platform.core.enums.pay.channel.ProductEnum;
@@ -14,11 +16,33 @@ import java.util.Objects;
/// # 通道路由产品解析器
///
/// 通道商户号(channelMchNo)与支付产品一一绑定,是路由重构后的唯一定位锚点;
/// 通道(channel)/支付方式(method)→产品的旧解析逻辑暂时保留以兼容基础模式回退场景。
@Service
@RequiredArgsConstructor
public class PayRouteProductResolver {
private final PayRouteStrategyCapabilitySupport payRouteStrategyCapabilitySupport;
private final ChannelMerchantManager channelMerchantManager;
/// 根据通道商户号解析支付产品编码(channelMchNo 唯一绑定 product)
public String productOfChannelMchNo(String channelMchNo) {
if (StrUtil.isBlank(channelMchNo)) {
throw new BizInfoException(CommonErrorCode.VALIDATE_PARAMETERS_ERROR,
"pay.route.error.channelMchNoRequired");
}
return channelMerchantManager.lambdaQuery()
.eq(ChannelMerchant::getChannelMchNo, channelMchNo)
.oneOpt()
.map(ChannelMerchant::getProduct)
.orElseThrow(() -> new BizInfoException(CommonErrorCode.VALIDATE_PARAMETERS_ERROR,
"pay.route.error.channelMchNotExist", channelMchNo));
}
/// 根据通道商户号解析所属通道编码
public String channelOfChannelMchNo(String channelMchNo) {
return channelOfProduct(productOfChannelMchNo(channelMchNo));
}
/// 根据通道及支付方式解析产品编码
public String resolve(String mchNo, String channel, String method) {
@@ -51,4 +75,4 @@ public class PayRouteProductResolver {
}
return productEnum.getChannel();
}
}
}

View File

@@ -1,5 +1,6 @@
package cn.daxpay.open.payment.merchant.service.route.runtime;
import cn.daxpay.open.payment.common.util.PaymentStrategyFactory;
import cn.daxpay.open.payment.merchant.dao.route.basic.PayRouteBasicConfigManager;
import cn.daxpay.open.payment.merchant.dao.route.scene.PayRouteSceneConfigManager;
import cn.daxpay.open.payment.merchant.dao.route.strategy.PayRouteStrategyManager;
@@ -8,20 +9,29 @@ import cn.daxpay.open.payment.merchant.service.route.scene.PayRouteSceneMatcher;
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;
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.core.exception.BizInfoException;
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;
import cn.daxpay.open.platform.core.exception.BizInfoException;
import cn.hutool.core.util.StrUtil;
import lombok.RequiredArgsConstructor;
import java.util.Objects;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Objects;
/// # 支付通道路由服务
///
/// 实现 PayRouteFacade供 NormalPayService 在支付流程中调用。
/// 已指定 product 则跳过;否则按 appId 加载策略,经基础/场景模式匹配后回填 product。
/// 三种解析路径:
/// 1. 直定模式:已传 channelMchNo跳过路由直接由 channelMchNo 推导产品并校验/派生能力;
/// 2. 兼容跳过:未传 channelMchNo 但已指定 product(重付复用),直接返回;
/// 3. 路由模式:按 appId 加载策略,经基础/场景模式匹配后回填 product/channelMchNo/capability。
/// 调用方需保证 appId 已解析完毕。
@Slf4j
@Service
@@ -34,9 +44,15 @@ public class PayRouteService implements PayRouteFacade {
private final PayRouteProductResolver productResolver;
private final PayRouteBasicMatcher basicMatcher;
/// 实付路由解析:已指定 product 则跳过;否则按策略模式匹配并回填 product
/// 实付路由解析:直定模式优先,其次兼容已指定 product最后按策略模式匹配
@Override
public void resolve(PayParam payParam) {
// 直定模式:指定通道商户号,跳过路由
if (StrUtil.isNotBlank(payParam.getChannelMchNo())) {
resolveDirect(payParam);
return;
}
// 兼容:已指定 product(重付复用),跳过路由
if (StrUtil.isNotBlank(payParam.getProduct())) {
return;
}
@@ -56,6 +72,32 @@ public class PayRouteService implements PayRouteFacade {
fillPayParam(payParam, hit);
}
/// 直定模式:由通道商户号推导产品,校验或派生支付能力
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 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);
} else {
// 已传能力则校验属于该(产品, 支付方式)候选
validateCapability(product, methodEnum, capability);
}
payParam.setProduct(product);
payParam.setCapability(capability);
}
/// 按应用号从库加载路由数据包(无 Redis 缓存)
private PayRouteBundle loadBundle(String appId) {
var strategyOpt = strategyManager.findByAppId(appId);
@@ -77,11 +119,34 @@ public class PayRouteService implements PayRouteFacade {
return PayRouteSceneMatcher.match(bundle.getSceneConfigs(), payParam);
}
/// 将命中结果写入 PayParam回填 productchannel 从 ProductEnum 派生method 由客户端传入)
/// 将命中结果写入 PayParam回填 product(由通道商户号派生)、通道商户号、支付能力
private void fillPayParam(PayParam payParam, RouteHit hit) {
String product = StrUtil.isNotBlank(hit.product())
? hit.product()
: productResolver.resolve(payParam.getMchNo(), hit.channel(), hit.method());
: productResolver.productOfChannelMchNo(hit.channelMchNo());
payParam.setProduct(product);
if (StrUtil.isNotBlank(hit.channelMchNo())) {
payParam.setChannelMchNo(hit.channelMchNo());
}
if (StrUtil.isBlank(payParam.getCapability()) && StrUtil.isNotBlank(hit.capability())) {
payParam.setCapability(hit.capability());
}
}
/// 派生支付能力:取产品策略声明的(方式→能力)首个
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);
if (!ProductStrategySupport.strategySupportsCapabilityCode(strategy, method.getCode(), capability)) {
// 支付能力[{0}]与产品[{1}]、支付方式[{2}]不匹配
throw new BizInfoException(CommonErrorCode.VALIDATE_PARAMETERS_ERROR,
"pay.route.error.sceneCapabilityProductMismatch", capability, product, method.getCode());
}
}
}

View File

@@ -1,6 +1,7 @@
package cn.daxpay.open.payment.merchant.service.route.scene;
import cn.daxpay.open.payment.common.util.PaymentStrategyFactory;
import cn.daxpay.open.payment.channel.dao.mch.ChannelMerchantManager;
import cn.daxpay.open.payment.channel.entity.mch.ChannelMerchant;
import cn.daxpay.open.payment.merchant.dao.appinfo.MchAppInfoManager;
import cn.daxpay.open.payment.merchant.dao.route.scene.PayRouteSceneConfigManager;
import cn.daxpay.open.payment.merchant.dao.route.strategy.PayRouteStrategyManager;
@@ -10,17 +11,12 @@ import cn.daxpay.open.payment.merchant.param.route.scene.PayRouteSceneCapability
import cn.daxpay.open.payment.merchant.param.route.scene.PayRouteSceneConfigBatchParam;
import cn.daxpay.open.payment.merchant.param.route.scene.PayRouteSceneConfigItem;
import cn.daxpay.open.payment.merchant.result.route.scene.PayRouteSceneConfigResult;
import cn.daxpay.open.payment.merchant.service.route.basic.PayRouteBasicConfigService;
import cn.daxpay.open.payment.merchant.service.route.runtime.PayRouteProductResolver;
import cn.daxpay.open.payment.merchant.service.route.support.PayRouteConfigProviders;
import cn.daxpay.open.payment.merchant.service.route.support.PayRouteI18nHelper;
import cn.daxpay.open.payment.merchant.service.route.support.PayRouteStrategyCapabilitySupport;
import cn.daxpay.open.payment.merchant.service.route.runtime.PayRouteProductResolver;
import cn.daxpay.open.payment.masterdata.constants.provider.service.PayProviderMethodService;
import cn.daxpay.open.payment.merchant.service.route.support.PayRouteCapabilityService;
import cn.daxpay.open.payment.strategy.product.AbsProductStrategy;
import cn.daxpay.open.platform.core.code.CommonErrorCode;
import cn.daxpay.open.platform.core.enums.pay.channel.PayMethodEnum;
import cn.daxpay.open.platform.core.enums.pay.channel.PayProviderEnum;
import cn.daxpay.open.platform.core.exception.BizInfoException;
import cn.daxpay.open.platform.core.exception.DataNotExistException;
import cn.daxpay.open.platform.core.rest.dto.LabelValue;
@@ -33,13 +29,12 @@ import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
/// # 通道路由场景模式配置
///
/// 按渠道支付方式目录为每个 (provider, method) 绑定支付产品 product
/// 批量保存要求通道路由白名单内目录项均已配置支付产品,唯一键为 strategy + provider + method
/// 配置粒度为「支付方式 → (通道商户, 支付能力)」,每支付方式唯一一行
/// 通道商户唯一绑定支付产品,通道编码由产品派生。批量保存为全量覆盖
@Service
@RequiredArgsConstructor
public class PayRouteSceneConfigService {
@@ -49,11 +44,9 @@ public class PayRouteSceneConfigService {
private final PayRouteProductResolver productResolver;
private final MchAppInfoManager mchAppInfoManager;
private final PayRouteStrategyCapabilitySupport payRouteStrategyCapabilitySupport;
private final PayRouteSceneRouteResolver sceneRouteResolver;
private final PayRouteBasicConfigService basicConfigService;
private final PayRouteCapabilityService payRouteCapabilityService;
private final PayProviderMethodService payProviderMethodService;
private final PayRouteMethodValidator payRouteMethodValidator;
private final ChannelMerchantManager channelMerchantManager;
private final PayProviderMethodService payProviderMethodService;
/// 查询场景模式配置列表
public List<PayRouteSceneConfigResult> listSceneByAppId(String appId) {
@@ -63,61 +56,53 @@ public class PayRouteSceneConfigService {
.toList();
}
/// 批量保存场景模式配置(全量覆盖:目录完整、provider+method 唯一、产品能力校验)
/// 批量保存场景模式配置全量覆盖method 唯一、通道商户/能力校验)
@Transactional(rollbackFor = Exception.class)
public void saveSceneBatch(PayRouteSceneConfigBatchParam param) {
String mchNo = mchAppInfoManager.requireMchNoByAppIdNotTenant(param.getAppId());
PayRouteStrategy strategy = requireStrategy(param.getAppId());
validateSceneConfigUnique(param.getItems());
validateSceneProductCapabilityPairing(param.getItems());
validateSceneChannelMchCapabilityPairing(param.getItems());
sceneConfigManager.deleteByStrategyId(strategy.getId());
List<PayRouteSceneConfig> configs = new ArrayList<>();
for (PayRouteSceneConfigItem item : param.getItems()) {
String channel;
String method;
String product;
if (StrUtil.isNotBlank(item.getProvider())) {
if (!PayRouteConfigProviders.contains(item.getProvider())) {
continue;
}
if (StrUtil.isBlank(item.getMethod())) {
// 能力: 支付方式不能为空
throw new BizInfoException(CommonErrorCode.VALIDATE_PARAMETERS_ERROR,
"error.payment.capability.methodRequiredWithPayProvider");
}
if (isSceneDirectoryRowEmpty(item)) {
continue;
}
payRouteMethodValidator.validateSceneConfigItem(item.getProvider(), item.getMethod());
validateSceneMchProduct(mchNo, item.getProduct(), item.getProvider());
if (!payRouteCapabilityService.productSupportsMethod(item.getProduct(), item.getProvider(), item.getMethod())) {
throw new BizInfoException(CommonErrorCode.VALIDATE_PARAMETERS_ERROR,
"pay.route.error.sceneMethodProductMismatch",
PayRouteI18nHelper.payMethod(item.getMethod()), PayRouteI18nHelper.product(item.getProduct()));
}
payRouteStrategyCapabilitySupport.validateSceneCapability(
item.getProvider(), item.getMethod(), item.getProduct(), item.getCapability());
var route = sceneRouteResolver.resolve(item.getProduct(), item.getProvider(), item.getMethod());
channel = route.channel();
method = route.method();
product = route.product();
} else {
if (StrUtil.isBlank(item.getChannel()) || StrUtil.isBlank(item.getMethod())) {
throw new BizInfoException(CommonErrorCode.VALIDATE_PARAMETERS_ERROR,
"pay.route.error.sceneChannelMethodRequired");
}
payRouteMethodValidator.validateSceneConfigItem(item.getProvider(), item.getMethod());
channel = item.getChannel();
method = item.getMethod();
product = productResolver.resolveAndFill(mchNo, channel, method, item.getProduct());
if (StrUtil.isBlank(item.getMethod()) || !PayRouteConfigProviders.contains(item.getProvider())) {
continue;
}
validateRouteItem(mchNo, channel, method);
if (isSceneRowEmpty(item)) {
continue;
}
payRouteMethodValidator.validateSceneConfigItem(item.getProvider(), item.getMethod());
// 校验通道商户属本商户且启用
ChannelMerchant mch = channelMerchantManager
.findByMchNoAndChannelMchNo(mchNo, item.getChannelMchNo())
.orElseThrow(() -> new BizInfoException(CommonErrorCode.VALIDATE_PARAMETERS_ERROR,
"pay.route.error.channelMchNotExist", item.getChannelMchNo()));
if (!Boolean.TRUE.equals(mch.getEnable())) {
// 通道商户[{0}]未启用
throw new BizInfoException(CommonErrorCode.VALIDATE_PARAMETERS_ERROR,
"pay.route.error.channelMchDisabled", item.getChannelMchNo());
}
String product = mch.getProduct();
// 校验产品支持该(provider, method)
if (!payRouteStrategyCapabilitySupport.routeProductSupportsMethod(
product, item.getProvider(), item.getMethod())) {
throw new BizInfoException(CommonErrorCode.VALIDATE_PARAMETERS_ERROR,
"pay.route.error.sceneMethodProductMismatch",
PayRouteI18nHelper.payMethod(item.getMethod()), PayRouteI18nHelper.product(product));
}
// 校验支付能力在候选集合内
payRouteStrategyCapabilitySupport.validateSceneCapability(
item.getProvider(), item.getMethod(), item.getChannelMchNo(), item.getCapability());
// 通道编码由产品派生
String channel = productResolver.channelOfProduct(product);
PayRouteSceneConfig config = new PayRouteSceneConfig();
config.setStrategyId(strategy.getId());
config.setProvider(StrUtil.blankToDefault(item.getProvider(), null));
config.setProvider(item.getProvider());
config.setChannel(channel);
config.setMethod(method);
config.setProduct(product);
config.setMethod(item.getMethod());
config.setChannelMchNo(item.getChannelMchNo());
config.setCapability(item.getCapability());
configs.add(config);
}
for (PayRouteSceneConfig config : configs) {
@@ -125,103 +110,79 @@ public class PayRouteSceneConfigService {
}
}
/// 通道路由白名单目录下全部 (provider,method) 的产品候选(批量)
public Map<String, List<LabelValue>> listSceneProductCandidatesBatch(String appId) {
/// 通道路由白名单目录下全部 (provider,method) 的通道商户候选(批量)
public Map<String, List<LabelValue>> listSceneChannelMchCandidatesBatch(String appId) {
String mchNo = mchAppInfoManager.requireMchNoByAppIdNotTenant(appId);
return payRouteStrategyCapabilitySupport.listSceneProductCandidatesBatch(mchNo);
return payRouteStrategyCapabilitySupport.listSceneChannelMchCandidatesBatch(mchNo);
}
/// 按目录项+产品批量返回支付能力候选
/// 按目录项+通道商户批量返回支付能力候选
public Map<String, List<LabelValue>> listSceneCapabilityCandidatesBatch(PayRouteSceneCapabilityBatchParam param) {
String mchNo = mchAppInfoManager.requireMchNoByAppIdNotTenant(param.getAppId());
return payRouteStrategyCapabilitySupport.listSceneCapabilityCandidatesBatch(mchNo, param.getItems());
}
/// 按目录项(支付渠道+支付方式)筛选商户已开通且能力匹配的产品候选
public List<LabelValue> listSceneProductCandidatesForMethod(String appId, String provider, String method) {
/// 按目录项(支付渠道+支付方式)筛选商户已开通的通道商户候选
public List<LabelValue> listSceneChannelMchCandidatesForMethod(String appId, String provider, String method) {
if (StrUtil.isBlank(method) || !payProviderMethodService.contains(provider, method)) {
return List.of();
}
String mchNo = mchAppInfoManager.requireMchNoByAppIdNotTenant(appId);
return payRouteStrategyCapabilitySupport.listSceneProductCandidates(provider, method);
return payRouteStrategyCapabilitySupport.listSceneChannelMchCandidates(mchNo, provider, method);
}
/// 按目录项与支付产品筛选支付能力候选(策略 Map ∩ DB不落库
/// 按目录项与通道商户筛选支付能力候选(策略 Map ∩ DB不落库
public List<LabelValue> listSceneCapabilityCandidatesForMethod(
String appId, String provider, String method, String product) {
if (StrUtil.isBlank(product)) {
String appId, String provider, String method, String channelMchNo) {
if (StrUtil.isBlank(channelMchNo)) {
return List.of();
}
String mchNo = mchAppInfoManager.requireMchNoByAppIdNotTenant(appId);
return payRouteStrategyCapabilitySupport.listSceneCapabilityCandidates(provider, method, product);
return payRouteStrategyCapabilitySupport.listSceneCapabilityCandidates(provider, method, channelMchNo);
}
/// 回显推断支付能力(候选唯一时返回编码)
public String inferSceneCapability(String appId, String provider, String method, String product) {
if (StrUtil.isBlank(product)) {
public String inferSceneCapability(String appId, String provider, String method, String channelMchNo) {
if (StrUtil.isBlank(channelMchNo)) {
return null;
}
String mchNo = mchAppInfoManager.requireMchNoByAppIdNotTenant(appId);
return payRouteStrategyCapabilitySupport.inferSceneCapability(provider, method, product);
return payRouteStrategyCapabilitySupport.inferSceneCapability(provider, method, channelMchNo);
}
/// 校验批量保存项:provider + method 在策略内唯一
/// 校验批量保存项:支付方式全局唯一
private void validateSceneConfigUnique(List<PayRouteSceneConfigItem> items) {
Set<String> pairKeys = new HashSet<>();
Set<String> methodKeys = new HashSet<>();
for (PayRouteSceneConfigItem item : items) {
if (StrUtil.isBlank(item.getProvider())) {
if (StrUtil.isBlank(item.getMethod())) {
continue;
}
String key = item.getProvider() + "|" + StrUtil.blankToDefault(item.getMethod(), "");
if (!pairKeys.add(key)) {
if (!methodKeys.add(item.getMethod())) {
// 场景模式下同一支付方式存在多条配置
throw new BizInfoException(CommonErrorCode.VALIDATE_PARAMETERS_ERROR,
"pay.route.error.duplicatePayProviderMethod",
PayRouteI18nHelper.provider(item.getProvider()), PayRouteI18nHelper.payMethod(item.getMethod()));
"pay.route.error.duplicateSceneMethod", PayRouteI18nHelper.payMethod(item.getMethod()));
}
}
}
/// 校验目录行:支付产品与支付能力须同时为空或同时有值,不可只填其一
private void validateSceneProductCapabilityPairing(List<PayRouteSceneConfigItem> items) {
/// 校验目录行:通道商户号与支付能力须同时为空或同时有值,不可只填其一
private void validateSceneChannelMchCapabilityPairing(List<PayRouteSceneConfigItem> items) {
for (PayRouteSceneConfigItem item : items) {
if (StrUtil.isBlank(item.getProvider())) {
if (StrUtil.isBlank(item.getMethod())) {
continue;
}
boolean hasProduct = StrUtil.isNotBlank(item.getProduct());
boolean hasCapability = StrUtil.isNotBlank(item.getCapability());
if (hasProduct == hasCapability) {
boolean hasMch = StrUtil.isNotBlank(item.getChannelMchNo());
boolean hasCap = StrUtil.isNotBlank(item.getCapability());
if (hasMch == hasCap) {
continue;
}
// 支付方式[{0}]须同时选择通道商户与支付能力,或同时留空
throw new BizInfoException(CommonErrorCode.VALIDATE_PARAMETERS_ERROR,
"pay.route.error.sceneProductCapabilityPair",
PayRouteI18nHelper.provider(item.getProvider()),
PayRouteI18nHelper.payMethod(item.getMethod()));
"pay.route.error.sceneChannelMchCapabilityPair", PayRouteI18nHelper.payMethod(item.getMethod()));
}
}
/// 目录行未配置(产品与能力均为空)
private boolean isSceneDirectoryRowEmpty(PayRouteSceneConfigItem item) {
return StrUtil.isBlank(item.getProduct()) && StrUtil.isBlank(item.getCapability());
}
/// 场景配置所选产品须在商户侧可用且支持该支付渠道
private void validateSceneMchProduct(String mchNo, String product, String providerCode) {
PayProviderEnum provider = PayProviderEnum.findByCode(providerCode);
if (provider == null) {
throw new BizInfoException(CommonErrorCode.VALIDATE_PARAMETERS_ERROR, "pay.route.error.basicProviderInvalid");
}
basicConfigService.validateBasicProduct(product, provider);
}
/// 校验通道+方式可解析为商户产品且存在对应产品策略
private void validateRouteItem(String mchNo, String channel, String method) {
PayMethodEnum.findByCode(method);
String product = productResolver.resolve(mchNo, channel, method);
if (!PaymentStrategyFactory.existsByProduct(product, AbsProductStrategy.class)) {
throw new BizInfoException(CommonErrorCode.VALIDATE_PARAMETERS_ERROR,
"pay.route.error.productStrategyMissing");
}
/// 目录行未配置(通道商户与能力均为空)
private boolean isSceneRowEmpty(PayRouteSceneConfigItem item) {
return StrUtil.isBlank(item.getChannelMchNo()) && StrUtil.isBlank(item.getCapability());
}
/// 按应用号加载路由策略,不存在则抛业务异常

View File

@@ -4,7 +4,6 @@ import cn.daxpay.open.payment.merchant.entity.route.scene.PayRouteSceneConfig;
import cn.daxpay.open.payment.merchant.service.route.model.RouteHit;
import cn.daxpay.open.payment.unipay.param.trade.pay.PayParam;
import cn.daxpay.open.platform.core.code.CommonErrorCode;
import cn.daxpay.open.platform.core.enums.pay.channel.ProductEnum;
import cn.daxpay.open.platform.core.exception.BizInfoException;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.StrUtil;
@@ -15,46 +14,32 @@ import java.util.Objects;
/// # 场景模式通道路由匹配器
///
/// 配置粒度为「支付渠道 + 支付方式 → 产品/通道」;下单须同时传 provider 与 method含 barcode 消歧)。
/// 配置粒度为「支付方式 → (通道商户, 支付能力)」,每个支付方式唯一一行;
/// 下单须传 method按 method 精确命中,命中行的 channelMchNo/capability 即路由结果。
@UtilityClass
public class PayRouteSceneMatcher {
/// 场景模式匹配:按 provider + method 精确命中唯一配置行
/// 场景模式匹配:按 method 精确命中唯一配置行
public RouteHit match(List<PayRouteSceneConfig> configs, PayParam payParam) {
if (CollUtil.isEmpty(configs)) {
return null;
}
String providerCode = providerFromProduct(payParam);
if (StrUtil.isBlank(providerCode)) {
throw new BizInfoException(CommonErrorCode.VALIDATE_PARAMETERS_ERROR, "pay.route.error.providerRequiredForScene");
}
if (StrUtil.isBlank(payParam.getMethod())) {
// 能力: 支付方式不能为空
throw new BizInfoException(CommonErrorCode.VALIDATE_PARAMETERS_ERROR,
"error.payment.capability.methodRequiredWithPayProvider");
// 场景模式下须选择支付方式
throw new BizInfoException(CommonErrorCode.VALIDATE_PARAMETERS_ERROR, "pay.route.error.sceneMethodRequired");
}
String finalProviderCode = providerCode;
String method = payParam.getMethod();
List<PayRouteSceneConfig> candidates = configs.stream()
.filter(config -> Objects.equals(config.getProvider(), finalProviderCode))
.filter(config -> Objects.equals(config.getMethod(), payParam.getMethod()))
.filter(config -> Objects.equals(config.getMethod(), method))
.toList();
if (candidates.isEmpty()) {
return null;
}
if (candidates.size() > 1) {
throw new BizInfoException(CommonErrorCode.VALIDATE_PARAMETERS_ERROR, "pay.route.error.duplicateSceneConfig");
// 场景模式下同一支付方式存在多条配置
throw new BizInfoException(CommonErrorCode.VALIDATE_PARAMETERS_ERROR, "pay.route.error.duplicateSceneMethod",
method);
}
return RouteHit.fromScene(candidates.getFirst());
}
/// 从产品编码推导 Provider临时兼容待路由重构后移除
private static String providerFromProduct(PayParam payParam) {
if (StrUtil.isNotBlank(payParam.getProduct())) {
ProductEnum product = ProductEnum.findByCode(payParam.getProduct());
if (product != null) {
return product.getChannel();
}
}
return null;
}
}

View File

@@ -1,63 +0,0 @@
package cn.daxpay.open.payment.merchant.service.route.scene;
import cn.daxpay.open.payment.common.util.PaymentStrategyFactory;
import cn.daxpay.open.payment.merchant.service.route.support.PayRouteCapabilityService;
import cn.daxpay.open.payment.merchant.service.route.support.PayRouteI18nHelper;
import cn.daxpay.open.payment.merchant.service.route.runtime.PayRouteProductResolver;
import cn.daxpay.open.payment.strategy.product.AbsProductStrategy;
import cn.daxpay.open.platform.core.code.CommonErrorCode;
import cn.daxpay.open.platform.core.enums.pay.channel.PayProviderEnum;
import cn.daxpay.open.platform.core.enums.pay.channel.ProductEnum;
import cn.daxpay.open.platform.core.exception.BizInfoException;
import cn.hutool.core.util.StrUtil;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.List;
/// # 场景模式路由解析(支付产品 + 支付方式 → 通道)
@Service
@RequiredArgsConstructor
public class PayRouteSceneRouteResolver {
private final PayRouteProductResolver productResolver;
private final PayRouteCapabilityService payRouteCapabilityService;
private final PayRouteMethodValidator payRouteMethodValidator;
/// 按产品、支付渠道与所选支付方式解析通道
public SceneRoute resolve(String product, String providerCode, String method) {
if (StrUtil.isBlank(product)) {
throw new BizInfoException(CommonErrorCode.VALIDATE_PARAMETERS_ERROR, "pay.route.error.sceneProductRequired");
}
if (StrUtil.isBlank(method)) {
throw new BizInfoException(CommonErrorCode.VALIDATE_PARAMETERS_ERROR, "pay.route.error.sceneMethodRequired");
}
PayProviderEnum provider = PayProviderEnum.findByCode(providerCode);
if (provider == null) {
throw new BizInfoException(CommonErrorCode.VALIDATE_PARAMETERS_ERROR, "pay.route.error.basicProviderInvalid");
}
ProductEnum.findByCode(product);
if (!PaymentStrategyFactory.existsByProduct(product, AbsProductStrategy.class)) {
throw new BizInfoException(CommonErrorCode.VALIDATE_PARAMETERS_ERROR,
"pay.route.error.productStrategyMissing");
}
List<String> methodCandidates = payRouteCapabilityService.methodsForProductPayProvider(product, providerCode);
if (methodCandidates.isEmpty()) {
throw new BizInfoException(CommonErrorCode.VALIDATE_PARAMETERS_ERROR,
"pay.route.error.methodNotSupportedForProduct",
PayRouteI18nHelper.product(product));
}
if (!methodCandidates.contains(method)) {
throw new BizInfoException(CommonErrorCode.VALIDATE_PARAMETERS_ERROR,
"pay.route.error.sceneMethodProductMismatch",
PayRouteI18nHelper.payMethod(method), PayRouteI18nHelper.product(product));
}
payRouteMethodValidator.validateSceneConfigItem(providerCode, method);
String channel = productResolver.channelOfProduct(product);
return new SceneRoute(channel, method, product);
}
/// 场景模式解析结果:通道、方式、产品
public record SceneRoute(String channel, String method, String product) {
}
}

View File

@@ -1,57 +0,0 @@
package cn.daxpay.open.payment.merchant.service.route.support;
import cn.daxpay.open.payment.common.util.PaymentStrategyFactory;
import cn.daxpay.open.payment.masterdata.constants.product.service.PayProductCapabilityService;
import cn.daxpay.open.payment.masterdata.constants.provider.service.PayProviderMethodService;
import cn.daxpay.open.payment.strategy.product.AbsProductStrategy;
import cn.daxpay.open.payment.old.pay.support.ProductStrategySupport;
import cn.daxpay.open.platform.core.enums.pay.channel.PayMethodEnum;
import cn.daxpay.open.platform.core.enums.pay.channel.PayProviderEnum;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.List;
/// # 通道路由支付能力
///
/// 渠道目录 × 支付产品 × 策略能力 求交与校验。
@Service
@RequiredArgsConstructor
public class PayRouteCapabilityService {
private final PayProviderMethodService payProviderMethodService;
private final PayProductCapabilityService payProductCapabilityService;
/// 目录与支付产品已挂载能力求交:指定支付产品在该支付渠道下可用的支付方式
public List<String> methodsForProductPayProvider(String product, String providerCode) {
PayProviderEnum provider = PayProviderEnum.findByCode(providerCode);
if (provider == null) {
return List.of();
}
List<String> methodCodes = new ArrayList<>();
for (PayMethodEnum directoryMethod : payProviderMethodService.listMethodsForBrand(provider)) {
String methodCode = directoryMethod.getCode();
if (productSupportsMethod(product, providerCode, methodCode)) {
methodCodes.add(methodCode);
}
}
return methodCodes;
}
/// 支付产品是否支持目录中的 provider + method 组合(策略方式→能力 Map ∧ `pay_md_product_capability`
public boolean productSupportsMethod(String product, String providerCode, String methodCode) {
if (!payProviderMethodService.contains(providerCode, methodCode)) {
return false;
}
if (!PaymentStrategyFactory.existsByProduct(product, AbsProductStrategy.class)) {
return false;
}
AbsProductStrategy strategy = PaymentStrategyFactory.createByProduct(product, AbsProductStrategy.class);
PayMethodEnum method = PayMethodEnum.findByCode(methodCode);
if (!ProductStrategySupport.supportsDirectoryMethod(strategy, method)) {
return false;
}
return payProductCapabilityService.productSupportsMethod(product, methodCode);
}
}

View File

@@ -1,20 +1,20 @@
package cn.daxpay.open.payment.merchant.service.route.support;
import cn.daxpay.open.payment.channel.dao.mch.ChannelMerchantManager;
import cn.daxpay.open.payment.channel.entity.mch.ChannelMerchant;
import cn.daxpay.open.payment.common.util.PaymentStrategyFactory;
import cn.daxpay.open.payment.merchant.param.route.scene.PayRouteSceneCapabilityBatchItem;
import cn.daxpay.open.payment.masterdata.constants.capability.dao.PayCapabilityManager;
import cn.daxpay.open.payment.masterdata.constants.capability.dao.PayProductCapabilityManager;
import cn.daxpay.open.payment.masterdata.constants.provider.dao.PayProviderMethodManager;
import cn.daxpay.open.payment.masterdata.constants.capability.entity.PayCapability;
import cn.daxpay.open.payment.masterdata.constants.product.service.PayProductCapabilityService;
import cn.daxpay.open.payment.masterdata.constants.provider.dao.PayProviderMethodManager;
import cn.daxpay.open.payment.masterdata.constants.provider.service.PayProviderMethodService;
import cn.daxpay.open.payment.strategy.product.AbsProductStrategy;
import cn.daxpay.open.payment.old.pay.support.ProductStrategySupport;
import cn.daxpay.open.payment.strategy.product.AbsProductStrategy;
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.PayCapabilityEnum;
import cn.daxpay.open.platform.core.enums.pay.channel.PayMethodEnum;
import cn.daxpay.open.platform.core.enums.pay.channel.ProductEnum;
import cn.daxpay.open.platform.core.exception.BizInfoException;
import cn.daxpay.open.platform.core.model.PayProviderMethodEntry;
import cn.daxpay.open.platform.core.rest.dto.LabelValue;
@@ -24,15 +24,16 @@ import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
/// # 通道路由:策略方式→能力与 DB 求交
/// # 通道路由:策略方式→通道商户与能力候选
///
/// 统一场景模式产品/能力候选及保存校验,数据源为 {@link AbsProductStrategy#methodCapabilityMapping()}
/// 统一场景模式通道商户/能力候选及保存校验。
/// 通道商户候选数据源为商户已开通的 {@link ChannelMerchant}(其 product 须支持对应目录支付方式)
/// 能力候选数据源为 {@link AbsProductStrategy#methodCapabilityMapping()}(由通道商户绑定的产品决定)。
@Service
@RequiredArgsConstructor
public class PayRouteStrategyCapabilitySupport {
@@ -41,23 +42,24 @@ public class PayRouteStrategyCapabilitySupport {
private final PayProductCapabilityService payProductCapabilityService;
private final PayProductCapabilityManager payProductCapabilityManager;
private final PayCapabilityManager payCapabilityManager;
private final ChannelMerchantManager channelMerchantManager;
/// 通道路由白名单目录下全部 (provider,method) 的产品候选(单次请求聚合)
public Map<String, List<LabelValue>> listSceneProductCandidatesBatch(String mchNo) {
/// 通道路由白名单目录下全部 (provider,method) 的通道商户候选(单次请求聚合)
public Map<String, List<LabelValue>> listSceneChannelMchCandidatesBatch(String mchNo) {
Map<String, List<LabelValue>> index = new LinkedHashMap<>();
// 通道商户是商户级数据,目录循环外一次性加载
List<ChannelMerchant> mchants = channelMerchantManager.findAllByMchNo(mchNo);
for (PayProviderMethodEntry entry : payProviderMethodService.listDirectoryEntries()) {
if (!PayRouteConfigProviders.contains(entry.getProviderCode())) {
continue;
}
String provider = entry.getProviderCode();
String method = entry.getMethodCode();
String key = PayProviderMethodManager.pairKey(provider, method);
index.put(key, listSceneProductCandidates(provider, method));
String key = PayProviderMethodManager.pairKey(entry.getProviderCode(), entry.getMethodCode());
index.put(key, filterChannelMchForDirectory(mchants, entry.getProviderCode(), entry.getMethodCode()));
}
return index;
}
/// 按目录项+产品批量返回支付能力候选
/// 按目录项+通道商户批量返回支付能力候选
public Map<String, List<LabelValue>> listSceneCapabilityCandidatesBatch(
String mchNo, List<PayRouteSceneCapabilityBatchItem> items) {
Map<String, List<LabelValue>> index = new LinkedHashMap<>();
@@ -65,47 +67,56 @@ public class PayRouteStrategyCapabilitySupport {
return index;
}
for (PayRouteSceneCapabilityBatchItem item : items) {
if (item == null || StrUtil.hasBlank(item.getProvider(), item.getMethod(), item.getProduct())) {
if (item == null || StrUtil.hasBlank(item.getProvider(), item.getMethod(), item.getChannelMchNo())) {
continue;
}
String key = capabilityBatchKey(item.getProvider(), item.getMethod(), item.getProduct());
index.put(key, listSceneCapabilityCandidates(
item.getProvider(), item.getMethod(), item.getProduct()));
String key = capabilityBatchKey(item.getProvider(), item.getMethod(), item.getChannelMchNo());
index.put(key, listSceneCapabilityCandidates(item.getProvider(), item.getMethod(), item.getChannelMchNo()));
}
return index;
}
/// 能力批量候选 Map 的 keyprovider|method|product
public static String capabilityBatchKey(String provider, String method, String product) {
return provider + "|" + method + "|" + product;
/// 能力批量候选 Map 的 keyprovider|method|channelMchNo
public static String capabilityBatchKey(String provider, String method, String channelMchNo) {
return provider + "|" + method + "|" + channelMchNo;
}
/// 目录项下可用支付产品候选
public List<LabelValue> listSceneProductCandidates(String provider, String method) {
/// 目录项下商户已开通且其产品支持该(provider,method)的通道商户候选
public List<LabelValue> listSceneChannelMchCandidates(String mchNo, String provider, String method) {
if (!payProviderMethodService.contains(provider, method)) {
return List.of();
}
return filterChannelMchForDirectory(channelMerchantManager.findAllByMchNo(mchNo), provider, method);
}
/// 从商户全部通道商户中筛出启用且其产品支持该(provider,method)的候选
private List<LabelValue> filterChannelMchForDirectory(List<ChannelMerchant> mchants, String provider, String method) {
PayMethodEnum methodEnum = PayMethodEnum.findByCode(method);
List<LabelValue> results = new ArrayList<>();
for (ProductEnum pe : ProductEnum.values()) {
String product = pe.getCode();
if (!routeProductSupportsMethod(product, methodEnum)) {
for (ChannelMerchant mch : mchants) {
if (!Boolean.TRUE.equals(mch.getEnable())) {
continue;
}
String label = I18nUtil.getEnumName(pe);
if (results.stream().noneMatch(item -> Objects.equals(item.getValue(), product))) {
results.add(new LabelValue(label, product));
String product = mch.getProduct();
if (!routeProductSupportsMethod(product, provider, method)) {
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;
}
/// 指定产品+目录项下支付能力候选(策略 Map ∩ DB ∩ 能力主数据启用)
public List<LabelValue> listSceneCapabilityCandidates(String provider, String method, String product) {
/// 指定通道商户+目录项下支付能力候选(策略 Map ∩ DB ∩ 能力主数据启用)
public List<LabelValue> listSceneCapabilityCandidates(String provider, String method, String channelMchNo) {
if (!payProviderMethodService.contains(provider, method)) {
return List.of();
}
if (!PaymentStrategyFactory.existsByProduct(product, AbsProductStrategy.class)) {
String product = productOfChannelMchNo(channelMchNo);
if (StrUtil.isBlank(product) || !PaymentStrategyFactory.existsByProduct(product, AbsProductStrategy.class)) {
return List.of();
}
AbsProductStrategy strategy = PaymentStrategyFactory.createByProduct(product, AbsProductStrategy.class);
@@ -117,8 +128,8 @@ public class PayRouteStrategyCapabilitySupport {
}
/// 候选唯一时返回能力编码(仅供回显)
public String inferSceneCapability(String provider, String method, String product) {
List<LabelValue> candidates = listSceneCapabilityCandidates(provider, method, product);
public String inferSceneCapability(String provider, String method, String channelMchNo) {
List<LabelValue> candidates = listSceneCapabilityCandidates(provider, method, channelMchNo);
if (candidates.size() == 1) {
return candidates.getFirst().getValue();
}
@@ -126,21 +137,21 @@ public class PayRouteStrategyCapabilitySupport {
}
/// 校验场景配置项所选能力在候选集合内
public void validateSceneCapability(String provider, String method, String product, String capability) {
public void validateSceneCapability(String provider, String method, String channelMchNo, String capability) {
if (StrUtil.isBlank(capability)) {
// 场景模式下须选择支付能力
throw new BizInfoException(CommonErrorCode.VALIDATE_PARAMETERS_ERROR,
"pay.route.error.sceneCapabilityRequired");
}
boolean matched = listSceneCapabilityCandidates(provider, method, product).stream()
boolean matched = listSceneCapabilityCandidates(provider, method, channelMchNo).stream()
.anyMatch(item -> Objects.equals(item.getValue(), capability));
if (!matched) {
PayCapabilityEnum capabilityEnum = PayCapabilityEnum.findByCode(capability);
String capabilityLabel = capabilityEnum != null ? I18nUtil.getEnumName(capabilityEnum) : capability;
// 支付能力[{0}]与通道商户[{1}]、支付方式[{2}]不匹配
throw new BizInfoException(CommonErrorCode.VALIDATE_PARAMETERS_ERROR,
"pay.route.error.sceneCapabilityProductMismatch",
capabilityLabel,
PayRouteI18nHelper.product(product),
PayRouteI18nHelper.payMethod(method));
"pay.route.error.sceneCapabilityChannelMchMismatch",
capabilityLabel, channelMchNo, method);
}
}
@@ -164,10 +175,19 @@ public class PayRouteStrategyCapabilitySupport {
return routeProductSupportsMethod(product, PayMethodEnum.findByCode(methodCode));
}
/// 通道商户号→产品编码(不存在返回 null)
private String productOfChannelMchNo(String channelMchNo) {
return channelMerchantManager.lambdaQuery()
.eq(ChannelMerchant::getChannelMchNo, channelMchNo)
.oneOpt()
.map(ChannelMerchant::getProduct)
.orElse(null);
}
private boolean productCapabilityEnabled(String productCode, String capabilityCode) {
if (!payProductCapabilityManager.exists(productCode, capabilityCode)) {
return false;
}
return payCapabilityManager.findByCode(capabilityCode).isPresent();
}
}
}

View File

@@ -31,5 +31,12 @@
"routeStrategyNotExist": "Route strategy not found",
"routeModeNotExist": "Route mode not found: {0}",
"businessSceneNotExist": "Business scene not found: {0}",
"terminalSceneNotExist": "Terminal scene not found: {0}"
"terminalSceneNotExist": "Terminal scene not found: {0}",
"channelMchNoRequired": "Channel merchant no cannot be blank",
"channelMchNotExist": "Channel merchant [{0}] not found",
"channelMchDisabled": "Channel merchant [{0}] is disabled",
"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}]"
}

View File

@@ -75,7 +75,8 @@
"notNull": "Application ID cannot be null"
},
"channelMchNo": {
"size": "Channel merchant no. cannot exceed 32 characters"
"size": "Channel merchant no cannot exceed 32 characters",
"notBlank": "Channel merchant no cannot be blank"
},
"subMchId": {
"notBlank": "WeChat sub-merchant ID cannot be blank"
@@ -181,6 +182,9 @@
"notBlank": "Payment method cannot be blank",
"size": "Payment method cannot exceed 32 characters"
},
"capability": {
"size": "Payment capability cannot exceed 32 characters"
},
"provider": {
"notBlank": "Pay brand must not be blank",
"size": "Pay brand code must not exceed 32 characters"

View File

@@ -31,5 +31,12 @@
"routeStrategyNotExist": "路由策略不存在",
"routeModeNotExist": "路由模式不存在: {0}",
"businessSceneNotExist": "业务场景不存在: {0}",
"terminalSceneNotExist": "终端场景不存在: {0}"
"terminalSceneNotExist": "终端场景不存在: {0}",
"channelMchNoRequired": "通道商户号不能为空",
"channelMchNotExist": "通道商户号[{0}]不存在",
"channelMchDisabled": "通道商户号[{0}]未启用",
"duplicateSceneMethod": "场景模式下同一支付方式[{0}]存在多条配置",
"sceneChannelMchCapabilityPair": "支付方式[{0}]须同时选择通道商户与支付能力,或同时留空",
"basicChannelMchNotConfigured": "支付渠道[{0}]未配置通道商户,请在通道路由基础模式中完成配置",
"sceneCapabilityChannelMchMismatch": "支付能力[{0}]与通道商户[{1}]、支付方式[{2}]不匹配"
}

View File

@@ -75,7 +75,8 @@
"notNull": "应用ID不可为空"
},
"channelMchNo": {
"size": "通道商户号不可超过32位"
"size": "通道商户号不可超过32位",
"notBlank": "通道商户号不可为空"
},
"subMchId": {
"notBlank": "微信子商户号不可为空"
@@ -181,6 +182,9 @@
"notBlank": "支付方式不可为空",
"size": "支付方式编码不可超过32位"
},
"capability": {
"size": "支付能力编码不可超过32位"
},
"provider": {
"notBlank": "支付渠道不可为空",
"size": "支付渠道编码不可超过32位"

View File

@@ -11,87 +11,91 @@ import java.util.Objects;
/// # 支付方式
///
/// 字典: pay_method`code` 全局唯一,且仅绑定一个支付渠道。
/// `provider` 显式声明每个支付方式所属的支付渠道,供通道路由零开销解析(替代旧版从支付产品反推)。
@Getter
@RequiredArgsConstructor
public enum PayMethodEnum implements I18nSupport {
/* 聚合支付 */
/// 聚合扫码支付
AGGREGATE_PAY_QRCODE("aggregate_pay_qrcode"),
AGGREGATE_PAY_QRCODE("aggregate_pay_qrcode", PayProviderEnum.AGGREGATE_PAY),
/// 聚合付款码支付
AGGREGATE_PAY_BARCODE("aggregate_pay_barcode"),
AGGREGATE_PAY_BARCODE("aggregate_pay_barcode", PayProviderEnum.AGGREGATE_PAY),
/* 微信 */
/// 微信小程序收银台
WECHAT_CASHIER("wechat_cashier"),
WECHAT_CASHIER("wechat_cashier", PayProviderEnum.WECHAT),
/// 微信扫码
WECHAT_QR("wechat_qr"),
WECHAT_QR("wechat_qr", PayProviderEnum.WECHAT),
/// 微信jsapi
WECHAT_JSAPI("wechat_jsapi"),
WECHAT_JSAPI("wechat_jsapi", PayProviderEnum.WECHAT),
/// 微信小程序
WECHAT_MINI("wechat_mini"),
WECHAT_MINI("wechat_mini", PayProviderEnum.WECHAT),
/// 微信H5
WECHAT_H5("wechat_h5"),
WECHAT_H5("wechat_h5", PayProviderEnum.WECHAT),
/// 微信应用支付
WECHAT_APP("wechat_app"),
WECHAT_APP("wechat_app", PayProviderEnum.WECHAT),
/// 微信付款码
WECHAT_BARCODE("wechat_barcode"),
WECHAT_BARCODE("wechat_barcode", PayProviderEnum.WECHAT),
/* 支付宝 */
/// 支付宝扫码
ALIPAY_QR("alipay_qr"),
ALIPAY_QR("alipay_qr", PayProviderEnum.ALIPAY),
/// 支付宝订单码
ALIPAY_ORDER_QR("alipay_order_qr"),
ALIPAY_ORDER_QR("alipay_order_qr", PayProviderEnum.ALIPAY),
/// 支付宝jsapi
ALIPAY_JSAPI("alipay_jsapi"),
ALIPAY_JSAPI("alipay_jsapi", PayProviderEnum.ALIPAY),
/// 支付宝小程序
ALIPAY_MINI("alipay_mini"),
ALIPAY_MINI("alipay_mini", PayProviderEnum.ALIPAY),
/// 支付宝电脑支付
ALIPAY_PC("alipay_pc"),
ALIPAY_PC("alipay_pc", PayProviderEnum.ALIPAY),
/// 支付宝H5
ALIPAY_H5("alipay_h5"),
ALIPAY_H5("alipay_h5", PayProviderEnum.ALIPAY),
/// 支付宝应用支付
ALIPAY_APP("alipay_app"),
ALIPAY_APP("alipay_app", PayProviderEnum.ALIPAY),
/// 支付宝付款码
ALIPAY_BARCODE("alipay_barcode"),
ALIPAY_BARCODE("alipay_barcode", PayProviderEnum.ALIPAY),
/* 银联 */
/// 银联扫码
UNION_QR("union_qr"),
UNION_QR("union_qr", PayProviderEnum.UNION_PAY),
/// 银联jsapi
UNION_JSAPI("union_jsapi"),
UNION_JSAPI("union_jsapi", PayProviderEnum.UNION_PAY),
/// 银联H5
UNION_H5("union_h5"),
UNION_H5("union_h5", PayProviderEnum.UNION_PAY),
/// 银联付款码
UNION_PAY_BARCODE("union_pay_barcode"),
UNION_PAY_BARCODE("union_pay_barcode", PayProviderEnum.UNION_PAY),
/* 抖音 */
/// 抖音扫码支付
DOUYIN_QR("douyin_qr"),
DOUYIN_QR("douyin_qr", PayProviderEnum.DOUYIN),
/// 抖音JSAPI支付
DOUYIN_JSAPI("douyin_jsapi"),
DOUYIN_JSAPI("douyin_jsapi", PayProviderEnum.DOUYIN),
/// 抖音H5支付
DOUYIN_H5("douyin_h5"),
DOUYIN_H5("douyin_h5", PayProviderEnum.DOUYIN),
/// 抖音APP支付
DOUYIN_APP("douyin_app"),
DOUYIN_APP("douyin_app", PayProviderEnum.DOUYIN),
/* 卡组 */
/// Visa 网关支付
VISA_CARD_GATEWAY("visa_card_gateway"),
VISA_CARD_GATEWAY("visa_card_gateway", PayProviderEnum.VISA),
/// Visa 刷卡支付
VISA_CARD_PRESENT("visa_card_present"),
VISA_CARD_PRESENT("visa_card_present", PayProviderEnum.VISA),
/// 万事达网关支付
MASTERCARD_CARD_GATEWAY("mastercard_card_gateway"),
MASTERCARD_CARD_GATEWAY("mastercard_card_gateway", PayProviderEnum.MASTERCARD),
/// 万事达刷卡支付
MASTERCARD_CARD_PRESENT("mastercard_card_present"),
MASTERCARD_CARD_PRESENT("mastercard_card_present", PayProviderEnum.MASTERCARD),
/// 其他支付方式
OTHER("other"),
/// 其他支付方式(无固定渠道归属)
OTHER("other", null),
;
/// 编码
private final String code;
/// 所属支付渠道(OTHER 等无固定归属时为 null)
private final PayProviderEnum provider;
/// 翻译 key 前缀
@Override
public String getI18nPrefix() {