From 4bb5efdc713000698d7192af70c4ba574433563e Mon Sep 17 00:00:00 2001 From: DaxPay Dev Date: Thu, 2 Jul 2026 03:55:59 +0800 Subject: [PATCH] =?UTF-8?q?feat(channel):=20=E5=BE=AE=E4=BF=A1/=E6=8A=96?= =?UTF-8?q?=E9=9F=B3=E7=9B=B4=E8=BF=9E=E5=95=86=E6=88=B7=E5=BA=94=E7=94=A8?= =?UTF-8?q?=E6=94=AF=E4=BB=98=E8=83=BD=E5=8A=9B=E7=BB=91=E5=AE=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 微信直连(商户级)+服务商(全局级)能力绑定: entity/dao/convert/param/result/service/controller 全套 - 抖音直连(商户级)能力绑定: 同上(抖音仅直连模式) - resolveApp: 显式配置 > appType推导 > 通道商户首个兜底 - 微信直连应用补全 appType 字段+枚举, 应用删除级联清理关联 - SQL: wechat_isv/wechat_direct/douyin_direct_app_capability 三表 - i18n: error/enum 中英文同步 --- _config/sql/update-tables.sql | 101 +++++++++++- .../douyin/code/DouyinAppTypeCode.java | 47 ++++++ .../douyin/code/DouyinDirectAppTypeEnum.java | 29 ++++ .../DouyinDirectAppCapabilityController.java | 60 +++++++ .../DouyinDirectAppCapabilityConvert.java | 19 +++ .../DouyinDirectAppCapabilityManager.java | 51 ++++++ .../DouyinDirectAppCapabilityMapper.java | 13 ++ .../dao/direct/DouyinDirectAppManager.java | 22 +++ .../direct/DouyinDirectAppCapability.java | 43 +++++ .../DouyinDirectAppCapabilityBatchParam.java | 32 ++++ .../direct/DouyinDirectAppCapabilityItem.java | 25 +++ .../douyin/result/DouyinCapabilityOption.java | 8 + .../DouyinDirectAppCapabilityResult.java | 36 +++++ .../DouyinDirectAppCapabilityService.java | 151 ++++++++++++++++++ .../direct/DouyinDirectAppService.java | 16 ++ .../wechat/code/WechatAppTypeCode.java | 50 ++++++ .../wechat/code/WechatDirectAppTypeEnum.java | 30 ++++ .../WechatDirectAppCapabilityController.java | 60 +++++++ .../isv/WechatIsvAppCapabilityController.java | 57 +++++++ .../WechatDirectAppCapabilityConvert.java | 19 +++ .../isv/WechatIsvAppCapabilityConvert.java | 19 +++ .../WechatDirectAppCapabilityManager.java | 51 ++++++ .../WechatDirectAppCapabilityMapper.java | 13 ++ .../dao/direct/WechatDirectAppManager.java | 18 +++ .../isv/WechatIsvAppCapabilityManager.java | 47 ++++++ .../dao/isv/WechatIsvAppCapabilityMapper.java | 13 ++ .../wechat/dao/isv/WechatIsvAppManager.java | 8 + .../wechat/entity/direct/WechatDirectApp.java | 4 + .../direct/WechatDirectAppCapability.java | 43 +++++ .../entity/isv/WechatIsvAppCapability.java | 39 +++++ .../WechatDirectAppCapabilityBatchParam.java | 32 ++++ .../direct/WechatDirectAppCapabilityItem.java | 25 +++ .../param/direct/WechatDirectAppParam.java | 6 +- .../isv/WechatIsvAppCapabilityBatchParam.java | 23 +++ .../param/isv/WechatIsvAppCapabilityItem.java | 25 +++ .../wechat/result/WechatCapabilityOption.java | 9 ++ .../WechatDirectAppCapabilityResult.java | 36 +++++ .../result/direct/WechatDirectAppResult.java | 5 +- .../isv/WechatIsvAppCapabilityResult.java | 33 ++++ .../WechatDirectAppCapabilityService.java | 151 ++++++++++++++++++ .../direct/WechatDirectAppService.java | 20 ++- .../isv/WechatIsvAppCapabilityService.java | 144 +++++++++++++++++ .../service/isv/WechatIsvAppService.java | 6 +- .../en-US/enum/douyin_direct_app_type.json | 5 + .../en-US/enum/wechat_direct_app_type.json | 5 + .../i18n/en-US/enum/wechat_isv_app_type.json | 5 + .../i18n/en-US/error/channel/douyin.json | 7 + .../i18n/en-US/error/channel/wechat.json | 3 +- .../zh-CN/enum/douyin_direct_app_type.json | 5 + .../zh-CN/enum/wechat_direct_app_type.json | 5 + .../i18n/zh-CN/enum/wechat_isv_app_type.json | 5 + .../i18n/zh-CN/error/channel/douyin.json | 7 + .../i18n/zh-CN/error/channel/wechat.json | 3 +- 53 files changed, 1675 insertions(+), 14 deletions(-) create mode 100644 daxpay-channel/daxpay-channel-douyin/src/main/java/cn/daxpay/open/channel/douyin/code/DouyinAppTypeCode.java create mode 100644 daxpay-channel/daxpay-channel-douyin/src/main/java/cn/daxpay/open/channel/douyin/code/DouyinDirectAppTypeEnum.java create mode 100644 daxpay-channel/daxpay-channel-douyin/src/main/java/cn/daxpay/open/channel/douyin/controller/direct/DouyinDirectAppCapabilityController.java create mode 100644 daxpay-channel/daxpay-channel-douyin/src/main/java/cn/daxpay/open/channel/douyin/convert/direct/DouyinDirectAppCapabilityConvert.java create mode 100644 daxpay-channel/daxpay-channel-douyin/src/main/java/cn/daxpay/open/channel/douyin/dao/direct/DouyinDirectAppCapabilityManager.java create mode 100644 daxpay-channel/daxpay-channel-douyin/src/main/java/cn/daxpay/open/channel/douyin/dao/direct/DouyinDirectAppCapabilityMapper.java create mode 100644 daxpay-channel/daxpay-channel-douyin/src/main/java/cn/daxpay/open/channel/douyin/entity/direct/DouyinDirectAppCapability.java create mode 100644 daxpay-channel/daxpay-channel-douyin/src/main/java/cn/daxpay/open/channel/douyin/param/direct/DouyinDirectAppCapabilityBatchParam.java create mode 100644 daxpay-channel/daxpay-channel-douyin/src/main/java/cn/daxpay/open/channel/douyin/param/direct/DouyinDirectAppCapabilityItem.java create mode 100644 daxpay-channel/daxpay-channel-douyin/src/main/java/cn/daxpay/open/channel/douyin/result/DouyinCapabilityOption.java create mode 100644 daxpay-channel/daxpay-channel-douyin/src/main/java/cn/daxpay/open/channel/douyin/result/direct/DouyinDirectAppCapabilityResult.java create mode 100644 daxpay-channel/daxpay-channel-douyin/src/main/java/cn/daxpay/open/channel/douyin/service/direct/DouyinDirectAppCapabilityService.java create mode 100644 daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/code/WechatAppTypeCode.java create mode 100644 daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/code/WechatDirectAppTypeEnum.java create mode 100644 daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/controller/direct/WechatDirectAppCapabilityController.java create mode 100644 daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/controller/isv/WechatIsvAppCapabilityController.java create mode 100644 daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/convert/direct/WechatDirectAppCapabilityConvert.java create mode 100644 daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/convert/isv/WechatIsvAppCapabilityConvert.java create mode 100644 daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/dao/direct/WechatDirectAppCapabilityManager.java create mode 100644 daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/dao/direct/WechatDirectAppCapabilityMapper.java create mode 100644 daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/dao/isv/WechatIsvAppCapabilityManager.java create mode 100644 daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/dao/isv/WechatIsvAppCapabilityMapper.java create mode 100644 daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/entity/direct/WechatDirectAppCapability.java create mode 100644 daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/entity/isv/WechatIsvAppCapability.java create mode 100644 daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/param/direct/WechatDirectAppCapabilityBatchParam.java create mode 100644 daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/param/direct/WechatDirectAppCapabilityItem.java create mode 100644 daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/param/isv/WechatIsvAppCapabilityBatchParam.java create mode 100644 daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/param/isv/WechatIsvAppCapabilityItem.java create mode 100644 daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/result/WechatCapabilityOption.java create mode 100644 daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/result/direct/WechatDirectAppCapabilityResult.java create mode 100644 daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/result/isv/WechatIsvAppCapabilityResult.java create mode 100644 daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/service/direct/WechatDirectAppCapabilityService.java create mode 100644 daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/service/isv/WechatIsvAppCapabilityService.java create mode 100644 daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/en-US/enum/douyin_direct_app_type.json create mode 100644 daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/en-US/enum/wechat_direct_app_type.json create mode 100644 daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/en-US/enum/wechat_isv_app_type.json create mode 100644 daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/en-US/error/channel/douyin.json create mode 100644 daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/zh-CN/enum/douyin_direct_app_type.json create mode 100644 daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/zh-CN/enum/wechat_direct_app_type.json create mode 100644 daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/zh-CN/enum/wechat_isv_app_type.json create mode 100644 daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/zh-CN/error/channel/douyin.json diff --git a/_config/sql/update-tables.sql b/_config/sql/update-tables.sql index 838bde61f..e341fac35 100644 --- a/_config/sql/update-tables.sql +++ b/_config/sql/update-tables.sql @@ -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 '逻辑删除标志'; diff --git a/daxpay-channel/daxpay-channel-douyin/src/main/java/cn/daxpay/open/channel/douyin/code/DouyinAppTypeCode.java b/daxpay-channel/daxpay-channel-douyin/src/main/java/cn/daxpay/open/channel/douyin/code/DouyinAppTypeCode.java new file mode 100644 index 000000000..67daefcb5 --- /dev/null +++ b/daxpay-channel/daxpay-channel-douyin/src/main/java/cn/daxpay/open/channel/douyin/code/DouyinAppTypeCode.java @@ -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 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); + } +} diff --git a/daxpay-channel/daxpay-channel-douyin/src/main/java/cn/daxpay/open/channel/douyin/code/DouyinDirectAppTypeEnum.java b/daxpay-channel/daxpay-channel-douyin/src/main/java/cn/daxpay/open/channel/douyin/code/DouyinDirectAppTypeEnum.java new file mode 100644 index 000000000..f1412a345 --- /dev/null +++ b/daxpay-channel/daxpay-channel-douyin/src/main/java/cn/daxpay/open/channel/douyin/code/DouyinDirectAppTypeEnum.java @@ -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"; + } +} diff --git a/daxpay-channel/daxpay-channel-douyin/src/main/java/cn/daxpay/open/channel/douyin/controller/direct/DouyinDirectAppCapabilityController.java b/daxpay-channel/daxpay-channel-douyin/src/main/java/cn/daxpay/open/channel/douyin/controller/direct/DouyinDirectAppCapabilityController.java new file mode 100644 index 000000000..b2cf7ff48 --- /dev/null +++ b/daxpay-channel/daxpay-channel-douyin/src/main/java/cn/daxpay/open/channel/douyin/controller/direct/DouyinDirectAppCapabilityController.java @@ -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> 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 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> listSupportedCapabilities() { + return Res.ok(douyinDirectAppCapabilityService.listSupportedCapabilities()); + } +} diff --git a/daxpay-channel/daxpay-channel-douyin/src/main/java/cn/daxpay/open/channel/douyin/convert/direct/DouyinDirectAppCapabilityConvert.java b/daxpay-channel/daxpay-channel-douyin/src/main/java/cn/daxpay/open/channel/douyin/convert/direct/DouyinDirectAppCapabilityConvert.java new file mode 100644 index 000000000..bbdfbcd84 --- /dev/null +++ b/daxpay-channel/daxpay-channel-douyin/src/main/java/cn/daxpay/open/channel/douyin/convert/direct/DouyinDirectAppCapabilityConvert.java @@ -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); +} diff --git a/daxpay-channel/daxpay-channel-douyin/src/main/java/cn/daxpay/open/channel/douyin/dao/direct/DouyinDirectAppCapabilityManager.java b/daxpay-channel/daxpay-channel-douyin/src/main/java/cn/daxpay/open/channel/douyin/dao/direct/DouyinDirectAppCapabilityManager.java new file mode 100644 index 000000000..ed7525033 --- /dev/null +++ b/daxpay-channel/daxpay-channel-douyin/src/main/java/cn/daxpay/open/channel/douyin/dao/direct/DouyinDirectAppCapabilityManager.java @@ -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 { + + /// 根据通道商户号查询全部关联(按创建时间升序) + public List listByChannelMchNo(String channelMchNo) { + return lambdaQuery() + .eq(DouyinDirectAppCapability::getChannelMchNo, channelMchNo) + .orderByAsc(DouyinDirectAppCapability::getCreateTime) + .orderByAsc(DouyinDirectAppCapability::getId) + .list(); + } + + /// 根据通道商户号与支付能力查询单条关联(支付时调用) + public Optional 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(); + } +} diff --git a/daxpay-channel/daxpay-channel-douyin/src/main/java/cn/daxpay/open/channel/douyin/dao/direct/DouyinDirectAppCapabilityMapper.java b/daxpay-channel/daxpay-channel-douyin/src/main/java/cn/daxpay/open/channel/douyin/dao/direct/DouyinDirectAppCapabilityMapper.java new file mode 100644 index 000000000..f015b45b4 --- /dev/null +++ b/daxpay-channel/daxpay-channel-douyin/src/main/java/cn/daxpay/open/channel/douyin/dao/direct/DouyinDirectAppCapabilityMapper.java @@ -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 { +} diff --git a/daxpay-channel/daxpay-channel-douyin/src/main/java/cn/daxpay/open/channel/douyin/dao/direct/DouyinDirectAppManager.java b/daxpay-channel/daxpay-channel-douyin/src/main/java/cn/daxpay/open/channel/douyin/dao/direct/DouyinDirectAppManager.java index c5091c795..378a351e7 100644 --- a/daxpay-channel/daxpay-channel-douyin/src/main/java/cn/daxpay/open/channel/douyin/dao/direct/DouyinDirectAppManager.java +++ b/daxpay-channel/daxpay-channel-douyin/src/main/java/cn/daxpay/open/channel/douyin/dao/direct/DouyinDirectAppManager.java @@ -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 findFirstByChannelMchNo(String channelMchNo) { + return lambdaQuery() + .eq(DouyinDirectApp::getChannelMchNo, channelMchNo) + .orderByAsc(DouyinDirectApp::getCreateTime) + .orderByAsc(DouyinDirectApp::getId) + .last("limit 1") + .oneOpt(); + } + + /// 根据通道商户号与应用类型取首个应用(appType自动推导时调用) + public Optional 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(); + } } diff --git a/daxpay-channel/daxpay-channel-douyin/src/main/java/cn/daxpay/open/channel/douyin/entity/direct/DouyinDirectAppCapability.java b/daxpay-channel/daxpay-channel-douyin/src/main/java/cn/daxpay/open/channel/douyin/entity/direct/DouyinDirectAppCapability.java new file mode 100644 index 000000000..877e73169 --- /dev/null +++ b/daxpay-channel/daxpay-channel-douyin/src/main/java/cn/daxpay/open/channel/douyin/entity/direct/DouyinDirectAppCapability.java @@ -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 { + + /// 通道商户号 + @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); + } +} diff --git a/daxpay-channel/daxpay-channel-douyin/src/main/java/cn/daxpay/open/channel/douyin/param/direct/DouyinDirectAppCapabilityBatchParam.java b/daxpay-channel/daxpay-channel-douyin/src/main/java/cn/daxpay/open/channel/douyin/param/direct/DouyinDirectAppCapabilityBatchParam.java new file mode 100644 index 000000000..1ab0ea29d --- /dev/null +++ b/daxpay-channel/daxpay-channel-douyin/src/main/java/cn/daxpay/open/channel/douyin/param/direct/DouyinDirectAppCapabilityBatchParam.java @@ -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 items; +} diff --git a/daxpay-channel/daxpay-channel-douyin/src/main/java/cn/daxpay/open/channel/douyin/param/direct/DouyinDirectAppCapabilityItem.java b/daxpay-channel/daxpay-channel-douyin/src/main/java/cn/daxpay/open/channel/douyin/param/direct/DouyinDirectAppCapabilityItem.java new file mode 100644 index 000000000..27126794e --- /dev/null +++ b/daxpay-channel/daxpay-channel-douyin/src/main/java/cn/daxpay/open/channel/douyin/param/direct/DouyinDirectAppCapabilityItem.java @@ -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; +} diff --git a/daxpay-channel/daxpay-channel-douyin/src/main/java/cn/daxpay/open/channel/douyin/result/DouyinCapabilityOption.java b/daxpay-channel/daxpay-channel-douyin/src/main/java/cn/daxpay/open/channel/douyin/result/DouyinCapabilityOption.java new file mode 100644 index 000000000..124302335 --- /dev/null +++ b/daxpay-channel/daxpay-channel-douyin/src/main/java/cn/daxpay/open/channel/douyin/result/DouyinCapabilityOption.java @@ -0,0 +1,8 @@ +package cn.daxpay.open.channel.douyin.result; + +/// # 抖音支付能力候选项 +/// +/// 用于展示抖音直连产品支持的支付能力编码及其国际化名称。 +/// +public record DouyinCapabilityOption(String code, String name) { +} diff --git a/daxpay-channel/daxpay-channel-douyin/src/main/java/cn/daxpay/open/channel/douyin/result/direct/DouyinDirectAppCapabilityResult.java b/daxpay-channel/daxpay-channel-douyin/src/main/java/cn/daxpay/open/channel/douyin/result/direct/DouyinDirectAppCapabilityResult.java new file mode 100644 index 000000000..418b75fa1 --- /dev/null +++ b/daxpay-channel/daxpay-channel-douyin/src/main/java/cn/daxpay/open/channel/douyin/result/direct/DouyinDirectAppCapabilityResult.java @@ -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; +} diff --git a/daxpay-channel/daxpay-channel-douyin/src/main/java/cn/daxpay/open/channel/douyin/service/direct/DouyinDirectAppCapabilityService.java b/daxpay-channel/daxpay-channel-douyin/src/main/java/cn/daxpay/open/channel/douyin/service/direct/DouyinDirectAppCapabilityService.java new file mode 100644 index 000000000..a89047459 --- /dev/null +++ b/daxpay-channel/daxpay-channel-douyin/src/main/java/cn/daxpay/open/channel/douyin/service/direct/DouyinDirectAppCapabilityService.java @@ -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 listByChannelMchNo(String mchNo, String channelMchNo) { + List rels = capabilityManager.listByChannelMchNo(channelMchNo); + if (rels.isEmpty()) { + return List.of(); + } + // 批量查询该通道商户下全部应用,构建 id → app 映射用于填充展示字段 + Map 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 items = param.getItems(); + // 先清空旧关联 + capabilityManager.deleteByChannelMchNo(channelMchNo); + if (CollUtil.isEmpty(items)) { + return; + } + // 查询该通道商户下全部应用,校验应用归属 + List apps = douyinDirectAppManager.listByMchNoAndChannelMchNo(mchNo, channelMchNo); + Map appMap = apps.stream() + .collect(Collectors.toMap(DouyinDirectApp::getId, Function.identity())); + HashSet 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 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 byType = douyinDirectAppManager.findFirstByChannelMchNoAndAppType(channelMchNo, appType); + if (byType.isPresent()) { + return byType; + } + } + // 3. 最终兜底:按通道商户号取首个应用 + return douyinDirectAppManager.findFirstByChannelMchNo(channelMchNo); + } + + /// 查询抖音直连产品支持的支付能力候选列表(含国际化名称) + public List 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; + } +} diff --git a/daxpay-channel/daxpay-channel-douyin/src/main/java/cn/daxpay/open/channel/douyin/service/direct/DouyinDirectAppService.java b/daxpay-channel/daxpay-channel-douyin/src/main/java/cn/daxpay/open/channel/douyin/service/direct/DouyinDirectAppService.java index b5fa9fdfc..574b450ef 100644 --- a/daxpay-channel/daxpay-channel-douyin/src/main/java/cn/daxpay/open/channel/douyin/service/direct/DouyinDirectAppService.java +++ b/daxpay-channel/daxpay-channel-douyin/src/main/java/cn/daxpay/open/channel/douyin/service/direct/DouyinDirectAppService.java @@ -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 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"); + } + } } diff --git a/daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/code/WechatAppTypeCode.java b/daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/code/WechatAppTypeCode.java new file mode 100644 index 000000000..1b1dd9394 --- /dev/null +++ b/daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/code/WechatAppTypeCode.java @@ -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 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); + } +} diff --git a/daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/code/WechatDirectAppTypeEnum.java b/daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/code/WechatDirectAppTypeEnum.java new file mode 100644 index 000000000..db853477e --- /dev/null +++ b/daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/code/WechatDirectAppTypeEnum.java @@ -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"; + } +} diff --git a/daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/controller/direct/WechatDirectAppCapabilityController.java b/daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/controller/direct/WechatDirectAppCapabilityController.java new file mode 100644 index 000000000..b867fc00e --- /dev/null +++ b/daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/controller/direct/WechatDirectAppCapabilityController.java @@ -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> 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 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> listSupportedCapabilities() { + return Res.ok(wechatDirectAppCapabilityService.listSupportedCapabilities()); + } +} diff --git a/daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/controller/isv/WechatIsvAppCapabilityController.java b/daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/controller/isv/WechatIsvAppCapabilityController.java new file mode 100644 index 000000000..6f05a5028 --- /dev/null +++ b/daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/controller/isv/WechatIsvAppCapabilityController.java @@ -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> listAll() { + return Res.ok(wechatIsvAppCapabilityService.listAll()); + } + + @PermCode(code = "manage", nameCn = "微信服务商管理", nameEn = "WeChat ISV Manage") + @Operation(summary = "全量保存能力应用关联") + @PostMapping("/save-batch") + public Result 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> listSupportedCapabilities() { + return Res.ok(wechatIsvAppCapabilityService.listSupportedCapabilities()); + } +} diff --git a/daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/convert/direct/WechatDirectAppCapabilityConvert.java b/daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/convert/direct/WechatDirectAppCapabilityConvert.java new file mode 100644 index 000000000..16bbe9d41 --- /dev/null +++ b/daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/convert/direct/WechatDirectAppCapabilityConvert.java @@ -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); +} diff --git a/daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/convert/isv/WechatIsvAppCapabilityConvert.java b/daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/convert/isv/WechatIsvAppCapabilityConvert.java new file mode 100644 index 000000000..76c0a6ff9 --- /dev/null +++ b/daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/convert/isv/WechatIsvAppCapabilityConvert.java @@ -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); +} diff --git a/daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/dao/direct/WechatDirectAppCapabilityManager.java b/daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/dao/direct/WechatDirectAppCapabilityManager.java new file mode 100644 index 000000000..7876febab --- /dev/null +++ b/daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/dao/direct/WechatDirectAppCapabilityManager.java @@ -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 { + + /// 根据通道商户号查询全部关联(按创建时间升序) + public List listByChannelMchNo(String channelMchNo) { + return lambdaQuery() + .eq(WechatDirectAppCapability::getChannelMchNo, channelMchNo) + .orderByAsc(WechatDirectAppCapability::getCreateTime) + .orderByAsc(WechatDirectAppCapability::getId) + .list(); + } + + /// 根据通道商户号与支付能力查询单条关联(支付时调用) + public Optional 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(); + } +} diff --git a/daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/dao/direct/WechatDirectAppCapabilityMapper.java b/daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/dao/direct/WechatDirectAppCapabilityMapper.java new file mode 100644 index 000000000..42cb4a38c --- /dev/null +++ b/daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/dao/direct/WechatDirectAppCapabilityMapper.java @@ -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 { +} diff --git a/daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/dao/direct/WechatDirectAppManager.java b/daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/dao/direct/WechatDirectAppManager.java index 51c234cd7..6c154ce38 100644 --- a/daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/dao/direct/WechatDirectAppManager.java +++ b/daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/dao/direct/WechatDirectAppManager.java @@ -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 findFirstByChannelMchNo(String channelMchNo) { + return firstOpt(q -> q + .eq(WechatDirectApp::getChannelMchNo, channelMchNo) + .orderByAsc(WechatDirectApp::getCreateTime) + .orderByAsc(WechatDirectApp::getId)); + } + + /// 按通道商户号与应用类型查询首个应用(能力→应用类型推导时使用) + public Optional 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() diff --git a/daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/dao/isv/WechatIsvAppCapabilityManager.java b/daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/dao/isv/WechatIsvAppCapabilityManager.java new file mode 100644 index 000000000..a5d28f1ae --- /dev/null +++ b/daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/dao/isv/WechatIsvAppCapabilityManager.java @@ -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 { + + /// 查询全部关联(按创建时间升序) + public List listAll() { + return lambdaQuery() + .orderByAsc(WechatIsvAppCapability::getCreateTime) + .orderByAsc(WechatIsvAppCapability::getId) + .list(); + } + + /// 根据支付能力查询单条关联(支付时调用) + public Optional 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(); + } +} diff --git a/daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/dao/isv/WechatIsvAppCapabilityMapper.java b/daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/dao/isv/WechatIsvAppCapabilityMapper.java new file mode 100644 index 000000000..2185cb712 --- /dev/null +++ b/daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/dao/isv/WechatIsvAppCapabilityMapper.java @@ -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 { +} diff --git a/daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/dao/isv/WechatIsvAppManager.java b/daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/dao/isv/WechatIsvAppManager.java index d1f98d302..1ba8e08ad 100644 --- a/daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/dao/isv/WechatIsvAppManager.java +++ b/daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/dao/isv/WechatIsvAppManager.java @@ -31,6 +31,14 @@ public class WechatIsvAppManager extends BaseManager 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() diff --git a/daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/entity/direct/WechatDirectApp.java b/daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/entity/direct/WechatDirectApp.java index ceaf9d2de..f30e3b3e3 100644 --- a/daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/entity/direct/WechatDirectApp.java +++ b/daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/entity/direct/WechatDirectApp.java @@ -28,6 +28,10 @@ public class WechatDirectApp extends MchBaseEntity implements ToResult { + + /// 通道商户号 + @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); + } +} diff --git a/daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/entity/isv/WechatIsvAppCapability.java b/daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/entity/isv/WechatIsvAppCapability.java new file mode 100644 index 000000000..24ba4ed5d --- /dev/null +++ b/daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/entity/isv/WechatIsvAppCapability.java @@ -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 { + + /// 支付能力编码 + /// @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); + } +} diff --git a/daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/param/direct/WechatDirectAppCapabilityBatchParam.java b/daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/param/direct/WechatDirectAppCapabilityBatchParam.java new file mode 100644 index 000000000..5c8cfef76 --- /dev/null +++ b/daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/param/direct/WechatDirectAppCapabilityBatchParam.java @@ -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 items; +} diff --git a/daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/param/direct/WechatDirectAppCapabilityItem.java b/daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/param/direct/WechatDirectAppCapabilityItem.java new file mode 100644 index 000000000..dee25c114 --- /dev/null +++ b/daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/param/direct/WechatDirectAppCapabilityItem.java @@ -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; +} diff --git a/daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/param/direct/WechatDirectAppParam.java b/daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/param/direct/WechatDirectAppParam.java index 328be69fc..c0500ba4c 100644 --- a/daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/param/direct/WechatDirectAppParam.java +++ b/daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/param/direct/WechatDirectAppParam.java @@ -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; diff --git a/daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/param/isv/WechatIsvAppCapabilityBatchParam.java b/daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/param/isv/WechatIsvAppCapabilityBatchParam.java new file mode 100644 index 000000000..c51463e69 --- /dev/null +++ b/daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/param/isv/WechatIsvAppCapabilityBatchParam.java @@ -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 items; +} diff --git a/daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/param/isv/WechatIsvAppCapabilityItem.java b/daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/param/isv/WechatIsvAppCapabilityItem.java new file mode 100644 index 000000000..4b9ee6f73 --- /dev/null +++ b/daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/param/isv/WechatIsvAppCapabilityItem.java @@ -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; +} diff --git a/daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/result/WechatCapabilityOption.java b/daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/result/WechatCapabilityOption.java new file mode 100644 index 000000000..3403dd7e5 --- /dev/null +++ b/daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/result/WechatCapabilityOption.java @@ -0,0 +1,9 @@ +package cn.daxpay.open.channel.wechat.result; + +/// # 微信支付能力候选项 +/// +/// 用于展示微信产品(直连/服务商)支持的支付能力编码及其国际化名称。 +/// 直连与服务商共用本结构。 +/// +public record WechatCapabilityOption(String code, String name) { +} diff --git a/daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/result/direct/WechatDirectAppCapabilityResult.java b/daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/result/direct/WechatDirectAppCapabilityResult.java new file mode 100644 index 000000000..0ac974d42 --- /dev/null +++ b/daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/result/direct/WechatDirectAppCapabilityResult.java @@ -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; +} diff --git a/daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/result/direct/WechatDirectAppResult.java b/daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/result/direct/WechatDirectAppResult.java index 58fdf7b0a..e04392be0 100644 --- a/daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/result/direct/WechatDirectAppResult.java +++ b/daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/result/direct/WechatDirectAppResult.java @@ -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; } diff --git a/daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/result/isv/WechatIsvAppCapabilityResult.java b/daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/result/isv/WechatIsvAppCapabilityResult.java new file mode 100644 index 000000000..2eff9ab4c --- /dev/null +++ b/daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/result/isv/WechatIsvAppCapabilityResult.java @@ -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; +} diff --git a/daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/service/direct/WechatDirectAppCapabilityService.java b/daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/service/direct/WechatDirectAppCapabilityService.java new file mode 100644 index 000000000..1b6c2029e --- /dev/null +++ b/daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/service/direct/WechatDirectAppCapabilityService.java @@ -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 listByChannelMchNo(String mchNo, String channelMchNo) { + List rels = capabilityManager.listByChannelMchNo(channelMchNo); + if (rels.isEmpty()) { + return List.of(); + } + // 批量查询该通道商户下全部应用,构建 id → app 映射用于填充展示字段 + Map 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 items = param.getItems(); + // 先清空旧关联 + capabilityManager.deleteByChannelMchNo(channelMchNo); + if (CollUtil.isEmpty(items)) { + return; + } + // 查询该通道商户下全部应用,校验应用归属 + List apps = wechatDirectAppManager.listByMchNoAndChannelMchNo(mchNo, channelMchNo); + Map appMap = apps.stream() + .collect(Collectors.toMap(WechatDirectApp::getId, Function.identity())); + HashSet 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 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 byType = wechatDirectAppManager.findFirstByChannelMchNoAndAppType(channelMchNo, appType); + if (byType.isPresent()) { + return byType; + } + } + // 3. 最终兜底:按通道商户号取首个应用 + return wechatDirectAppManager.findFirstByChannelMchNo(channelMchNo); + } + + /// 查询微信直连产品支持的支付能力候选列表(含国际化名称) + public List 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; + } +} diff --git a/daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/service/direct/WechatDirectAppService.java b/daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/service/direct/WechatDirectAppService.java index 002f29bb8..e2507cf9f 100644 --- a/daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/service/direct/WechatDirectAppService.java +++ b/daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/service/direct/WechatDirectAppService.java @@ -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 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"); + } + } } diff --git a/daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/service/isv/WechatIsvAppCapabilityService.java b/daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/service/isv/WechatIsvAppCapabilityService.java new file mode 100644 index 000000000..945133f24 --- /dev/null +++ b/daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/service/isv/WechatIsvAppCapabilityService.java @@ -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 listAll() { + List rels = capabilityManager.listAll(); + if (rels.isEmpty()) { + return List.of(); + } + // 批量查询全部服务商应用,构建 id → app 映射用于填充展示字段 + Map 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 items = param.getItems(); + // 先清空旧关联 + capabilityManager.deleteAll(); + if (CollUtil.isEmpty(items)) { + return; + } + // 查询全部服务商应用,校验应用存在 + Map appMap = wechatIsvAppManager.listAll().stream() + .collect(Collectors.toMap(WechatIsvApp::getId, Function.identity())); + HashSet 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 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 byType = wechatIsvAppManager.findFirstByAppType(appType); + if (byType.isPresent()) { + return byType; + } + } + // 3. 最终兜底:取首个服务商应用 + return wechatIsvAppManager.findFirst(); + } + + /// 查询微信服务商产品支持的支付能力候选列表(含国际化名称) + public List 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; + } +} diff --git a/daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/service/isv/WechatIsvAppService.java b/daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/service/isv/WechatIsvAppService.java index 6a0ca00fa..14161a08e 100644 --- a/daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/service/isv/WechatIsvAppService.java +++ b/daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/service/isv/WechatIsvAppService.java @@ -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 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); } diff --git a/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/en-US/enum/douyin_direct_app_type.json b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/en-US/enum/douyin_direct_app_type.json new file mode 100644 index 000000000..ec9db46dd --- /dev/null +++ b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/en-US/enum/douyin_direct_app_type.json @@ -0,0 +1,5 @@ +{ + "mini_program": "Mini Program", + "mobile_app": "Mobile App", + "web_app": "Web App" +} diff --git a/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/en-US/enum/wechat_direct_app_type.json b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/en-US/enum/wechat_direct_app_type.json new file mode 100644 index 000000000..b4a5d5da5 --- /dev/null +++ b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/en-US/enum/wechat_direct_app_type.json @@ -0,0 +1,5 @@ +{ + "official_account": "Official Account", + "mini_program": "Mini Program", + "mobile_app": "Mobile App" +} diff --git a/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/en-US/enum/wechat_isv_app_type.json b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/en-US/enum/wechat_isv_app_type.json new file mode 100644 index 000000000..b4a5d5da5 --- /dev/null +++ b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/en-US/enum/wechat_isv_app_type.json @@ -0,0 +1,5 @@ +{ + "official_account": "Official Account", + "mini_program": "Mini Program", + "mobile_app": "Mobile App" +} diff --git a/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/en-US/error/channel/douyin.json b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/en-US/error/channel/douyin.json new file mode 100644 index 000000000..ee1340ac0 --- /dev/null +++ b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/en-US/error/channel/douyin.json @@ -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" +} diff --git a/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/en-US/error/channel/wechat.json b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/en-US/error/channel/wechat.json index dd24d4092..5385e2a3c 100644 --- a/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/en-US/error/channel/wechat.json +++ b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/en-US/error/channel/wechat.json @@ -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" } \ No newline at end of file diff --git a/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/zh-CN/enum/douyin_direct_app_type.json b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/zh-CN/enum/douyin_direct_app_type.json new file mode 100644 index 000000000..8b2d6ace7 --- /dev/null +++ b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/zh-CN/enum/douyin_direct_app_type.json @@ -0,0 +1,5 @@ +{ + "mini_program": "小程序", + "mobile_app": "移动应用", + "web_app": "网站应用" +} diff --git a/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/zh-CN/enum/wechat_direct_app_type.json b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/zh-CN/enum/wechat_direct_app_type.json new file mode 100644 index 000000000..1dbfdcfe5 --- /dev/null +++ b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/zh-CN/enum/wechat_direct_app_type.json @@ -0,0 +1,5 @@ +{ + "official_account": "公众号", + "mini_program": "小程序", + "mobile_app": "移动应用" +} diff --git a/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/zh-CN/enum/wechat_isv_app_type.json b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/zh-CN/enum/wechat_isv_app_type.json new file mode 100644 index 000000000..1dbfdcfe5 --- /dev/null +++ b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/zh-CN/enum/wechat_isv_app_type.json @@ -0,0 +1,5 @@ +{ + "official_account": "公众号", + "mini_program": "小程序", + "mobile_app": "移动应用" +} diff --git a/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/zh-CN/error/channel/douyin.json b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/zh-CN/error/channel/douyin.json new file mode 100644 index 000000000..c1506577b --- /dev/null +++ b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/zh-CN/error/channel/douyin.json @@ -0,0 +1,7 @@ +{ + "mchAppNotFound": "抖音通道商户应用不存在", + "appIdDuplicate": "同一通道商户下抖音应用ID已存在", + "directMchDuplicate": "同一商户下该抖音直连商户已存在", + "appTypeInvalid": "抖音应用类型无效", + "capabilityDuplicate": "同一通道商户下支付能力[{0}]重复配置" +} diff --git a/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/zh-CN/error/channel/wechat.json b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/zh-CN/error/channel/wechat.json index 6789ead3f..947a7da0d 100644 --- a/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/zh-CN/error/channel/wechat.json +++ b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/zh-CN/error/channel/wechat.json @@ -44,5 +44,6 @@ "publicKeyRequired": "支付公钥未配置", "privateCertRequired": "API证书未配置", "subMerchantDuplicate": "该微信应用下已存在此特约商户号", - "directMchDuplicate": "同一商户下该微信商户已存在" + "directMchDuplicate": "同一商户下该微信商户已存在", + "capabilityDuplicate": "微信支付能力[{0}]重复配置" }