feat(channel): 微信/抖音直连商户应用支付能力绑定

- 微信直连(商户级)+服务商(全局级)能力绑定: entity/dao/convert/param/result/service/controller 全套

- 抖音直连(商户级)能力绑定: 同上(抖音仅直连模式)

- resolveApp: 显式配置 > appType推导 > 通道商户首个兜底

- 微信直连应用补全 appType 字段+枚举, 应用删除级联清理关联

- SQL: wechat_isv/wechat_direct/douyin_direct_app_capability 三表

- i18n: error/enum 中英文同步
This commit is contained in:
DaxPay Dev
2026-07-02 03:55:59 +08:00
parent 8aa74aa91c
commit 4bb5efdc71
53 changed files with 1675 additions and 14 deletions

View File

@@ -1,7 +1,98 @@
-- 支付同步: pay_normal_order 冗余通道路由参数, 供同步时凭证解析使用
-- ============================================================
-- 升级建表脚本
-- ============================================================
ALTER TABLE pay_normal_order ADD COLUMN IF NOT EXISTS channel_mch_no varchar(64);
ALTER TABLE pay_normal_order ADD COLUMN IF NOT EXISTS capability varchar(64);
COMMENT ON COLUMN pay_normal_order.channel_mch_no IS '通道商户号(路由回填, 同步时用于解析通道应用凭证)';
COMMENT ON COLUMN pay_normal_order.capability IS '支付能力编码(路由回填, 同步时用于解析通道应用凭证)';
-- ------------------------------------------------------------
-- 微信服务商应用支付能力关联(全局维度,一个能力绑定一个服务商应用)
-- ------------------------------------------------------------
CREATE TABLE IF NOT EXISTS wechat_isv_app_capability (
id bigserial PRIMARY KEY,
capability varchar(64) NOT NULL,
wechat_isv_app_id bigint NOT NULL,
creator bigint,
create_time timestamptz(6) DEFAULT now(),
last_modifier bigint,
last_modified_time timestamptz(6),
version integer NOT NULL DEFAULT 0,
deleted boolean NOT NULL DEFAULT false,
CONSTRAINT uk_wechat_isv_app_capability UNIQUE (capability)
);
COMMENT ON TABLE wechat_isv_app_capability IS '微信服务商应用支付能力关联';
COMMENT ON COLUMN wechat_isv_app_capability.id IS '主键';
COMMENT ON COLUMN wechat_isv_app_capability.capability IS '支付能力编码';
COMMENT ON COLUMN wechat_isv_app_capability.wechat_isv_app_id IS '关联微信服务商应用ID';
COMMENT ON COLUMN wechat_isv_app_capability.creator IS '创建人';
COMMENT ON COLUMN wechat_isv_app_capability.create_time IS '创建时间';
COMMENT ON COLUMN wechat_isv_app_capability.last_modifier IS '最后修改人';
COMMENT ON COLUMN wechat_isv_app_capability.last_modified_time IS '最后修改时间';
COMMENT ON COLUMN wechat_isv_app_capability.version IS '乐观锁版本号';
COMMENT ON COLUMN wechat_isv_app_capability.deleted IS '逻辑删除标志';
-- ------------------------------------------------------------
-- 微信直连商户应用支付能力关联(商户维度,同一通道商户下一个能力绑定一个应用)
-- ------------------------------------------------------------
CREATE TABLE IF NOT EXISTS wechat_direct_app_capability (
id bigserial PRIMARY KEY,
mch_no varchar(32) NOT NULL,
channel_mch_no varchar(32) NOT NULL,
capability varchar(64) NOT NULL,
wechat_direct_app_id bigint NOT NULL,
creator bigint,
create_time timestamptz(6) DEFAULT now(),
last_modifier bigint,
last_modified_time timestamptz(6),
version integer NOT NULL DEFAULT 0,
deleted boolean NOT NULL DEFAULT false,
CONSTRAINT uk_wechat_direct_app_capability UNIQUE (channel_mch_no, capability)
);
COMMENT ON TABLE wechat_direct_app_capability IS '微信直连商户应用支付能力关联';
COMMENT ON COLUMN wechat_direct_app_capability.id IS '主键';
COMMENT ON COLUMN wechat_direct_app_capability.mch_no IS '商户号';
COMMENT ON COLUMN wechat_direct_app_capability.channel_mch_no IS '通道商户号';
COMMENT ON COLUMN wechat_direct_app_capability.capability IS '支付能力编码';
COMMENT ON COLUMN wechat_direct_app_capability.wechat_direct_app_id IS '关联微信直连应用ID';
COMMENT ON COLUMN wechat_direct_app_capability.creator IS '创建人';
COMMENT ON COLUMN wechat_direct_app_capability.create_time IS '创建时间';
COMMENT ON COLUMN wechat_direct_app_capability.last_modifier IS '最后修改人';
COMMENT ON COLUMN wechat_direct_app_capability.last_modified_time IS '最后修改时间';
COMMENT ON COLUMN wechat_direct_app_capability.version IS '乐观锁版本号';
COMMENT ON COLUMN wechat_direct_app_capability.deleted IS '逻辑删除标志';
-- ------------------------------------------------------------
-- 抖音直连商户应用支付能力关联
-- ------------------------------------------------------------
CREATE TABLE IF NOT EXISTS douyin_direct_app_capability (
id bigserial PRIMARY KEY,
mch_no varchar(32) NOT NULL,
channel_mch_no varchar(32) NOT NULL,
capability varchar(64) NOT NULL,
douyin_direct_app_id bigint NOT NULL,
creator bigint,
create_time timestamptz(6) DEFAULT now(),
last_modifier bigint,
last_modified_time timestamptz(6),
version integer NOT NULL DEFAULT 0,
deleted boolean NOT NULL DEFAULT false,
CONSTRAINT uk_douyin_direct_app_capability UNIQUE (channel_mch_no, capability)
);
COMMENT ON TABLE douyin_direct_app_capability IS '抖音直连商户应用支付能力关联';
COMMENT ON COLUMN douyin_direct_app_capability.id IS '主键';
COMMENT ON COLUMN douyin_direct_app_capability.mch_no IS '商户号';
COMMENT ON COLUMN douyin_direct_app_capability.channel_mch_no IS '通道商户号';
COMMENT ON COLUMN douyin_direct_app_capability.capability IS '支付能力编码';
COMMENT ON COLUMN douyin_direct_app_capability.douyin_direct_app_id IS '关联抖音直连应用ID';
COMMENT ON COLUMN douyin_direct_app_capability.creator IS '创建人';
COMMENT ON COLUMN douyin_direct_app_capability.create_time IS '创建时间';
COMMENT ON COLUMN douyin_direct_app_capability.last_modifier IS '最后修改人';
COMMENT ON COLUMN douyin_direct_app_capability.last_modified_time IS '最后修改时间';
COMMENT ON COLUMN douyin_direct_app_capability.version IS '乐观锁版本号';
COMMENT ON COLUMN douyin_direct_app_capability.deleted IS '逻辑删除标志';

View File

@@ -0,0 +1,47 @@
package cn.daxpay.open.channel.douyin.code;
import cn.daxpay.open.platform.core.enums.pay.channel.PayCapabilityEnum;
import java.util.Map;
/// # 抖音应用类型与支付能力映射
///
/// 定义抖音直连商户应用([cn.daxpay.open.channel.douyin.entity.direct.DouyinDirectApp].appType)的常量,
/// 以及支付能力(PayCapabilityEnum)到应用类型的默认推导关系。
///
/// 当未显式配置「能力→应用」关联时,[cn.daxpay.open.channel.douyin.service.direct.DouyinDirectAppCapabilityService]
/// 会依据本映射按能力自动推导出合适的 appType进而在应用列表中匹配对应应用。
///
public final class DouyinAppTypeCode {
private DouyinAppTypeCode() {
}
/// 应用类型: 小程序
public static final String MINI_PROGRAM = "mini_program";
/// 应用类型: 移动应用
public static final String MOBILE_APP = "mobile_app";
/// 应用类型: 网站应用
public static final String WEB_APP = "web_app";
/// 支付能力 → 应用类型的默认推导映射
///
/// - 小程序支付 → 小程序
/// - APP支付 → 移动应用
/// - 扫码/H5 → 网站应用
private static final Map<String, String> CAPABILITY_APP_TYPE_MAP = Map.of(
PayCapabilityEnum.DOUYIN_JSAPI.getCode(), MINI_PROGRAM,
PayCapabilityEnum.DOUYIN_APP.getCode(), MOBILE_APP,
PayCapabilityEnum.DOUYIN_QR.getCode(), WEB_APP,
PayCapabilityEnum.DOUYIN_H5.getCode(), WEB_APP);
/// 根据支付能力编码推导默认应用类型;未知能力返回 null
public static String resolveAppType(String capabilityCode) {
if (capabilityCode == null) {
return null;
}
return CAPABILITY_APP_TYPE_MAP.get(capabilityCode);
}
}

View File

@@ -0,0 +1,29 @@
package cn.daxpay.open.channel.douyin.code;
import cn.daxpay.open.platform.core.i18n.I18nSupport;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
/// # 抖音直连商户应用类型
///
/// 定义抖音直连商户应用([cn.daxpay.open.channel.douyin.entity.direct.DouyinDirectApp].appType)的取值范围。
///
@Getter
@RequiredArgsConstructor
public enum DouyinDirectAppTypeEnum implements I18nSupport {
/// 小程序
MINI_PROGRAM("mini_program"),
/// 移动应用
MOBILE_APP("mobile_app"),
/// 网站应用
WEB_APP("web_app"),
;
private final String code;
/// 翻译 key 前缀
@Override
public String getI18nPrefix() {
return "enum.douyin_direct_app_type";
}
}

View File

@@ -0,0 +1,60 @@
package cn.daxpay.open.channel.douyin.controller.direct;
import cn.daxpay.open.channel.douyin.param.direct.DouyinDirectAppCapabilityBatchParam;
import cn.daxpay.open.channel.douyin.result.DouyinCapabilityOption;
import cn.daxpay.open.channel.douyin.result.direct.DouyinDirectAppCapabilityResult;
import cn.daxpay.open.channel.douyin.service.direct.DouyinDirectAppCapabilityService;
import cn.daxpay.open.platform.core.annotation.PermCode;
import cn.daxpay.open.platform.core.rest.Res;
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.validation.annotation.Validated;
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 = "channel:douyin:app")
@Validated
@Tag(name = "抖音直连商户应用支付能力关联管理")
@RestController
@RequestMapping("/admin/douyin/mch-app/capability")
@RequiredArgsConstructor
public class DouyinDirectAppCapabilityController {
private final DouyinDirectAppCapabilityService douyinDirectAppCapabilityService;
@PermCode(code = "view", nameCn = "通道商户查看", nameEn = "Channel Merchant View")
@Operation(summary = "查询通道商户的能力应用关联列表")
@GetMapping("/list-by-channel-mch-no")
public Result<List<DouyinDirectAppCapabilityResult>> listByChannelMchNo(
@NotBlank(message = "{validation.field.mchNo.notBlank}") String mchNo,
@NotBlank(message = "{validation.field.channelMerchantNo.notBlank}") String channelMchNo) {
return Res.ok(douyinDirectAppCapabilityService.listByChannelMchNo(mchNo, channelMchNo));
}
@PermCode(code = "manage", nameCn = "通道商户管理", nameEn = "Channel Merchant Manage")
@Operation(summary = "全量保存能力应用关联")
@PostMapping("/save-batch")
public Result<Void> saveBatch(@RequestBody @Validated DouyinDirectAppCapabilityBatchParam param) {
douyinDirectAppCapabilityService.saveBatch(param);
return Res.ok();
}
@PermCode(code = "view", nameCn = "通道商户查看", nameEn = "Channel Merchant View")
@Operation(summary = "查询抖音直连支持的支付能力候选")
@GetMapping("/list-supported-capabilities")
public Result<List<DouyinCapabilityOption>> listSupportedCapabilities() {
return Res.ok(douyinDirectAppCapabilityService.listSupportedCapabilities());
}
}

View File

@@ -0,0 +1,19 @@
package cn.daxpay.open.channel.douyin.convert.direct;
import cn.daxpay.open.channel.douyin.entity.direct.DouyinDirectAppCapability;
import cn.daxpay.open.channel.douyin.result.direct.DouyinDirectAppCapabilityResult;
import org.mapstruct.Mapper;
import org.mapstruct.factory.Mappers;
/// # 抖音直连商户应用支付能力关联转换
///
/// MapStruct转换器负责关联记录在实体与返回结果之间的转换。
///
@Mapper
public interface DouyinDirectAppCapabilityConvert {
DouyinDirectAppCapabilityConvert CONVERT = Mappers.getMapper(DouyinDirectAppCapabilityConvert.class);
/// 转换为返回对象
DouyinDirectAppCapabilityResult toResult(DouyinDirectAppCapability entity);
}

View File

@@ -0,0 +1,51 @@
package cn.daxpay.open.channel.douyin.dao.direct;
import cn.daxpay.open.channel.douyin.entity.direct.DouyinDirectAppCapability;
import cn.daxpay.open.platform.common.mybatisplus.impl.BaseManager;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Optional;
/// # 抖音直连商户应用支付能力关联 Manager
///
/// 关联记录数据访问管理器,提供按通道商户号查询/删除、按能力查应用、按应用清理等方法。
///
@Slf4j
@Service
@RequiredArgsConstructor
public class DouyinDirectAppCapabilityManager extends BaseManager<DouyinDirectAppCapabilityMapper, DouyinDirectAppCapability> {
/// 根据通道商户号查询全部关联(按创建时间升序)
public List<DouyinDirectAppCapability> listByChannelMchNo(String channelMchNo) {
return lambdaQuery()
.eq(DouyinDirectAppCapability::getChannelMchNo, channelMchNo)
.orderByAsc(DouyinDirectAppCapability::getCreateTime)
.orderByAsc(DouyinDirectAppCapability::getId)
.list();
}
/// 根据通道商户号与支付能力查询单条关联(支付时调用)
public Optional<DouyinDirectAppCapability> findOne(String channelMchNo, String capability) {
return lambdaQuery()
.eq(DouyinDirectAppCapability::getChannelMchNo, channelMchNo)
.eq(DouyinDirectAppCapability::getCapability, capability)
.oneOpt();
}
/// 根据通道商户号删除全部关联(批量保存时先清后插)
public void deleteByChannelMchNo(String channelMchNo) {
lambdaUpdate()
.eq(DouyinDirectAppCapability::getChannelMchNo, channelMchNo)
.remove();
}
/// 根据应用ID删除关联(应用被删除时级联清理,避免悬空引用)
public void deleteByDouyinDirectAppId(Long douyinDirectAppId) {
lambdaUpdate()
.eq(DouyinDirectAppCapability::getDouyinDirectAppId, douyinDirectAppId)
.remove();
}
}

View File

@@ -0,0 +1,13 @@
package cn.daxpay.open.channel.douyin.dao.direct;
import cn.daxpay.open.channel.douyin.entity.direct.DouyinDirectAppCapability;
import com.github.yulichang.base.MPJBaseMapper;
import org.apache.ibatis.annotations.Mapper;
/// # 抖音直连商户应用支付能力关联 Mapper
///
/// 继承 MPJBaseMapper 支持多表联查。
///
@Mapper
public interface DouyinDirectAppCapabilityMapper extends MPJBaseMapper<DouyinDirectAppCapability> {
}

View File

@@ -5,6 +5,7 @@ import cn.daxpay.open.platform.common.mybatisplus.impl.BaseManager;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
/// # 抖音直连商户应用
///
@@ -32,4 +33,25 @@ public class DouyinDirectAppManager extends BaseManager<DouyinDirectAppMapper, D
.ne(excludeId != null, DouyinDirectApp::getId, excludeId)
.exists();
}
/// 根据通道商户号取首个应用(支付能力推导兜底)
public Optional<DouyinDirectApp> findFirstByChannelMchNo(String channelMchNo) {
return lambdaQuery()
.eq(DouyinDirectApp::getChannelMchNo, channelMchNo)
.orderByAsc(DouyinDirectApp::getCreateTime)
.orderByAsc(DouyinDirectApp::getId)
.last("limit 1")
.oneOpt();
}
/// 根据通道商户号与应用类型取首个应用(appType自动推导时调用)
public Optional<DouyinDirectApp> findFirstByChannelMchNoAndAppType(String channelMchNo, String appType) {
return lambdaQuery()
.eq(DouyinDirectApp::getChannelMchNo, channelMchNo)
.eq(DouyinDirectApp::getAppType, appType)
.orderByAsc(DouyinDirectApp::getCreateTime)
.orderByAsc(DouyinDirectApp::getId)
.last("limit 1")
.oneOpt();
}
}

View File

@@ -0,0 +1,43 @@
package cn.daxpay.open.channel.douyin.entity.direct;
import cn.daxpay.open.channel.douyin.convert.direct.DouyinDirectAppCapabilityConvert;
import cn.daxpay.open.channel.douyin.result.direct.DouyinDirectAppCapabilityResult;
import cn.daxpay.open.payment.common.entity.merchant.MchBaseEntity;
import cn.daxpay.open.platform.common.mybatisplus.function.ToResult;
import com.baomidou.mybatisplus.annotation.FieldStrategy;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.experimental.Accessors;
/// # 抖音直连商户应用支付能力关联
///
/// 建立通道商户维度下「支付能力 → 直连应用」的绑定关系,供支付时选择正确的抖音应用(AppId)。
/// 同一通道商户下,一个支付能力只能绑定一个应用(由唯一约束 channel_mch_no + capability 保证)。
///
@EqualsAndHashCode(callSuper = true)
@Data
@Accessors(chain = true)
@TableName(value = "douyin_direct_app_capability", autoResultMap = true)
public class DouyinDirectAppCapability extends MchBaseEntity implements ToResult<DouyinDirectAppCapabilityResult> {
/// 通道商户号
@TableField(updateStrategy = FieldStrategy.NEVER)
private String channelMchNo;
/// 支付能力编码
/// @see cn.daxpay.open.platform.core.enums.pay.channel.PayCapabilityEnum
@TableField(updateStrategy = FieldStrategy.NEVER)
private String capability;
/// 关联抖音直连应用ID(指向 douyin_direct_app.id)
@TableField(updateStrategy = FieldStrategy.NEVER)
private Long douyinDirectAppId;
/// 转换
@Override
public DouyinDirectAppCapabilityResult toResult() {
return DouyinDirectAppCapabilityConvert.CONVERT.toResult(this);
}
}

View File

@@ -0,0 +1,32 @@
package cn.daxpay.open.channel.douyin.param.direct;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
import lombok.experimental.Accessors;
import java.util.List;
/// # 抖音直连支付能力关联应用批量保存参数
///
/// 全量覆盖某通道商户下「支付能力 → 直连应用」的绑定关系:先清除旧记录,再按 items 批量插入。
/// items 为空表示清空该通道商户下所有绑定。
///
@Data
@Accessors(chain = true)
@Schema(title = "抖音直连支付能力关联应用批量保存参数")
public class DouyinDirectAppCapabilityBatchParam {
@NotBlank(message = "{validation.field.mchNo.notBlank}")
@Schema(description = "商户号")
private String mchNo;
@NotBlank(message = "{validation.field.channelMchNo.notBlank}")
@Schema(description = "通道商户号")
private String channelMchNo;
@Valid
@Schema(description = "支付能力关联应用列表")
private List<DouyinDirectAppCapabilityItem> items;
}

View File

@@ -0,0 +1,25 @@
package cn.daxpay.open.channel.douyin.param.direct;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import lombok.experimental.Accessors;
/// # 抖音直连支付能力关联应用单项
///
/// 用于批量保存时,声明单个支付能力绑定的直连应用。
///
@Data
@Accessors(chain = true)
@Schema(title = "抖音直连支付能力关联应用单项")
public class DouyinDirectAppCapabilityItem {
@NotBlank(message = "{validation.field.capability.notBlank}")
@Schema(description = "支付能力编码")
private String capability;
@NotNull(message = "{validation.field.douyinDirectAppId.notNull}")
@Schema(description = "关联抖音直连应用ID")
private Long douyinDirectAppId;
}

View File

@@ -0,0 +1,8 @@
package cn.daxpay.open.channel.douyin.result;
/// # 抖音支付能力候选项
///
/// 用于展示抖音直连产品支持的支付能力编码及其国际化名称。
///
public record DouyinCapabilityOption(String code, String name) {
}

View File

@@ -0,0 +1,36 @@
package cn.daxpay.open.channel.douyin.result.direct;
import cn.daxpay.open.payment.common.result.MchBaseResult;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.experimental.Accessors;
/// # 抖音直连商户应用支付能力关联
///
/// 展示通道商户下各支付能力绑定的直连应用信息,含应用名称/AppId/类型等冗余字段(由 Service 填充)。
///
@EqualsAndHashCode(callSuper = true)
@Data
@Accessors(chain = true)
@Schema(title = "抖音直连商户应用支付能力关联")
public class DouyinDirectAppCapabilityResult extends MchBaseResult {
@Schema(description = "通道商户号")
private String channelMchNo;
@Schema(description = "支付能力编码")
private String capability;
@Schema(description = "关联抖音直连应用ID")
private Long douyinDirectAppId;
@Schema(description = "应用名称(冗余展示)")
private String appName;
@Schema(description = "抖音应用AppId(冗余展示)")
private String douyinAppId;
@Schema(description = "应用类型(冗余展示): mini_program/mobile_app/web_app")
private String appType;
}

View File

@@ -0,0 +1,151 @@
package cn.daxpay.open.channel.douyin.service.direct;
import cn.daxpay.open.channel.douyin.code.DouyinAppTypeCode;
import cn.daxpay.open.channel.douyin.convert.direct.DouyinDirectAppCapabilityConvert;
import cn.daxpay.open.channel.douyin.dao.direct.DouyinDirectAppCapabilityManager;
import cn.daxpay.open.channel.douyin.dao.direct.DouyinDirectAppManager;
import cn.daxpay.open.channel.douyin.entity.direct.DouyinDirectApp;
import cn.daxpay.open.channel.douyin.entity.direct.DouyinDirectAppCapability;
import cn.daxpay.open.channel.douyin.param.direct.DouyinDirectAppCapabilityBatchParam;
import cn.daxpay.open.channel.douyin.param.direct.DouyinDirectAppCapabilityItem;
import cn.daxpay.open.channel.douyin.result.DouyinCapabilityOption;
import cn.daxpay.open.channel.douyin.result.direct.DouyinDirectAppCapabilityResult;
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.ProductEnum;
import cn.daxpay.open.platform.core.exception.BizInfoException;
import cn.daxpay.open.payment.common.util.PaymentStrategyFactory;
import cn.daxpay.open.payment.strategy.product.AbsProductStrategy;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.StrUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.function.Function;
import java.util.stream.Collectors;
/// # 抖音直连商户应用支付能力关联
///
/// 管理通道商户维度下「支付能力 → 直连应用」的绑定关系。
/// 支付时通过 [resolveApp] 解析当前能力对应的应用:显式配置 > appType自动推导 > 通道商户首个兜底。
///
@Slf4j
@Service
@RequiredArgsConstructor
public class DouyinDirectAppCapabilityService {
private final DouyinDirectAppCapabilityManager capabilityManager;
private final DouyinDirectAppManager douyinDirectAppManager;
/// 查询通道商户下的能力关联列表,并填充应用展示信息(应用名称/AppId/类型)
public List<DouyinDirectAppCapabilityResult> listByChannelMchNo(String mchNo, String channelMchNo) {
List<DouyinDirectAppCapability> rels = capabilityManager.listByChannelMchNo(channelMchNo);
if (rels.isEmpty()) {
return List.of();
}
// 批量查询该通道商户下全部应用,构建 id → app 映射用于填充展示字段
Map<Long, DouyinDirectApp> appMap = douyinDirectAppManager.listByMchNoAndChannelMchNo(mchNo, channelMchNo)
.stream()
.collect(Collectors.toMap(DouyinDirectApp::getId, Function.identity()));
return rels.stream()
.map(rel -> fillResult(rel, appMap.get(rel.getDouyinDirectAppId())))
.toList();
}
/// 全量保存能力关联(先清后插),校验应用归属与能力唯一
@Transactional(rollbackFor = Exception.class)
public void saveBatch(DouyinDirectAppCapabilityBatchParam param) {
String mchNo = param.getMchNo();
String channelMchNo = param.getChannelMchNo();
List<DouyinDirectAppCapabilityItem> items = param.getItems();
// 先清空旧关联
capabilityManager.deleteByChannelMchNo(channelMchNo);
if (CollUtil.isEmpty(items)) {
return;
}
// 查询该通道商户下全部应用,校验应用归属
List<DouyinDirectApp> apps = douyinDirectAppManager.listByMchNoAndChannelMchNo(mchNo, channelMchNo);
Map<Long, DouyinDirectApp> appMap = apps.stream()
.collect(Collectors.toMap(DouyinDirectApp::getId, Function.identity()));
HashSet<String> capabilitySet = new HashSet<>();
for (DouyinDirectAppCapabilityItem item : items) {
// 能力不可重复
if (!capabilitySet.add(item.getCapability())) {
// 抖音: 支付能力[{0}]重复配置
throw new BizInfoException(CommonErrorCode.VALIDATE_PARAMETERS_ERROR,
"error.channel.douyin.capabilityDuplicate", item.getCapability());
}
// 应用归属校验
if (!appMap.containsKey(item.getDouyinDirectAppId())) {
// 抖音: 直连商户应用不存在
throw new BizInfoException(CommonErrorCode.VALIDATE_PARAMETERS_ERROR,
"error.channel.douyin.mchAppNotFound");
}
var entity = new DouyinDirectAppCapability()
.setChannelMchNo(channelMchNo)
.setCapability(item.getCapability())
.setDouyinDirectAppId(item.getDouyinDirectAppId());
entity.setMchNo(mchNo);
capabilityManager.save(entity);
}
}
/// 应用被删除时级联清理关联,避免悬空引用
public void deleteByDouyinDirectAppId(Long douyinDirectAppId) {
capabilityManager.deleteByDouyinDirectAppId(douyinDirectAppId);
}
/// 支付时解析当前支付能力对应的应用:显式配置 > appType自动推导 > 通道商户首个兜底
///
/// @param channelMchNo 通道商户号
/// @param capability 支付能力编码
/// @return 命中的应用;三者均未命中返回 empty由调用方兜底回退
public Optional<DouyinDirectApp> resolveApp(String channelMchNo, String capability) {
if (StrUtil.hasBlank(channelMchNo, capability)) {
return Optional.empty();
}
// 1. 显式配置优先
var rel = capabilityManager.findOne(channelMchNo, capability);
if (rel.isPresent()) {
return douyinDirectAppManager.findById(rel.get().getDouyinDirectAppId());
}
// 2. 未配置则按能力 → appType 自动推导
String appType = DouyinAppTypeCode.resolveAppType(capability);
if (appType != null) {
Optional<DouyinDirectApp> byType = douyinDirectAppManager.findFirstByChannelMchNoAndAppType(channelMchNo, appType);
if (byType.isPresent()) {
return byType;
}
}
// 3. 最终兜底:按通道商户号取首个应用
return douyinDirectAppManager.findFirstByChannelMchNo(channelMchNo);
}
/// 查询抖音直连产品支持的支付能力候选列表(含国际化名称)
public List<DouyinCapabilityOption> listSupportedCapabilities() {
AbsProductStrategy strategy = PaymentStrategyFactory.createByProduct(
ProductEnum.DOUYIN_PAY.getCode(), AbsProductStrategy.class);
return strategy.methodCapabilityMapping().values().stream()
.flatMap(List::stream)
.distinct()
.map(cap -> new DouyinCapabilityOption(cap.getCode(), I18nUtil.getEnumName(cap)))
.toList();
}
/// 填充关联结果的应用展示字段
private DouyinDirectAppCapabilityResult fillResult(DouyinDirectAppCapability rel, DouyinDirectApp app) {
DouyinDirectAppCapabilityResult result = DouyinDirectAppCapabilityConvert.CONVERT.toResult(rel);
if (app != null) {
result.setAppName(app.getAppName())
.setDouyinAppId(app.getDouyinAppId())
.setAppType(app.getAppType());
}
return result;
}
}

View File

@@ -1,5 +1,6 @@
package cn.daxpay.open.channel.douyin.service.direct;
import cn.daxpay.open.channel.douyin.code.DouyinDirectAppTypeEnum;
import cn.daxpay.open.channel.douyin.convert.direct.DouyinDirectAppConvert;
import cn.daxpay.open.channel.douyin.dao.direct.DouyinDirectAppManager;
import cn.daxpay.open.channel.douyin.entity.direct.DouyinDirectApp;
@@ -15,6 +16,7 @@ import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.Arrays;
import java.util.List;
/// # 抖音直连商户应用管理
@@ -28,6 +30,7 @@ public class DouyinDirectAppService {
private final DouyinDirectAppManager douyinDirectAppManager;
private final DouyinDirectAppAuthConfigService douyinDirectAppAuthConfigService;
private final DouyinDirectAppCapabilityService douyinDirectAppCapabilityService;
/// 根据商户号和通道商户号查询应用列表
public List<DouyinDirectAppResult> listByMchNoAndChannelMchNo(String mchNo, String channelMchNo) {
@@ -54,6 +57,7 @@ public class DouyinDirectAppService {
/// 新增抖音直连商户应用
public void add(DouyinDirectAppParam param) {
this.assertDouyinAppIdUnique(param.getMchNo(), param.getChannelMchNo(), param.getDouyinAppId(), null);
this.validateAppType(param.getAppType());
var entity = DouyinDirectAppConvert.CONVERT.toEntity(param);
douyinDirectAppManager.save(entity);
}
@@ -65,6 +69,7 @@ public class DouyinDirectAppService {
.orElseThrow(() -> new DataNotExistException("error.channel.douyin.mchAppNotFound"));
this.assertScopeMatch(entity, param.getMchNo(), param.getChannelMchNo());
this.assertDouyinAppIdUnique(entity.getMchNo(), entity.getChannelMchNo(), param.getDouyinAppId(), param.getId());
this.validateAppType(param.getAppType());
DouyinDirectAppConvert.CONVERT.copy(param, entity);
douyinDirectAppManager.updateById(entity);
}
@@ -76,6 +81,8 @@ public class DouyinDirectAppService {
// 抖音: 直连商户应用不存在
.orElseThrow(() -> new DataNotExistException("error.channel.douyin.mchAppNotFound"));
douyinDirectAppAuthConfigService.deleteByDouyinDirectAppId(id);
// 级联清理支付能力关联,避免悬空引用
douyinDirectAppCapabilityService.deleteByDouyinDirectAppId(id);
douyinDirectAppManager.deleteById(id);
}
@@ -93,4 +100,13 @@ public class DouyinDirectAppService {
throw new BizInfoException(CommonErrorCode.VALIDATE_PARAMETERS_ERROR, "error.channel.douyin.mchAppNotFound");
}
}
/// 校验应用类型
private void validateAppType(String appType) {
boolean valid = Arrays.stream(DouyinDirectAppTypeEnum.values())
.anyMatch(item -> item.getCode().equals(appType));
if (!valid) {
throw new BizInfoException(CommonErrorCode.VALIDATE_PARAMETERS_ERROR, "error.channel.douyin.appTypeInvalid");
}
}
}

View File

@@ -0,0 +1,50 @@
package cn.daxpay.open.channel.wechat.code;
import cn.daxpay.open.platform.core.enums.pay.channel.PayCapabilityEnum;
import java.util.Map;
/// # 微信应用类型与支付能力映射
///
/// 定义微信应用类型([cn.daxpay.open.channel.wechat.entity.isv.WechatIsvApp].appType)常量,
/// 以及支付能力(PayCapabilityEnum)到应用类型的默认推导关系。
///
/// 当未显式配置「能力→应用」关联时,[cn.daxpay.open.channel.wechat.service.isv.WechatIsvAppCapabilityService]
/// 会依据本映射按能力自动推导出合适的 appType进而在应用列表中匹配对应应用。
///
public final class WechatAppTypeCode {
private WechatAppTypeCode() {
}
/// 应用类型: 公众号
public static final String OFFICIAL_ACCOUNT = "official_account";
/// 应用类型: 小程序
public static final String MINI_PROGRAM = "mini_program";
/// 应用类型: 移动应用
public static final String MOBILE_APP = "mobile_app";
/// 支付能力 → 应用类型的默认推导映射
///
/// - JSAPI支付 → 公众号
/// - 小程序支付 → 小程序
/// - APP支付 → 移动应用
/// - 扫码/H5/付款码 → 公众号(微信扫码/H5/付款码通常使用公众号应用)
private static final Map<String, String> CAPABILITY_APP_TYPE_MAP = Map.of(
PayCapabilityEnum.WECHAT_JSAPI.getCode(), OFFICIAL_ACCOUNT,
PayCapabilityEnum.WECHAT_QR.getCode(), OFFICIAL_ACCOUNT,
PayCapabilityEnum.WECHAT_H5.getCode(), OFFICIAL_ACCOUNT,
PayCapabilityEnum.WECHAT_BARCODE.getCode(), OFFICIAL_ACCOUNT,
PayCapabilityEnum.WECHAT_MINI.getCode(), MINI_PROGRAM,
PayCapabilityEnum.WECHAT_APP.getCode(), MOBILE_APP);
/// 根据支付能力编码推导默认应用类型;未知能力返回 null
public static String resolveAppType(String capabilityCode) {
if (capabilityCode == null) {
return null;
}
return CAPABILITY_APP_TYPE_MAP.get(capabilityCode);
}
}

View File

@@ -0,0 +1,30 @@
package cn.daxpay.open.channel.wechat.code;
import cn.daxpay.open.platform.core.i18n.I18nSupport;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
/// # 微信直连商户应用类型
///
/// 定义微信直连商户应用([cn.daxpay.open.channel.wechat.entity.direct.WechatDirectApp].appType)的取值范围,
/// 与微信服务商应用类型([WechatIsvAppTypeEnum])取值保持一致,便于能力与应用类型统一推导。
///
@Getter
@RequiredArgsConstructor
public enum WechatDirectAppTypeEnum implements I18nSupport {
/// 公众号
OFFICIAL_ACCOUNT("official_account"),
/// 小程序
MINI_PROGRAM("mini_program"),
/// 移动应用
MOBILE_APP("mobile_app"),
;
private final String code;
/// 翻译 key 前缀
@Override
public String getI18nPrefix() {
return "enum.wechat_direct_app_type";
}
}

View File

@@ -0,0 +1,60 @@
package cn.daxpay.open.channel.wechat.controller.direct;
import cn.daxpay.open.channel.wechat.param.direct.WechatDirectAppCapabilityBatchParam;
import cn.daxpay.open.channel.wechat.result.WechatCapabilityOption;
import cn.daxpay.open.channel.wechat.result.direct.WechatDirectAppCapabilityResult;
import cn.daxpay.open.channel.wechat.service.direct.WechatDirectAppCapabilityService;
import cn.daxpay.open.platform.core.annotation.PermCode;
import cn.daxpay.open.platform.core.rest.Res;
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.validation.annotation.Validated;
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 = "channel:wechat:app")
@Validated
@Tag(name = "微信直连商户应用支付能力关联管理")
@RestController
@RequestMapping("/admin/wechat/mch-app/capability")
@RequiredArgsConstructor
public class WechatDirectAppCapabilityController {
private final WechatDirectAppCapabilityService wechatDirectAppCapabilityService;
@PermCode(code = "view", nameCn = "通道商户查看", nameEn = "Channel Merchant View")
@Operation(summary = "查询通道商户的能力应用关联列表")
@GetMapping("/list-by-channel-mch-no")
public Result<List<WechatDirectAppCapabilityResult>> listByChannelMchNo(
@NotBlank(message = "{validation.field.mchNo.notBlank}") String mchNo,
@NotBlank(message = "{validation.field.channelMerchantNo.notBlank}") String channelMchNo) {
return Res.ok(wechatDirectAppCapabilityService.listByChannelMchNo(mchNo, channelMchNo));
}
@PermCode(code = "manage", nameCn = "通道商户管理", nameEn = "Channel Merchant Manage")
@Operation(summary = "全量保存能力应用关联")
@PostMapping("/save-batch")
public Result<Void> saveBatch(@RequestBody @Validated WechatDirectAppCapabilityBatchParam param) {
wechatDirectAppCapabilityService.saveBatch(param);
return Res.ok();
}
@PermCode(code = "view", nameCn = "通道商户查看", nameEn = "Channel Merchant View")
@Operation(summary = "查询微信直连支持的支付能力候选")
@GetMapping("/list-supported-capabilities")
public Result<List<WechatCapabilityOption>> listSupportedCapabilities() {
return Res.ok(wechatDirectAppCapabilityService.listSupportedCapabilities());
}
}

View File

@@ -0,0 +1,57 @@
package cn.daxpay.open.channel.wechat.controller.isv;
import cn.daxpay.open.channel.wechat.param.isv.WechatIsvAppCapabilityBatchParam;
import cn.daxpay.open.channel.wechat.result.WechatCapabilityOption;
import cn.daxpay.open.channel.wechat.result.isv.WechatIsvAppCapabilityResult;
import cn.daxpay.open.channel.wechat.service.isv.WechatIsvAppCapabilityService;
import cn.daxpay.open.platform.core.annotation.PermCode;
import cn.daxpay.open.platform.core.rest.Res;
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 lombok.RequiredArgsConstructor;
import org.springframework.validation.annotation.Validated;
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:wechat:isv")
@Validated
@Tag(name = "微信服务商应用支付能力关联管理")
@RestController
@RequestMapping("/admin/wechat/isv-app/capability")
@RequiredArgsConstructor
public class WechatIsvAppCapabilityController {
private final WechatIsvAppCapabilityService wechatIsvAppCapabilityService;
@PermCode(code = "view", nameCn = "微信服务商查看", nameEn = "WeChat ISV View")
@Operation(summary = "查询能力应用关联列表")
@GetMapping("/list-all")
public Result<List<WechatIsvAppCapabilityResult>> listAll() {
return Res.ok(wechatIsvAppCapabilityService.listAll());
}
@PermCode(code = "manage", nameCn = "微信服务商管理", nameEn = "WeChat ISV Manage")
@Operation(summary = "全量保存能力应用关联")
@PostMapping("/save-batch")
public Result<Void> saveBatch(@RequestBody @Validated WechatIsvAppCapabilityBatchParam param) {
wechatIsvAppCapabilityService.saveBatch(param);
return Res.ok();
}
@PermCode(code = "view", nameCn = "微信服务商查看", nameEn = "WeChat ISV View")
@Operation(summary = "查询微信服务商支持的支付能力候选")
@GetMapping("/list-supported-capabilities")
public Result<List<WechatCapabilityOption>> listSupportedCapabilities() {
return Res.ok(wechatIsvAppCapabilityService.listSupportedCapabilities());
}
}

View File

@@ -0,0 +1,19 @@
package cn.daxpay.open.channel.wechat.convert.direct;
import cn.daxpay.open.channel.wechat.entity.direct.WechatDirectAppCapability;
import cn.daxpay.open.channel.wechat.result.direct.WechatDirectAppCapabilityResult;
import org.mapstruct.Mapper;
import org.mapstruct.factory.Mappers;
/// # 微信直连商户应用支付能力关联转换
///
/// MapStruct转换器负责关联记录在实体与返回结果之间的转换。
///
@Mapper
public interface WechatDirectAppCapabilityConvert {
WechatDirectAppCapabilityConvert CONVERT = Mappers.getMapper(WechatDirectAppCapabilityConvert.class);
/// 转换为返回对象
WechatDirectAppCapabilityResult toResult(WechatDirectAppCapability entity);
}

View File

@@ -0,0 +1,19 @@
package cn.daxpay.open.channel.wechat.convert.isv;
import cn.daxpay.open.channel.wechat.entity.isv.WechatIsvAppCapability;
import cn.daxpay.open.channel.wechat.result.isv.WechatIsvAppCapabilityResult;
import org.mapstruct.Mapper;
import org.mapstruct.factory.Mappers;
/// # 微信服务商应用支付能力关联转换
///
/// MapStruct转换器负责关联记录在实体与返回结果之间的转换。
///
@Mapper
public interface WechatIsvAppCapabilityConvert {
WechatIsvAppCapabilityConvert CONVERT = Mappers.getMapper(WechatIsvAppCapabilityConvert.class);
/// 转换为返回对象
WechatIsvAppCapabilityResult toResult(WechatIsvAppCapability entity);
}

View File

@@ -0,0 +1,51 @@
package cn.daxpay.open.channel.wechat.dao.direct;
import cn.daxpay.open.channel.wechat.entity.direct.WechatDirectAppCapability;
import cn.daxpay.open.platform.common.mybatisplus.impl.BaseManager;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Optional;
/// # 微信直连商户应用支付能力关联 Manager
///
/// 关联记录数据访问管理器,提供按通道商户号查询/删除、按能力查应用、按应用清理等方法。
///
@Slf4j
@Service
@RequiredArgsConstructor
public class WechatDirectAppCapabilityManager extends BaseManager<WechatDirectAppCapabilityMapper, WechatDirectAppCapability> {
/// 根据通道商户号查询全部关联(按创建时间升序)
public List<WechatDirectAppCapability> listByChannelMchNo(String channelMchNo) {
return lambdaQuery()
.eq(WechatDirectAppCapability::getChannelMchNo, channelMchNo)
.orderByAsc(WechatDirectAppCapability::getCreateTime)
.orderByAsc(WechatDirectAppCapability::getId)
.list();
}
/// 根据通道商户号与支付能力查询单条关联(支付时调用)
public Optional<WechatDirectAppCapability> findOne(String channelMchNo, String capability) {
return lambdaQuery()
.eq(WechatDirectAppCapability::getChannelMchNo, channelMchNo)
.eq(WechatDirectAppCapability::getCapability, capability)
.oneOpt();
}
/// 根据通道商户号删除全部关联(批量保存时先清后插)
public void deleteByChannelMchNo(String channelMchNo) {
lambdaUpdate()
.eq(WechatDirectAppCapability::getChannelMchNo, channelMchNo)
.remove();
}
/// 根据应用ID删除关联(应用被删除时级联清理,避免悬空引用)
public void deleteByWechatDirectAppId(Long wechatDirectAppId) {
lambdaUpdate()
.eq(WechatDirectAppCapability::getWechatDirectAppId, wechatDirectAppId)
.remove();
}
}

View File

@@ -0,0 +1,13 @@
package cn.daxpay.open.channel.wechat.dao.direct;
import cn.daxpay.open.channel.wechat.entity.direct.WechatDirectAppCapability;
import com.github.yulichang.base.MPJBaseMapper;
import org.apache.ibatis.annotations.Mapper;
/// # 微信直连商户应用支付能力关联 Mapper
///
/// 继承 MPJBaseMapper 支持多表联查。
///
@Mapper
public interface WechatDirectAppCapabilityMapper extends MPJBaseMapper<WechatDirectAppCapability> {
}

View File

@@ -5,6 +5,7 @@ import cn.daxpay.open.platform.common.mybatisplus.impl.BaseManager;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
/// # 微信直连商户应用
///
@@ -23,6 +24,23 @@ public class WechatDirectAppManager extends BaseManager<WechatDirectAppMapper, W
.list();
}
/// 按通道商户号查询首个应用(能力解析兜底时使用)
public Optional<WechatDirectApp> findFirstByChannelMchNo(String channelMchNo) {
return firstOpt(q -> q
.eq(WechatDirectApp::getChannelMchNo, channelMchNo)
.orderByAsc(WechatDirectApp::getCreateTime)
.orderByAsc(WechatDirectApp::getId));
}
/// 按通道商户号与应用类型查询首个应用(能力→应用类型推导时使用)
public Optional<WechatDirectApp> findFirstByChannelMchNoAndAppType(String channelMchNo, String appType) {
return firstOpt(q -> q
.eq(WechatDirectApp::getChannelMchNo, channelMchNo)
.eq(WechatDirectApp::getAppType, appType)
.orderByAsc(WechatDirectApp::getCreateTime)
.orderByAsc(WechatDirectApp::getId));
}
/// 校验同一通道商户下wxAppId是否已存在(排除自身)
public boolean existsByChannelMchNoAndWxAppId(String mchNo, String channelMchNo, String wxAppId, Long excludeId) {
return lambdaQuery()

View File

@@ -0,0 +1,47 @@
package cn.daxpay.open.channel.wechat.dao.isv;
import cn.daxpay.open.channel.wechat.entity.isv.WechatIsvAppCapability;
import cn.daxpay.open.platform.common.mybatisplus.impl.BaseManager;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Optional;
/// # 微信服务商应用支付能力关联 Manager
///
/// 关联记录数据访问管理器,提供全量查询、按能力查应用、全量删除、按应用清理等方法。
///
@Slf4j
@Service
@RequiredArgsConstructor
public class WechatIsvAppCapabilityManager extends BaseManager<WechatIsvAppCapabilityMapper, WechatIsvAppCapability> {
/// 查询全部关联(按创建时间升序)
public List<WechatIsvAppCapability> listAll() {
return lambdaQuery()
.orderByAsc(WechatIsvAppCapability::getCreateTime)
.orderByAsc(WechatIsvAppCapability::getId)
.list();
}
/// 根据支付能力查询单条关联(支付时调用)
public Optional<WechatIsvAppCapability> findOne(String capability) {
return lambdaQuery()
.eq(WechatIsvAppCapability::getCapability, capability)
.oneOpt();
}
/// 全量删除(批量保存时先清后插)
public void deleteAll() {
lambdaUpdate().remove();
}
/// 根据应用ID删除关联(应用被删除时级联清理,避免悬空引用)
public void deleteByWechatIsvAppId(Long wechatIsvAppId) {
lambdaUpdate()
.eq(WechatIsvAppCapability::getWechatIsvAppId, wechatIsvAppId)
.remove();
}
}

View File

@@ -0,0 +1,13 @@
package cn.daxpay.open.channel.wechat.dao.isv;
import cn.daxpay.open.channel.wechat.entity.isv.WechatIsvAppCapability;
import com.github.yulichang.base.MPJBaseMapper;
import org.apache.ibatis.annotations.Mapper;
/// # 微信服务商应用支付能力关联 Mapper
///
/// 继承 MPJBaseMapper 支持多表联查。
///
@Mapper
public interface WechatIsvAppCapabilityMapper extends MPJBaseMapper<WechatIsvAppCapability> {
}

View File

@@ -31,6 +31,14 @@ public class WechatIsvAppManager extends BaseManager<WechatIsvAppMapper, WechatI
.orderByAsc(WechatIsvApp::getId));
}
/// 按应用类型查询首个应用(能力→应用类型推导时使用)
public Optional<WechatIsvApp> findFirstByAppType(String appType) {
return firstOpt(q -> q
.eq(WechatIsvApp::getAppType, appType)
.orderByAsc(WechatIsvApp::getCreateTime)
.orderByAsc(WechatIsvApp::getId));
}
/// 校验微信应用AppId是否已存在(排除自身)
public boolean existsByWxAppId(String wxAppId, Long excludeId) {
return lambdaQuery()

View File

@@ -28,6 +28,10 @@ public class WechatDirectApp extends MchBaseEntity implements ToResult<WechatDir
/// 应用名称
private String appName;
/// 应用类型
@TableField(updateStrategy = FieldStrategy.NEVER)
private String appType;
/// 微信应用AppId
private String wxAppId;

View File

@@ -0,0 +1,43 @@
package cn.daxpay.open.channel.wechat.entity.direct;
import cn.daxpay.open.channel.wechat.convert.direct.WechatDirectAppCapabilityConvert;
import cn.daxpay.open.channel.wechat.result.direct.WechatDirectAppCapabilityResult;
import cn.daxpay.open.payment.common.entity.merchant.MchBaseEntity;
import cn.daxpay.open.platform.common.mybatisplus.function.ToResult;
import com.baomidou.mybatisplus.annotation.FieldStrategy;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.experimental.Accessors;
/// # 微信直连商户应用支付能力关联
///
/// 建立通道商户维度下「支付能力 → 直连应用」的绑定关系,供支付时选择正确的微信应用(AppId)。
/// 同一通道商户下,一个支付能力只能绑定一个应用(由唯一约束 channel_mch_no + capability 保证)。
///
@EqualsAndHashCode(callSuper = true)
@Data
@Accessors(chain = true)
@TableName(value = "wechat_direct_app_capability", autoResultMap = true)
public class WechatDirectAppCapability extends MchBaseEntity implements ToResult<WechatDirectAppCapabilityResult> {
/// 通道商户号
@TableField(updateStrategy = FieldStrategy.NEVER)
private String channelMchNo;
/// 支付能力编码
/// @see cn.daxpay.open.platform.core.enums.pay.channel.PayCapabilityEnum
@TableField(updateStrategy = FieldStrategy.NEVER)
private String capability;
/// 关联微信直连应用ID(指向 wechat_direct_app.id)
@TableField(updateStrategy = FieldStrategy.NEVER)
private Long wechatDirectAppId;
/// 转换
@Override
public WechatDirectAppCapabilityResult toResult() {
return WechatDirectAppCapabilityConvert.CONVERT.toResult(this);
}
}

View File

@@ -0,0 +1,39 @@
package cn.daxpay.open.channel.wechat.entity.isv;
import cn.daxpay.open.channel.wechat.convert.isv.WechatIsvAppCapabilityConvert;
import cn.daxpay.open.channel.wechat.result.isv.WechatIsvAppCapabilityResult;
import cn.daxpay.open.platform.common.mybatisplus.base.MpBaseEntity;
import cn.daxpay.open.platform.common.mybatisplus.function.ToResult;
import com.baomidou.mybatisplus.annotation.FieldStrategy;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.experimental.Accessors;
/// # 微信服务商应用支付能力关联
///
/// 建立全局维度下「支付能力 → 服务商应用」的绑定关系,供支付时选择正确的服务商应用(微信AppId)。
/// 微信服务商应用全局共享(密钥全局唯一),故本表不挂通道商户号,同一支付能力全局只能绑定一个应用。
///
@EqualsAndHashCode(callSuper = true)
@Data
@Accessors(chain = true)
@TableName(value = "wechat_isv_app_capability", autoResultMap = true)
public class WechatIsvAppCapability extends MpBaseEntity implements ToResult<WechatIsvAppCapabilityResult> {
/// 支付能力编码
/// @see cn.daxpay.open.platform.core.enums.pay.channel.PayCapabilityEnum
@TableField(updateStrategy = FieldStrategy.NEVER)
private String capability;
/// 关联微信服务商应用ID(指向 wechat_isv_app.id)
@TableField(updateStrategy = FieldStrategy.NEVER)
private Long wechatIsvAppId;
/// 转换
@Override
public WechatIsvAppCapabilityResult toResult() {
return WechatIsvAppCapabilityConvert.CONVERT.toResult(this);
}
}

View File

@@ -0,0 +1,32 @@
package cn.daxpay.open.channel.wechat.param.direct;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
import lombok.experimental.Accessors;
import java.util.List;
/// # 微信直连支付能力关联应用批量保存参数
///
/// 全量覆盖某通道商户下「支付能力 → 直连应用」的绑定关系:先清除旧记录,再按 items 批量插入。
/// items 为空表示清空该通道商户下所有绑定。
///
@Data
@Accessors(chain = true)
@Schema(title = "微信直连支付能力关联应用批量保存参数")
public class WechatDirectAppCapabilityBatchParam {
@NotBlank(message = "{validation.field.mchNo.notBlank}")
@Schema(description = "商户号")
private String mchNo;
@NotBlank(message = "{validation.field.channelMchNo.notBlank}")
@Schema(description = "通道商户号")
private String channelMchNo;
@Valid
@Schema(description = "支付能力关联应用列表")
private List<WechatDirectAppCapabilityItem> items;
}

View File

@@ -0,0 +1,25 @@
package cn.daxpay.open.channel.wechat.param.direct;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import lombok.experimental.Accessors;
/// # 微信直连支付能力关联应用单项
///
/// 用于批量保存时,声明单个支付能力绑定的直连应用。
///
@Data
@Accessors(chain = true)
@Schema(title = "微信直连支付能力关联应用单项")
public class WechatDirectAppCapabilityItem {
@NotBlank(message = "{validation.field.capability.notBlank}")
@Schema(description = "支付能力编码")
private String capability;
@NotNull(message = "{validation.field.wechatDirectAppId.notNull}")
@Schema(description = "关联微信直连应用ID")
private Long wechatDirectAppId;
}

View File

@@ -9,7 +9,7 @@ import lombok.experimental.Accessors;
/// # 微信直连商户应用保存参数
///
/// 保存/更新微信直连商户应用时接收的请求参数含商户号、通道商户号、应用名称和微信应用AppId。
/// 保存/更新微信直连商户应用时接收的请求参数,含商户号、通道商户号、应用名称、应用类型和微信应用AppId。
///
@Data
@Accessors(chain = true)
@@ -32,6 +32,10 @@ public class WechatDirectAppParam {
@Schema(description = "应用名称")
private String appName;
@NotBlank(message = "{validation.field.appType.notBlank}")
@Schema(description = "应用类型")
private String appType;
@NotBlank(message = "{validation.field.wxAppId.notBlank}")
@Schema(description = "微信应用AppId")
private String wxAppId;

View File

@@ -0,0 +1,23 @@
package cn.daxpay.open.channel.wechat.param.isv;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.Valid;
import lombok.Data;
import lombok.experimental.Accessors;
import java.util.List;
/// # 微信服务商支付能力关联应用批量保存参数
///
/// 全量覆盖「支付能力 → 服务商应用」的绑定关系:先清除旧记录,再按 items 批量插入。
/// items 为空表示清空所有绑定。
///
@Data
@Accessors(chain = true)
@Schema(title = "微信服务商支付能力关联应用批量保存参数")
public class WechatIsvAppCapabilityBatchParam {
@Valid
@Schema(description = "支付能力关联应用列表")
private List<WechatIsvAppCapabilityItem> items;
}

View File

@@ -0,0 +1,25 @@
package cn.daxpay.open.channel.wechat.param.isv;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import lombok.experimental.Accessors;
/// # 微信服务商支付能力关联应用单项
///
/// 用于批量保存时,声明单个支付能力绑定的服务商应用。
///
@Data
@Accessors(chain = true)
@Schema(title = "微信服务商支付能力关联应用单项")
public class WechatIsvAppCapabilityItem {
@NotBlank(message = "{validation.field.capability.notBlank}")
@Schema(description = "支付能力编码")
private String capability;
@NotNull(message = "{validation.field.wechatIsvAppId.notNull}")
@Schema(description = "关联微信服务商应用ID")
private Long wechatIsvAppId;
}

View File

@@ -0,0 +1,9 @@
package cn.daxpay.open.channel.wechat.result;
/// # 微信支付能力候选项
///
/// 用于展示微信产品(直连/服务商)支持的支付能力编码及其国际化名称。
/// 直连与服务商共用本结构。
///
public record WechatCapabilityOption(String code, String name) {
}

View File

@@ -0,0 +1,36 @@
package cn.daxpay.open.channel.wechat.result.direct;
import cn.daxpay.open.payment.common.result.MchBaseResult;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.experimental.Accessors;
/// # 微信直连商户应用支付能力关联
///
/// 展示通道商户下各支付能力绑定的直连应用信息,含应用名称/AppId/类型等冗余字段(由 Service 填充)。
///
@EqualsAndHashCode(callSuper = true)
@Data
@Accessors(chain = true)
@Schema(title = "微信直连商户应用支付能力关联")
public class WechatDirectAppCapabilityResult extends MchBaseResult {
@Schema(description = "通道商户号")
private String channelMchNo;
@Schema(description = "支付能力编码")
private String capability;
@Schema(description = "关联微信直连应用ID")
private Long wechatDirectAppId;
@Schema(description = "应用名称(冗余展示)")
private String appName;
@Schema(description = "微信应用AppId(冗余展示)")
private String wxAppId;
@Schema(description = "应用类型(冗余展示): official_account/mini_program/mobile_app")
private String appType;
}

View File

@@ -8,7 +8,7 @@ import lombok.experimental.Accessors;
/// # 微信直连商户应用
///
/// 微信直连商户应用的返回结果对象包含通道商户号、应用名称和微信应用AppId等信息。
/// 微信直连商户应用的返回结果对象,包含通道商户号、应用名称、应用类型和微信应用AppId等信息。
///
@EqualsAndHashCode(callSuper = true)
@Data
@@ -22,6 +22,9 @@ public class WechatDirectAppResult extends MchTradeBaseResult {
@Schema(description = "应用名称")
private String appName;
@Schema(description = "应用类型")
private String appType;
@Schema(description = "微信应用AppId")
private String wxAppId;
}

View File

@@ -0,0 +1,33 @@
package cn.daxpay.open.channel.wechat.result.isv;
import cn.daxpay.open.platform.core.result.BaseResult;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.experimental.Accessors;
/// # 微信服务商应用支付能力关联
///
/// 展示各支付能力绑定的服务商应用信息,含应用名称/AppId/类型等冗余字段(由 Service 填充)。
///
@EqualsAndHashCode(callSuper = true)
@Data
@Accessors(chain = true)
@Schema(title = "微信服务商应用支付能力关联")
public class WechatIsvAppCapabilityResult extends BaseResult {
@Schema(description = "支付能力编码")
private String capability;
@Schema(description = "关联微信服务商应用ID")
private Long wechatIsvAppId;
@Schema(description = "应用名称(冗余展示)")
private String appName;
@Schema(description = "微信应用AppId(冗余展示)")
private String wxAppId;
@Schema(description = "应用类型(冗余展示): official_account/mini_program/mobile_app")
private String appType;
}

View File

@@ -0,0 +1,151 @@
package cn.daxpay.open.channel.wechat.service.direct;
import cn.daxpay.open.channel.wechat.code.WechatAppTypeCode;
import cn.daxpay.open.channel.wechat.convert.direct.WechatDirectAppCapabilityConvert;
import cn.daxpay.open.channel.wechat.dao.direct.WechatDirectAppCapabilityManager;
import cn.daxpay.open.channel.wechat.dao.direct.WechatDirectAppManager;
import cn.daxpay.open.channel.wechat.entity.direct.WechatDirectApp;
import cn.daxpay.open.channel.wechat.entity.direct.WechatDirectAppCapability;
import cn.daxpay.open.channel.wechat.param.direct.WechatDirectAppCapabilityBatchParam;
import cn.daxpay.open.channel.wechat.param.direct.WechatDirectAppCapabilityItem;
import cn.daxpay.open.channel.wechat.result.WechatCapabilityOption;
import cn.daxpay.open.channel.wechat.result.direct.WechatDirectAppCapabilityResult;
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.ProductEnum;
import cn.daxpay.open.platform.core.exception.BizInfoException;
import cn.daxpay.open.payment.common.util.PaymentStrategyFactory;
import cn.daxpay.open.payment.strategy.product.AbsProductStrategy;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.StrUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.function.Function;
import java.util.stream.Collectors;
/// # 微信直连商户应用支付能力关联
///
/// 管理通道商户维度下「支付能力 → 直连应用」的绑定关系。
/// 支付时通过 [resolveApp] 解析当前能力对应的应用:显式配置 > appType自动推导 > 通道商户首个兜底。
///
@Slf4j
@Service
@RequiredArgsConstructor
public class WechatDirectAppCapabilityService {
private final WechatDirectAppCapabilityManager capabilityManager;
private final WechatDirectAppManager wechatDirectAppManager;
/// 查询通道商户下的能力关联列表,并填充应用展示信息(应用名称/AppId/类型)
public List<WechatDirectAppCapabilityResult> listByChannelMchNo(String mchNo, String channelMchNo) {
List<WechatDirectAppCapability> rels = capabilityManager.listByChannelMchNo(channelMchNo);
if (rels.isEmpty()) {
return List.of();
}
// 批量查询该通道商户下全部应用,构建 id → app 映射用于填充展示字段
Map<Long, WechatDirectApp> appMap = wechatDirectAppManager.listByMchNoAndChannelMchNo(mchNo, channelMchNo)
.stream()
.collect(Collectors.toMap(WechatDirectApp::getId, Function.identity()));
return rels.stream()
.map(rel -> fillResult(rel, appMap.get(rel.getWechatDirectAppId())))
.toList();
}
/// 全量保存能力关联(先清后插),校验应用归属与能力唯一
@Transactional(rollbackFor = Exception.class)
public void saveBatch(WechatDirectAppCapabilityBatchParam param) {
String mchNo = param.getMchNo();
String channelMchNo = param.getChannelMchNo();
List<WechatDirectAppCapabilityItem> items = param.getItems();
// 先清空旧关联
capabilityManager.deleteByChannelMchNo(channelMchNo);
if (CollUtil.isEmpty(items)) {
return;
}
// 查询该通道商户下全部应用,校验应用归属
List<WechatDirectApp> apps = wechatDirectAppManager.listByMchNoAndChannelMchNo(mchNo, channelMchNo);
Map<Long, WechatDirectApp> appMap = apps.stream()
.collect(Collectors.toMap(WechatDirectApp::getId, Function.identity()));
HashSet<String> capabilitySet = new HashSet<>();
for (WechatDirectAppCapabilityItem item : items) {
// 能力不可重复
if (!capabilitySet.add(item.getCapability())) {
// 微信: 支付能力[{0}]重复配置
throw new BizInfoException(CommonErrorCode.VALIDATE_PARAMETERS_ERROR,
"error.channel.wechat.capabilityDuplicate", item.getCapability());
}
// 应用归属校验
if (!appMap.containsKey(item.getWechatDirectAppId())) {
// 微信: 直连商户应用不存在
throw new BizInfoException(CommonErrorCode.VALIDATE_PARAMETERS_ERROR,
"error.channel.wechat.mchAppNotFound");
}
var entity = new WechatDirectAppCapability()
.setChannelMchNo(channelMchNo)
.setCapability(item.getCapability())
.setWechatDirectAppId(item.getWechatDirectAppId());
entity.setMchNo(mchNo);
capabilityManager.save(entity);
}
}
/// 应用被删除时级联清理关联,避免悬空引用
public void deleteByWechatDirectAppId(Long wechatDirectAppId) {
capabilityManager.deleteByWechatDirectAppId(wechatDirectAppId);
}
/// 支付时解析当前支付能力对应的应用:显式配置 > appType自动推导 > 通道商户首个兜底
///
/// @param channelMchNo 通道商户号
/// @param capability 支付能力编码
/// @return 命中的应用;三者均未命中返回 empty由调用方兜底回退
public Optional<WechatDirectApp> resolveApp(String channelMchNo, String capability) {
if (StrUtil.hasBlank(channelMchNo, capability)) {
return Optional.empty();
}
// 1. 显式配置优先
var rel = capabilityManager.findOne(channelMchNo, capability);
if (rel.isPresent()) {
return wechatDirectAppManager.findById(rel.get().getWechatDirectAppId());
}
// 2. 未配置则按能力 → appType 自动推导
String appType = WechatAppTypeCode.resolveAppType(capability);
if (appType != null) {
Optional<WechatDirectApp> byType = wechatDirectAppManager.findFirstByChannelMchNoAndAppType(channelMchNo, appType);
if (byType.isPresent()) {
return byType;
}
}
// 3. 最终兜底:按通道商户号取首个应用
return wechatDirectAppManager.findFirstByChannelMchNo(channelMchNo);
}
/// 查询微信直连产品支持的支付能力候选列表(含国际化名称)
public List<WechatCapabilityOption> listSupportedCapabilities() {
AbsProductStrategy strategy = PaymentStrategyFactory.createByProduct(
ProductEnum.WECHAT_PAY.getCode(), AbsProductStrategy.class);
return strategy.methodCapabilityMapping().values().stream()
.flatMap(List::stream)
.distinct()
.map(cap -> new WechatCapabilityOption(cap.getCode(), I18nUtil.getEnumName(cap)))
.toList();
}
/// 填充关联结果的应用展示字段
private WechatDirectAppCapabilityResult fillResult(WechatDirectAppCapability rel, WechatDirectApp app) {
WechatDirectAppCapabilityResult result = WechatDirectAppCapabilityConvert.CONVERT.toResult(rel);
if (app != null) {
result.setAppName(app.getAppName())
.setWxAppId(app.getWxAppId())
.setAppType(app.getAppType());
}
return result;
}
}

View File

@@ -1,11 +1,11 @@
package cn.daxpay.open.channel.wechat.service.direct;
import cn.daxpay.open.channel.wechat.code.WechatDirectAppTypeEnum;
import cn.daxpay.open.channel.wechat.convert.direct.WechatDirectAppConvert;
import cn.daxpay.open.channel.wechat.dao.direct.WechatDirectAppManager;
import cn.daxpay.open.channel.wechat.entity.direct.WechatDirectApp;
import cn.daxpay.open.channel.wechat.param.direct.WechatDirectAppParam;
import cn.daxpay.open.channel.wechat.result.direct.WechatDirectAppResult;
import cn.daxpay.open.channel.wechat.service.direct.WechatDirectAppAuthConfigService;
import cn.daxpay.open.platform.common.mybatisplus.util.MpUtil;
import cn.daxpay.open.platform.core.code.CommonErrorCode;
import cn.daxpay.open.platform.core.exception.BizInfoException;
@@ -16,11 +16,12 @@ import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.Arrays;
import java.util.List;
/// # 微信直连商户应用管理
///
/// 提供直连商户应用的增删改查功能包含同一通道商户下应用ID的唯一性校验和操作范围校验(防止跨商户操作)。
/// 提供直连商户应用的增删改查功能包含同一通道商户下应用ID的唯一性校验、应用类型校验和操作范围校验(防止跨商户操作)。
///
@Slf4j
@Service
@@ -29,6 +30,7 @@ public class WechatDirectAppService {
private final WechatDirectAppManager wechatDirectAppManager;
private final WechatDirectAppAuthConfigService wechatDirectAppAuthConfigService;
private final WechatDirectAppCapabilityService wechatDirectAppCapabilityService;
/// 根据商户号和通道商户号查询应用列表
public List<WechatDirectAppResult> listByMchNoAndChannelMchNo(String mchNo, String channelMchNo) {
@@ -55,6 +57,7 @@ public class WechatDirectAppService {
/// 新增微信直连商户应用
public void add(WechatDirectAppParam param) {
this.assertWxAppIdUnique(param.getMchNo(), param.getChannelMchNo(), param.getWxAppId(), null);
this.validateAppType(param.getAppType());
var entity = WechatDirectAppConvert.CONVERT.toEntity(param);
wechatDirectAppManager.save(entity);
}
@@ -66,17 +69,19 @@ public class WechatDirectAppService {
.orElseThrow(() -> new DataNotExistException("error.channel.wechat.mchAppNotFound"));
this.assertScopeMatch(entity, param.getMchNo(), param.getChannelMchNo());
this.assertWxAppIdUnique(entity.getMchNo(), entity.getChannelMchNo(), param.getWxAppId(), param.getId());
this.validateAppType(param.getAppType());
WechatDirectAppConvert.CONVERT.copy(param, entity);
wechatDirectAppManager.updateById(entity);
}
/// 删除应用(级联删除授权认证配置)
/// 删除应用(级联删除授权认证配置与能力关联
@Transactional(rollbackFor = Exception.class)
public void delete(Long id) {
wechatDirectAppManager.findById(id)
// 微信: 直连商户应用不存在
.orElseThrow(() -> new DataNotExistException("error.channel.wechat.mchAppNotFound"));
wechatDirectAppAuthConfigService.deleteByWechatDirectAppId(id);
wechatDirectAppCapabilityService.deleteByWechatDirectAppId(id);
wechatDirectAppManager.deleteById(id);
}
@@ -94,4 +99,13 @@ public class WechatDirectAppService {
throw new BizInfoException(CommonErrorCode.VALIDATE_PARAMETERS_ERROR, "error.channel.wechat.mchAppNotFound");
}
}
/// 校验应用类型
private void validateAppType(String appType) {
boolean valid = Arrays.stream(WechatDirectAppTypeEnum.values())
.anyMatch(item -> item.getCode().equals(appType));
if (!valid) {
throw new BizInfoException(CommonErrorCode.VALIDATE_PARAMETERS_ERROR, "error.channel.wechat.appTypeInvalid");
}
}
}

View File

@@ -0,0 +1,144 @@
package cn.daxpay.open.channel.wechat.service.isv;
import cn.daxpay.open.channel.wechat.code.WechatAppTypeCode;
import cn.daxpay.open.channel.wechat.convert.isv.WechatIsvAppCapabilityConvert;
import cn.daxpay.open.channel.wechat.dao.isv.WechatIsvAppCapabilityManager;
import cn.daxpay.open.channel.wechat.dao.isv.WechatIsvAppManager;
import cn.daxpay.open.channel.wechat.entity.isv.WechatIsvApp;
import cn.daxpay.open.channel.wechat.entity.isv.WechatIsvAppCapability;
import cn.daxpay.open.channel.wechat.param.isv.WechatIsvAppCapabilityBatchParam;
import cn.daxpay.open.channel.wechat.param.isv.WechatIsvAppCapabilityItem;
import cn.daxpay.open.channel.wechat.result.WechatCapabilityOption;
import cn.daxpay.open.channel.wechat.result.isv.WechatIsvAppCapabilityResult;
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.ProductEnum;
import cn.daxpay.open.platform.core.exception.BizInfoException;
import cn.daxpay.open.payment.common.util.PaymentStrategyFactory;
import cn.daxpay.open.payment.strategy.product.AbsProductStrategy;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.StrUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.function.Function;
import java.util.stream.Collectors;
/// # 微信服务商应用支付能力关联
///
/// 管理全局维度下「支付能力 → 服务商应用」的绑定关系(微信服务商应用全局共享)。
/// 支付时通过 [resolveApp] 解析当前能力对应的应用:显式配置 > appType自动推导 > 取首个兜底。
///
@Slf4j
@Service
@RequiredArgsConstructor
public class WechatIsvAppCapabilityService {
private final WechatIsvAppCapabilityManager capabilityManager;
private final WechatIsvAppManager wechatIsvAppManager;
/// 查询能力关联列表,并填充应用展示信息(应用名称/AppId/类型)
public List<WechatIsvAppCapabilityResult> listAll() {
List<WechatIsvAppCapability> rels = capabilityManager.listAll();
if (rels.isEmpty()) {
return List.of();
}
// 批量查询全部服务商应用,构建 id → app 映射用于填充展示字段
Map<Long, WechatIsvApp> appMap = wechatIsvAppManager.listAll().stream()
.collect(Collectors.toMap(WechatIsvApp::getId, Function.identity()));
return rels.stream()
.map(rel -> fillResult(rel, appMap.get(rel.getWechatIsvAppId())))
.toList();
}
/// 全量保存能力关联(先清后插),校验应用存在与能力唯一
@Transactional(rollbackFor = Exception.class)
public void saveBatch(WechatIsvAppCapabilityBatchParam param) {
List<WechatIsvAppCapabilityItem> items = param.getItems();
// 先清空旧关联
capabilityManager.deleteAll();
if (CollUtil.isEmpty(items)) {
return;
}
// 查询全部服务商应用,校验应用存在
Map<Long, WechatIsvApp> appMap = wechatIsvAppManager.listAll().stream()
.collect(Collectors.toMap(WechatIsvApp::getId, Function.identity()));
HashSet<String> capabilitySet = new HashSet<>();
for (WechatIsvAppCapabilityItem item : items) {
// 能力不可重复
if (!capabilitySet.add(item.getCapability())) {
// 微信: 支付能力[{0}]重复配置
throw new BizInfoException(CommonErrorCode.VALIDATE_PARAMETERS_ERROR,
"error.channel.wechat.capabilityDuplicate", item.getCapability());
}
// 应用存在校验
if (!appMap.containsKey(item.getWechatIsvAppId())) {
// 微信: 服务商应用不存在
throw new BizInfoException(CommonErrorCode.VALIDATE_PARAMETERS_ERROR,
"error.channel.wechat.appNotFound");
}
var entity = new WechatIsvAppCapability()
.setCapability(item.getCapability())
.setWechatIsvAppId(item.getWechatIsvAppId());
capabilityManager.save(entity);
}
}
/// 应用被删除时级联清理关联,避免悬空引用
public void deleteByWechatIsvAppId(Long wechatIsvAppId) {
capabilityManager.deleteByWechatIsvAppId(wechatIsvAppId);
}
/// 支付时解析当前支付能力对应的应用:显式配置 > appType自动推导 > 取首个兜底
///
/// @param capability 支付能力编码
/// @return 命中的应用;三者均未命中返回 empty由调用方兜底回退
public Optional<WechatIsvApp> resolveApp(String capability) {
if (StrUtil.isBlank(capability)) {
return Optional.empty();
}
// 1. 显式配置优先
var rel = capabilityManager.findOne(capability);
if (rel.isPresent()) {
return wechatIsvAppManager.findById(rel.get().getWechatIsvAppId());
}
// 2. 未配置则按能力 → appType 自动推导
String appType = WechatAppTypeCode.resolveAppType(capability);
if (appType != null) {
Optional<WechatIsvApp> byType = wechatIsvAppManager.findFirstByAppType(appType);
if (byType.isPresent()) {
return byType;
}
}
// 3. 最终兜底:取首个服务商应用
return wechatIsvAppManager.findFirst();
}
/// 查询微信服务商产品支持的支付能力候选列表(含国际化名称)
public List<WechatCapabilityOption> listSupportedCapabilities() {
AbsProductStrategy strategy = PaymentStrategyFactory.createByProduct(
ProductEnum.WECHAT_ISV.getCode(), AbsProductStrategy.class);
return strategy.methodCapabilityMapping().values().stream()
.flatMap(List::stream)
.distinct()
.map(cap -> new WechatCapabilityOption(cap.getCode(), I18nUtil.getEnumName(cap)))
.toList();
}
/// 填充关联结果的应用展示字段
private WechatIsvAppCapabilityResult fillResult(WechatIsvAppCapability rel, WechatIsvApp app) {
WechatIsvAppCapabilityResult result = WechatIsvAppCapabilityConvert.CONVERT.toResult(rel);
if (app != null) {
result.setAppName(app.getAppName())
.setWxAppId(app.getWxAppId())
.setAppType(app.getAppType());
}
return result;
}
}

View File

@@ -6,7 +6,6 @@ import cn.daxpay.open.channel.wechat.entity.isv.WechatIsvApp;
import cn.daxpay.open.channel.wechat.code.WechatIsvAppTypeEnum;
import cn.daxpay.open.channel.wechat.param.isv.WechatIsvAppParam;
import cn.daxpay.open.channel.wechat.result.isv.WechatIsvAppResult;
import cn.daxpay.open.channel.wechat.service.isv.WechatIsvAppAuthConfigService;
import cn.daxpay.open.platform.common.mybatisplus.util.MpUtil;
import cn.daxpay.open.platform.core.code.CommonErrorCode;
import cn.daxpay.open.platform.core.exception.BizInfoException;
@@ -29,6 +28,7 @@ public class WechatIsvAppService {
private final WechatIsvAppManager wechatIsvAppManager;
private final WechatIsvAppAuthConfigService wechatIsvAppAuthConfigService;
private final WechatIsvAppCapabilityService wechatIsvAppCapabilityService;
/// 查询全部应用列表
public List<WechatIsvAppResult> listAll() {
@@ -71,16 +71,18 @@ public class WechatIsvAppService {
// 微信: 服务商应用不存在
.orElseThrow(() -> new DataNotExistException("error.channel.wechat.appNotFound"));
this.assertWxAppIdUnique(param.getWxAppId(), param.getId());
this.validateAppType(param.getAppType());
WechatIsvAppConvert.CONVERT.copy(param, entity);
wechatIsvAppManager.updateById(entity);
}
/// 删除应用
/// 删除应用(级联删除授权认证配置与能力关联)
public void delete(Long id) {
wechatIsvAppManager.findById(id)
// 微信: 服务商应用不存在
.orElseThrow(() -> new DataNotExistException("error.channel.wechat.appNotFound"));
wechatIsvAppAuthConfigService.deleteByWechatIsvAppId(id);
wechatIsvAppCapabilityService.deleteByWechatIsvAppId(id);
wechatIsvAppManager.deleteById(id);
}

View File

@@ -0,0 +1,5 @@
{
"mini_program": "Mini Program",
"mobile_app": "Mobile App",
"web_app": "Web App"
}

View File

@@ -0,0 +1,5 @@
{
"official_account": "Official Account",
"mini_program": "Mini Program",
"mobile_app": "Mobile App"
}

View File

@@ -0,0 +1,5 @@
{
"official_account": "Official Account",
"mini_program": "Mini Program",
"mobile_app": "Mobile App"
}

View File

@@ -0,0 +1,7 @@
{
"mchAppNotFound": "Douyin channel merchant application not found",
"appIdDuplicate": "Douyin AppId already exists under this channel merchant",
"directMchDuplicate": "This Douyin direct merchant already exists under the merchant",
"appTypeInvalid": "Invalid Douyin application type",
"capabilityDuplicate": "Payment capability [{0}] is duplicated under this channel merchant"
}

View File

@@ -44,5 +44,6 @@
"publicKeyRequired": "Payment public key is required",
"privateCertRequired": "API certificate is required",
"subMerchantDuplicate": "This sub-merchant already exists under the WeChat application",
"directMchDuplicate": "The WeChat merchant already exists under this merchant"
"directMchDuplicate": "The WeChat merchant already exists under this merchant",
"capabilityDuplicate": "WeChat payment capability [{0}] is duplicated"
}

View File

@@ -0,0 +1,5 @@
{
"mini_program": "小程序",
"mobile_app": "移动应用",
"web_app": "网站应用"
}

View File

@@ -0,0 +1,5 @@
{
"official_account": "公众号",
"mini_program": "小程序",
"mobile_app": "移动应用"
}

View File

@@ -0,0 +1,5 @@
{
"official_account": "公众号",
"mini_program": "小程序",
"mobile_app": "移动应用"
}

View File

@@ -0,0 +1,7 @@
{
"mchAppNotFound": "抖音通道商户应用不存在",
"appIdDuplicate": "同一通道商户下抖音应用ID已存在",
"directMchDuplicate": "同一商户下该抖音直连商户已存在",
"appTypeInvalid": "抖音应用类型无效",
"capabilityDuplicate": "同一通道商户下支付能力[{0}]重复配置"
}

View File

@@ -44,5 +44,6 @@
"publicKeyRequired": "支付公钥未配置",
"privateCertRequired": "API证书未配置",
"subMerchantDuplicate": "该微信应用下已存在此特约商户号",
"directMchDuplicate": "同一商户下该微信商户已存在"
"directMchDuplicate": "同一商户下该微信商户已存在",
"capabilityDuplicate": "微信支付能力[{0}]重复配置"
}