diff --git a/daxpay-channel/daxpay-channel-alipay/src/main/java/cn/daxpay/open/channel/alipay/service/payment/transfer/AlipayTransferService.java b/daxpay-channel/daxpay-channel-alipay/src/main/java/cn/daxpay/open/channel/alipay/service/payment/transfer/AlipayTransferService.java index 708735869..ef486f4f1 100644 --- a/daxpay-channel/daxpay-channel-alipay/src/main/java/cn/daxpay/open/channel/alipay/service/payment/transfer/AlipayTransferService.java +++ b/daxpay-channel/daxpay-channel-alipay/src/main/java/cn/daxpay/open/channel/alipay/service/payment/transfer/AlipayTransferService.java @@ -51,13 +51,13 @@ public class AlipayTransferService { /// 执行支付宝转账 /// - /// @param context 转账策略上下文(通道特有字段: payeeType/payeeAccount/payeeName/transferSceneConfigId) + /// @param context 转账策略上下文(通道特有字段: payeeType/payeeAccount/payeeName/transferScene) /// @param credential 通道调用凭证 /// @return 转账结果 public TransferResultBo transfer(TransferStrategyContext context, AlipaySdkCredential credential) { - // 转账场景配置(2026 新商户必填): 显式 configId 优先, 否则用通道商户默认 + // 转账场景配置(2026 新商户必填): 显式场景配置ID优先, 否则用通道商户默认 AlipayTransferSceneConfig scene = alipayTransferSceneConfigService.findEffective( - context.getChannelMchNo(), context.getTransferSceneConfigId()); + context.getChannelMchNo(), context.getTransferScene()); AlipayTransferReq req = new AlipayTransferReq(); req.setOutBizNo(context.getTransferNo()); diff --git a/daxpay-channel/daxpay-channel-alipay/src/main/java/cn/daxpay/open/channel/alipay/strategy/direct/transfer/AlipayTransferStrategy.java b/daxpay-channel/daxpay-channel-alipay/src/main/java/cn/daxpay/open/channel/alipay/strategy/direct/transfer/AlipayTransferStrategy.java index 4c4b757dc..c113c3784 100644 --- a/daxpay-channel/daxpay-channel-alipay/src/main/java/cn/daxpay/open/channel/alipay/strategy/direct/transfer/AlipayTransferStrategy.java +++ b/daxpay-channel/daxpay-channel-alipay/src/main/java/cn/daxpay/open/channel/alipay/strategy/direct/transfer/AlipayTransferStrategy.java @@ -17,25 +17,33 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; +import java.math.BigDecimal; import java.util.Set; /// # 支付宝直连转账策略 /// /// 支付宝单笔转账(uni.transfer)的通道策略。 /// 通道差异: -/// - 支持三种收款人类型([TransferPayeeTypeEnum]: user_id/open_id/login_name) -/// - 收款人姓名非必填, 但部分场景(转账到他人账号)支付宝要求姓名校验 +/// - 支持两种收款人类型([TransferPayeeTypeEnum]: user_id/login_name) +/// - 收款人姓名: 登录号(login_name)转账必填, 其余场景可选(填了则校验姓名一致性) +/// - 转账金额最低 0.1 元(文档 trans_amount 取值范围) +/// - 转账金额达到 50000 元时, 付款理由(remark)必填(监管要求) @Slf4j @Service @RequiredArgsConstructor public class AlipayTransferStrategy extends AbsTransferStrategy { - /// 支付宝支持的收款人类型 + /// 支付宝支持的收款人类型(仅会员ID/登录号, 不支持开放ID) private static final Set SUPPORTED_PAYEE_TYPES = Set.of( TransferPayeeTypeEnum.USER_ID.getCode(), - TransferPayeeTypeEnum.OPEN_ID.getCode(), TransferPayeeTypeEnum.LOGIN_NAME.getCode()); + /// 转账金额下限(元, 文档 trans_amount 取值范围 [0.1, 100000000]) + private static final BigDecimal AMOUNT_MIN = new BigDecimal("0.1"); + + /// 大额档位(元): 达到该金额后付款理由必填(错误码 MEMO_REQUIRED_IN_TRANSFER_ERROR) + private static final BigDecimal LARGE_AMOUNT_LIMIT = new BigDecimal("50000"); + private final AlipayTransferService alipayTransferService; private final AlipayDirectConfigAssembler alipayDirectConfigAssembler; private final AlipayTransferConfigManager alipayTransferConfigManager; @@ -63,6 +71,25 @@ public class AlipayTransferStrategy extends AbsTransferStrategy { throw new BizInfoException(CommonErrorCode.VALIDATE_PARAMETERS_ERROR, "error.channel.alipay.transferTitleRequired"); } + // 登录号(login_name)转账时姓名必填(文档 payee_info.name 条件必填) + if (TransferPayeeTypeEnum.LOGIN_NAME.getCode().equals(param.getPayeeType()) + && StrUtil.isBlank(param.getPayeeName())) { + // 支付宝: 登录号收款时必须填写收款人姓名 + throw new BizInfoException(CommonErrorCode.VALIDATE_PARAMETERS_ERROR, + "error.channel.alipay.transferLogonNameRequired"); + } + // 转账金额不可低于 0.1 元 + if (param.getAmount().compareTo(AMOUNT_MIN) < 0) { + // 支付宝: 转账金额不可低于0.1元 + throw new BizInfoException(CommonErrorCode.VALIDATE_PARAMETERS_ERROR, + "error.channel.alipay.transferAmountMin"); + } + // 转账金额达到 50000 元时, 付款理由必填(监管要求, 错误码 MEMO_REQUIRED_IN_TRANSFER_ERROR) + if (param.getAmount().compareTo(LARGE_AMOUNT_LIMIT) >= 0 && StrUtil.isBlank(param.getReason())) { + // 支付宝: 转账金额达到50000元时, 必须填写付款理由 + throw new BizInfoException(CommonErrorCode.VALIDATE_PARAMETERS_ERROR, + "error.channel.alipay.transferRemarkRequired"); + } } /// 发起转账 diff --git a/daxpay-channel/daxpay-channel-douyin/src/main/java/cn/daxpay/open/channel/douyin/client/req/DouyinTransferReq.java b/daxpay-channel/daxpay-channel-douyin/src/main/java/cn/daxpay/open/channel/douyin/client/req/DouyinTransferReq.java index 4b631e768..52f619f60 100644 --- a/daxpay-channel/daxpay-channel-douyin/src/main/java/cn/daxpay/open/channel/douyin/client/req/DouyinTransferReq.java +++ b/daxpay-channel/daxpay-channel-douyin/src/main/java/cn/daxpay/open/channel/douyin/client/req/DouyinTransferReq.java @@ -21,9 +21,12 @@ public class DouyinTransferReq { /// 转账金额(分, 同步可空) private Long amount; - /// 收款人 openid + /// 收款人 openid(与手机号二选一, 不可同时填入) private String openid; + /// 收款人手机号(phone 模式, 子应用证书加密上送; 与 openid 二选一, 不可同时填入) + private String phoneNumber; + /// 转账场景ID(transfer_scene_id, 主数据枚举1001-1007) private String scene; diff --git a/daxpay-channel/daxpay-channel-douyin/src/main/java/cn/daxpay/open/channel/douyin/service/payment/transfer/DouyinTransferService.java b/daxpay-channel/daxpay-channel-douyin/src/main/java/cn/daxpay/open/channel/douyin/service/payment/transfer/DouyinTransferService.java index 400facfa6..640cc2552 100644 --- a/daxpay-channel/daxpay-channel-douyin/src/main/java/cn/daxpay/open/channel/douyin/service/payment/transfer/DouyinTransferService.java +++ b/daxpay-channel/daxpay-channel-douyin/src/main/java/cn/daxpay/open/channel/douyin/service/payment/transfer/DouyinTransferService.java @@ -8,6 +8,7 @@ import cn.daxpay.open.payment.common.result.DaxResult; import cn.daxpay.open.payment.strategy.transfer.TransferStrategyContext; import cn.daxpay.open.payment.trade.enums.PayFundStatusEnum; import cn.daxpay.open.payment.trade.transfer.bo.TransferResultBo; +import cn.daxpay.open.payment.trade.transfer.enums.TransferPayeeTypeEnum; import cn.daxpay.open.platform.core.code.DaxPayErrorCode; import cn.daxpay.open.platform.core.exception.BizInfoException; import cn.daxpay.open.platform.system.service.config.infra.PlatformUrlConfigService; @@ -37,19 +38,23 @@ public class DouyinTransferService { /// 执行抖音转账 /// - /// @param context 转账策略上下文(通道特有字段: payeeAccount/payeeName/transferScene) + /// @param context 转账策略上下文(通道特有字段: payeeType/payeeAccount/payeeName/transferScene) /// @param credential 通道调用凭证 /// @return 转账结果 public TransferResultBo transfer(TransferStrategyContext context, DouyinSdkCredential credential) { DouyinTransferReq req = new DouyinTransferReq(); req.setOutBillNo(context.getTransferNo()); req.setAmount(context.getAmount()); - req.setOpenid(context.getPayeeAccount()); + // 收款人: openid 或手机号(phone 模式, 子应用证书加密上送) + if (TransferPayeeTypeEnum.PHONE.getCode().equals(context.getPayeeType())) { + req.setPhoneNumber(context.getPayeeAccount()); + } else { + req.setOpenid(context.getPayeeAccount()); + } req.setScene(context.getTransferScene()); req.setUserName(context.getPayeeName()); req.setRemark(StrUtil.sub(context.getTitle(), 0, 32)); - // 收款感知使用请求参数(按场景枚举选项), 不再用转账原因顶替 - req.setPerception(context.getUserRecvPerception()); + // 用户收款感知不传, 抖音按场景取默认(第一个) // 转账场景报备信息(按场景要求填写) req.setReportInfos(context.getReportInfos()); req.setNotifyUrl(this.buildNotifyUrl(context)); diff --git a/daxpay-channel/daxpay-channel-douyin/src/main/java/cn/daxpay/open/channel/douyin/strategy/transfer/DouyinTransferStrategy.java b/daxpay-channel/daxpay-channel-douyin/src/main/java/cn/daxpay/open/channel/douyin/strategy/transfer/DouyinTransferStrategy.java index f7eb0fa4f..6d5a0e2da 100644 --- a/daxpay-channel/daxpay-channel-douyin/src/main/java/cn/daxpay/open/channel/douyin/strategy/transfer/DouyinTransferStrategy.java +++ b/daxpay-channel/daxpay-channel-douyin/src/main/java/cn/daxpay/open/channel/douyin/strategy/transfer/DouyinTransferStrategy.java @@ -19,14 +19,19 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; +import java.util.Set; + /// # 抖音直连转账策略 /// /// 抖音商家转账的通道策略。 /// 通道差异: -/// - openid 收款人([TransferPayeeTypeEnum#OPENID]), 收款人 openId 由「转账发起应用」(网站应用)承接 H5 授权 +/// - 收款人支持 openid / phone(手机号) 两种([TransferPayeeTypeEnum]), +/// 手机号需与抖音支付实名一致, 由子应用证书加密上送; +/// 收款人 openId 由「转账发起应用」(网站应用)承接 H5 授权 /// - transfer_scene_id 为主数据枚举(1001-1007), 发起转账时由前端选择传入, 无需预配置 /// - 转账发起应用由通道商户的转账配置([DouyinTransferConfig])显式指定 /// - 金额大于等于 2000 元必填收款人姓名(子应用加密上送) +/// - 商户转账号(out_bill_no)规则: 6-32位, 仅数字/大小写字母/_/-/* @Slf4j @Service @RequiredArgsConstructor @@ -35,6 +40,14 @@ public class DouyinTransferStrategy extends AbsTransferStrategy { /// 大额档位(分): 大于等于该金额必填姓名 private static final long LARGE_AMOUNT_LIMIT = 200_000L; + /// 商户转账号规则(数字/大小写字母/_-*) + private static final String OUT_BILL_NO_PATTERN = "^[0-9A-Za-z_\\-*]{6,32}$"; + + /// 抖音支持的收款人类型 + private static final Set SUPPORTED_PAYEE_TYPES = Set.of( + TransferPayeeTypeEnum.OPENID.getCode(), + TransferPayeeTypeEnum.PHONE.getCode()); + private final DouyinTransferService douyinTransferService; private final DouyinDirectConfigAssembler douyinDirectConfigAssembler; private final DouyinTransferConfigManager douyinTransferConfigManager; @@ -47,11 +60,18 @@ public class DouyinTransferStrategy extends AbsTransferStrategy { /// 通道特有参数校验 @Override public void doValidateParam(TransferParam param) { - // 抖音仅支持 openid 收款 - if (!StrUtil.equals(param.getPayeeType(), TransferPayeeTypeEnum.OPENID.getCode())) { - // 抖音: 仅支持 openid 收款人 + // 抖音仅支持 openid / phone(手机号) 收款 + if (!SUPPORTED_PAYEE_TYPES.contains(param.getPayeeType())) { + // 抖音: 不支持的收款人账号类型 throw new BizInfoException(CommonErrorCode.VALIDATE_PARAMETERS_ERROR, - "error.channel.douyin.transferOnlyOpenid"); + "error.channel.douyin.transferPayeeTypeInvalid", param.getPayeeType()); + } + // 手机号模式: 11位数字且与抖音支付实名一致 + if (TransferPayeeTypeEnum.PHONE.getCode().equals(param.getPayeeType()) + && !param.getPayeeAccount().matches("^\\d{11}$")) { + // 抖音: 收款手机号格式不正确(需为11位数字) + throw new BizInfoException(CommonErrorCode.VALIDATE_PARAMETERS_ERROR, + "error.channel.douyin.transferPhoneInvalid"); } long amountFen = param.getAmount().movePointRight(2).longValue(); if (amountFen >= LARGE_AMOUNT_LIMIT && StrUtil.isBlank(param.getPayeeName())) { @@ -59,6 +79,12 @@ public class DouyinTransferStrategy extends AbsTransferStrategy { throw new BizInfoException(CommonErrorCode.VALIDATE_PARAMETERS_ERROR, "error.channel.douyin.transferNameRequired"); } + // 商户转账号规则(数字/大小写字母/_-*, 6-32位) + if (!param.getBizTransferNo().matches(OUT_BILL_NO_PATTERN)) { + // 抖音: 商户转账号格式不正确(6-32位, 仅数字/大小写字母/_/-/*) + throw new BizInfoException(CommonErrorCode.VALIDATE_PARAMETERS_ERROR, + "error.channel.douyin.transferOutBillNoInvalid"); + } // 转账场景ID必填且必须为合法枚举 if (StrUtil.isBlank(param.getTransferScene())) { // 抖音: 转账场景ID必填 diff --git a/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/trade/transfer/enums/TransferPayeeTypeEnum.java b/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/trade/transfer/enums/TransferPayeeTypeEnum.java index 192a5dbf5..cc8d3dd7b 100644 --- a/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/trade/transfer/enums/TransferPayeeTypeEnum.java +++ b/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/trade/transfer/enums/TransferPayeeTypeEnum.java @@ -11,8 +11,8 @@ import java.util.Arrays; /// /// 转账收款人账号类型,按通道差异化使用: /// - 微信: 仅支持 openid -/// - 支付宝: 支持 user_id / open_id / login_name -/// - 抖音: 按通道能力选取 +/// - 支付宝: 支持 user_id / login_name +/// - 抖音: 支持 openid / phone(手机号, 复用收款人账号字段) /// 字典: transfer_payee_type @Getter @RequiredArgsConstructor @@ -26,6 +26,8 @@ public enum TransferPayeeTypeEnum implements I18nSupport { OPEN_ID("open_id"), /// 支付宝登录账号(手机号/邮箱) LOGIN_NAME("login_name"), + /// 抖音收款手机号(需抖音支付实名一致, 子应用证书加密上送) + PHONE("phone"), ; private final String code;