feat(channel): 支付宝转账校验收紧(仅user_id/login_name/姓名/金额下限/大额理由) + 抖音手机号收款与转账号格式校验

This commit is contained in:
daxpay
2026-08-07 22:18:24 +08:00
parent cd1384fcc3
commit c88a42ccf8
6 changed files with 82 additions and 19 deletions

View File

@@ -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());

View File

@@ -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<String> 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");
}
}
/// 发起转账

View File

@@ -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;

View File

@@ -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));

View File

@@ -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<String> 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必填

View File

@@ -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;