feat(transfer): 微信/支付宝/抖音转账异步回调落地, README 补充转账与风控体系说明

This commit is contained in:
daxpay
2026-08-06 17:49:57 +08:00
parent 170eb951fa
commit d71d0adc4f
28 changed files with 633 additions and 25 deletions

View File

@@ -22,13 +22,14 @@
## 核心特性
- **支付核心能力** — 覆盖支付、退款、查询、回调、同步、关闭、商户通知等完整交易闭环,支持全额/部分退款与差错处理
- **支付核心能力** — 覆盖支付、退款、转账、查询、回调、同步、关闭、商户通知等完整交易闭环,支持全额/部分退款与差错处理,转账支持微信 / 支付宝 / 抖音单笔转账与失败重试
- **通道编排与对接分离** — 主应用负责通道路由、策略编排与配置管理,第三方 SDK 下沉到独立子应用,支持**独立部署、独立升级、弹性伸缩**
- **Java + Go 双语言实现** — 通道子应用同时提供 Java(Spring Boot)与 Go(Gin)两套对等实现,按团队技术栈与性能诉求按需选用
- **统一 HTTP 接口** — 各通道封装为 RESTful 标准协议,业务系统一次对接、多通道通用,接口请求与响应支持 RSA 签名
- **多商户与服务商模式** — 运营端 + 商户端双入口架构,菜单与数据按 `client_code` 隔离,每个商户数据通过商户编号行级隔离
- **全端覆盖** — 运营/商户 Web 管理端 + PC 与移动双端 H5 网关;管理端小程序、商户端小程序、收银小程序为商业扩展包
- **安全签名与字段加密** — RSA 双向验签 + AES-256-GCM 字段级加密 + 双重 Nonce 防重放,Sa-Token 权限认证
- **风控体系** — 平台级风控策略 + 商户级地理围栏两级门控:黑名单(IP / 支付宝 userId / 微信 openId / 省市区划)、海外 IP 拦截、门店地理围栏,交易前阻断 + 交易后补录命中记录
- **国际化与时区** — 中日韩 + 东盟 10 语种支持,时间字段统一UTC 存储,适配各时区情况
- **沙箱 / 生产部署级隔离** — 通过部署隔离 + 全局开关让"沙箱"概念在生产部署中根本不存在,启动期 fail-fast 强制对齐
@@ -75,7 +76,7 @@ DaxPay 在接口、传输、存储、认证多个层面构建安全防护:
| 插件 | 说明 |
|-------------------------|---------------------------------------------------------------------------------------|
| `daxpay-plugin-risk` | 支付风控插件 — 黑名单管理 + 命中记录 + 默认风控检查器,在支付流程中提供风险拦截 |
| `daxpay-plugin-risk` | 支付风控插件 — 黑名单(IP / 用户标识 / 省市地区)+ 海外 IP 拦截 + 门店地理围栏 + 命中记录,交易前阻断 / 交易后补录,支持平台策略与商户级 opt-in 两级门控 |
| `daxpay-plugin-easypay` | 易支付协议插件 — 实现易支付的订单/退款/凭证/配置对接,可直接与支持易支付协议的系统互通 |
## 核心技术栈

View File

@@ -10,6 +10,7 @@ import cn.daxpay.open.channel.alipay.client.req.AlipaySyncReq;
import cn.daxpay.open.channel.alipay.client.req.AlipayTransferReq;
import cn.daxpay.open.channel.alipay.client.resp.AlipayAppAuthTokenResp;
import cn.daxpay.open.channel.alipay.client.resp.AlipayCallbackParseResp;
import cn.daxpay.open.channel.alipay.client.resp.AlipayTransferCallbackParseResp;
import cn.daxpay.open.channel.alipay.client.resp.AlipayCloseResp;
import cn.daxpay.open.channel.alipay.client.resp.AlipayPayResp;
import cn.daxpay.open.channel.alipay.client.resp.AlipayRefundResp;
@@ -64,6 +65,10 @@ public interface AlipayChannelClient {
@PostExchange("/channel/alipay/callback/parse-refund")
DaxResult<AlipayCallbackParseResp> parseRefundCallback(@RequestBody AlipayCallbackParseReq req);
/// 转账回调验签解析(转发子应用)
@PostExchange("/channel/alipay/callback/parse-transfer")
DaxResult<AlipayTransferCallbackParseResp> parseTransferCallback(@RequestBody AlipayCallbackParseReq req);
/// 应用授权码换取 app_auth_token(代运营授权)
@PostExchange("/channel/alipay/auth/app-token")
DaxResult<AlipayAppAuthTokenResp> exchangeAppAuthToken(@RequestBody AlipayAppAuthTokenReq req);

View File

@@ -30,6 +30,9 @@ public class AlipayTransferReq {
/// 收款人姓名
private String payeeName;
/// 异步通知地址(支付宝→平台)
private String notifyUrl;
/// 通道调用凭证
private AlipaySdkCredential credential;
}

View File

@@ -8,7 +8,7 @@ import java.time.OffsetDateTime;
/// # 支付宝回调验签解析响应(子应用 → 主应用)
///
/// 与子应用 dax-pay-channel-one 的 `AlipayCallbackParseResp` 镜像。
/// 兼容支付退款两种回调: 通过 tradeType 区分。
/// 仅承载支付/退款回调; 转账回调见 [AlipayTransferCallbackParseResp]。通过 tradeType 区分。
@Data
@Accessors(chain = true)
public class AlipayCallbackParseResp {

View File

@@ -0,0 +1,33 @@
package cn.daxpay.open.channel.alipay.client.resp;
import lombok.Data;
import lombok.experimental.Accessors;
import java.time.OffsetDateTime;
/// # 支付宝转账回调验签解析响应(子应用 → 主应用)
///
/// 与子应用 dax-pay-channel-one 的 `AlipayTransferCallbackParseResp` 镜像。
/// 仅承载转账回调的结构化业务数据, 与支付/退款回调响应 [AlipayCallbackParseResp] 解耦。
@Data
@Accessors(chain = true)
public class AlipayTransferCallbackParseResp {
/// 是否验签通过
private Boolean success;
/// 商户转账单号(转账回调: out_biz_no = 平台转账单号 transferNo)
private String outBizNo;
/// 支付宝转账单号(转账回调: order_id = 通道转账单号 outTransferNo)
private String orderId;
/// 转账状态(转账回调: 原始 status, SUCCESS/FAIL/DEALING/REFUND/CLOSED)
private String transferStatus;
/// 转账失败原因(转账回调: sub_msg)
private String failReason;
/// 完成时间(转账回调: pay_date, 东八区 OffsetDateTime)
private OffsetDateTime finishTime;
}

View File

@@ -27,14 +27,14 @@ public class AlipayCallbackController {
private final MerchantContextLoader merchantContextLoader;
private final AlipayCallbackService alipayCallbackService;
/// 支付宝回调(支付/退款统一入口, 由服务层按表单参数区分)
@Operation(summary = "支付宝支付/退款回调")
/// 支付宝回调(支付/退款/转账统一入口, 由服务层按表单参数区分)
@Operation(summary = "支付宝支付/退款/转账回调")
@PostMapping
public String notify(@PathVariable String mchNo,
@PathVariable String channelMchNo,
HttpServletRequest request) {
// 显式装载商户租户上下文(不校验启用, 保证禁用商户历史单可回调)
merchantContextLoader.bindMchNoForCallback(mchNo);
return alipayCallbackService.handle(channelMchNo, request);
return alipayCallbackService.handle(mchNo, channelMchNo, request);
}
}

View File

@@ -4,6 +4,7 @@ import cn.daxpay.open.channel.alipay.client.AlipayChannelClient;
import cn.daxpay.open.channel.alipay.client.credential.AlipaySdkCredential;
import cn.daxpay.open.channel.alipay.client.req.AlipayCallbackParseReq;
import cn.daxpay.open.channel.alipay.client.resp.AlipayCallbackParseResp;
import cn.daxpay.open.channel.alipay.client.resp.AlipayTransferCallbackParseResp;
import cn.daxpay.open.channel.alipay.service.direct.AlipayDirectConfigAssembler;
import cn.daxpay.open.channel.alipay.service.isv.AlipayIsvConfigAssembler;
import cn.daxpay.open.payment.trade.order.dao.PayTradeManager;
@@ -16,6 +17,7 @@ import cn.daxpay.open.payment.trade.runtime.bo.RefundCallbackData;
import cn.daxpay.open.payment.trade.runtime.service.PayTradeContainerFields;
import cn.daxpay.open.payment.trade.runtime.service.callback.PayCallbackService;
import cn.daxpay.open.payment.trade.runtime.service.callback.RefundCallbackService;
import cn.daxpay.open.payment.trade.transfer.runtime.service.TransferCallbackService;
import cn.daxpay.open.platform.core.enums.pay.channel.ProductEnum;
import cn.daxpay.open.platform.core.enums.pay.notice.CallbackStatusEnum;
import cn.hutool.core.util.StrUtil;
@@ -28,11 +30,12 @@ import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
/// # 支付宝回调处理服务(支付/退款统一入口)
/// # 支付宝回调处理服务(支付/退款/转账统一入口)
///
/// 支付宝支付退款共用同一回调端点 `/unipay/callback/{mchNo}/{channelMchNo}/alipay`(无 /pay 后缀,
/// 见 AlipayPayService.buildNotifyUrl), 通过表单参数区分:
/// 支付宝支付退款与转账共用同一回调端点 `/unipay/callback/{mchNo}/{channelMchNo}/alipay`(无 /pay 后缀,
/// 见 AlipayPayService.buildNotifyUrl / AlipayTransferService.buildNotifyUrl), 通过表单参数区分:
/// - 含 `out_request_no` → 退款回调
/// - 含 `out_biz_no`(且无 out_request_no/out_trade_no) → 转账回调
/// - 否则 → 支付回调
///
/// 流程: 主应用接收原始表单 → 凭 out_trade_no/out_request_no 反查订单(channelMchNo 可加速/校验) →
@@ -54,12 +57,14 @@ public class AlipayCallbackService {
private final RefundOrderManager payRefundOrderManager;
private final PayCallbackService payCallbackService;
private final RefundCallbackService refundCallbackService;
private final TransferCallbackService transferCallbackService;
private final PayCallbackRecordService payCallbackRecordService;
/// 回调统一处理(自动区分支付/退款)
/// 回调统一处理(自动区分支付/退款/转账)
///
/// @param mchNo 商户号(转账回调凭证组装; 支付/退款凭证反查自订单)
/// @param channelMchNo 路径上的通道商户号(直连凭证定位; 服务商模式仍以订单 product 为准)
public String handle(String channelMchNo, HttpServletRequest request) {
public String handle(String mchNo, String channelMchNo, HttpServletRequest request) {
// 1. 提取全部表单参数
Map<String, String> params = extractFormParams(request);
if (params.isEmpty()) {
@@ -72,12 +77,17 @@ public class AlipayCallbackService {
return NOTIFY_FAIL;
}
// 2. 区分支付/退款(退款回调含 out_request_no)
// 2. 区分支付/退款/转账: 退款含 out_request_no, 转账含 out_biz_no(且无 out_request_no/out_trade_no)
boolean refund = params.containsKey("out_request_no");
boolean transfer = !refund && params.containsKey("out_biz_no")
&& !params.containsKey("out_trade_no");
try {
if (refund) {
return handleRefund(channelMchNo, params);
}
if (transfer) {
return handleTransfer(mchNo, channelMchNo, params);
}
return handlePay(channelMchNo, params);
} catch (Exception e) {
log.error("支付宝回调业务处理失败", e);
@@ -88,6 +98,14 @@ public class AlipayCallbackService {
failData.setCallbackStatus(CallbackStatusEnum.EXCEPTION);
failData.setCallbackErrorMsg(e.getMessage());
payCallbackRecordService.saveRefund(channelMchNo, failData);
} else if (transfer) {
CallbackData failData = new CallbackData();
failData.setCallbackData(params);
failData.setTradeNo(params.get("out_biz_no"));
failData.setOutTradeNo(params.get("order_id"));
failData.setCallbackStatus(CallbackStatusEnum.EXCEPTION);
failData.setCallbackErrorMsg(e.getMessage());
payCallbackRecordService.saveTransfer(channelMchNo, failData);
} else {
CallbackData failData = new CallbackData();
failData.setCallbackData(params);
@@ -199,7 +217,62 @@ public class AlipayCallbackService {
return NOTIFY_SUCCESS;
}
/// 转发子应用验签解析
/// 转账回调处理
///
/// 转账无服务商模式, 凭 path 上的 mchNo + channelMchNo 组装直连凭证(与发起路径
/// [cn.daxpay.open.channel.alipay.strategy.direct.transfer.AlipayTransferStrategy#buildCredential] 一致, capability=null)。
/// 状态映射与同步路径([cn.daxpay.open.channel.alipay.service.payment.transfer.AlipayTransferService])对齐:
/// SUCCESS → success / FAIL·CLOSED → close / DEALING·REFUND·未知 → 直接返回成功应答, 不调
/// [TransferCallbackService#transferCallback], 避免被 [TransferCallbackService#doTransferCallback]
/// 对非 success/close 的一律 fail 处理误置失败。
private String handleTransfer(String mchNo, String channelMchNo, Map<String, String> params) {
// 转账仅直连, 凭 path 通道商户号组装直连凭证(转账无能力维度)
AlipaySdkCredential credential = alipayDirectConfigAssembler.buildConfig(mchNo, channelMchNo, null);
AlipayTransferCallbackParseResp resp = parseTransfer(params, credential);
if (resp == null || !Boolean.TRUE.equals(resp.getSuccess())) {
log.error("支付宝转账回调验签失败: outBizNo={}", params.get("out_biz_no"));
CallbackData failData = new CallbackData();
failData.setCallbackData(params);
failData.setTradeNo(params.get("out_biz_no"));
failData.setOutTradeNo(params.get("order_id"));
failData.setCallbackStatus(CallbackStatusEnum.FAIL);
failData.setCallbackErrorMsg("支付宝转账回调验签失败");
payCallbackRecordService.saveTransfer(channelMchNo, failData);
return NOTIFY_FAIL;
}
String status = resp.getTransferStatus();
// 中间态(DEALING/REFUND/未知): 直接返回成功应答, 不调 transferCallback(避免被误置失败)
boolean terminalSuccess = Objects.equals(status, "SUCCESS");
boolean terminalClose = Objects.equals(status, "FAIL") || Objects.equals(status, "CLOSED");
if (!terminalSuccess && !terminalClose) {
log.info("支付宝转账回调中间态, 忽略: outBizNo={}, status={}", resp.getOutBizNo(), status);
return NOTIFY_SUCCESS;
}
CallbackData callbackData = new CallbackData();
callbackData.setCallbackData(params);
// 反查字段: outBizNo=平台转账单号 transferNo / orderId=通道转账单号 outTransferNo
callbackData.setTradeNo(resp.getOutBizNo());
callbackData.setOutTradeNo(resp.getOrderId());
callbackData.setFinishTime(resp.getFinishTime());
if (terminalSuccess) {
callbackData.setTradeStatus(CallbackStatusEnum.SUCCESS.getCode());
} else {
callbackData.setTradeStatus(CallbackStatusEnum.CLOSE.getCode());
callbackData.setTradeErrorMsg(resp.getFailReason());
}
try {
// transferCallback 内部已落回调记录, 此处不再重复保存
transferCallbackService.transferCallback(channelMchNo, "alipay", callbackData);
} catch (Exception e) {
log.error("支付宝转账回调业务处理失败: tradeNo={}", callbackData.getTradeNo(), e);
callbackData.setCallbackStatus(CallbackStatusEnum.EXCEPTION).setCallbackErrorMsg(e.getMessage());
payCallbackRecordService.saveTransfer(channelMchNo, callbackData);
return NOTIFY_FAIL;
}
return NOTIFY_SUCCESS;
}
/// 转发子应用验签解析(支付/退款)
private AlipayCallbackParseResp parse(Map<String, String> params, AlipaySdkCredential credential, boolean refund) {
AlipayCallbackParseReq req = new AlipayCallbackParseReq();
req.setCredential(credential);
@@ -214,6 +287,19 @@ public class AlipayCallbackService {
return result.getData();
}
/// 转发子应用验签解析(转账)
private AlipayTransferCallbackParseResp parseTransfer(Map<String, String> params, AlipaySdkCredential credential) {
AlipayCallbackParseReq req = new AlipayCallbackParseReq();
req.setCredential(credential);
req.setParams(params);
var result = alipayChannelClient.parseTransferCallback(req);
if (result.getCode() != 0) {
log.error("支付宝转账回调: 子应用解析失败: {}", result.getMsg());
return null;
}
return result.getData();
}
/// 凭原支付交易号反查通道凭证(直连/服务商自动分发)
///
/// @param pathChannelMchNo 回调 path 上的通道商户号; 订单侧 channelMchNo 优先, 空则用 path

View File

@@ -10,6 +10,8 @@ import cn.daxpay.open.payment.trade.enums.PayFundStatusEnum;
import cn.daxpay.open.payment.trade.transfer.bo.TransferResultBo;
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;
import cn.hutool.core.util.StrUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
@@ -26,6 +28,7 @@ import java.util.Objects;
public class AlipayTransferService {
private final AlipayChannelClient alipayChannelClient;
private final PlatformUrlConfigService platformUrlConfigService;
/// 执行支付宝转账
///
@@ -41,6 +44,7 @@ public class AlipayTransferService {
req.setPayeeType(context.getPayeeType());
req.setPayeeAccount(context.getPayeeAccount());
req.setPayeeName(context.getPayeeName());
req.setNotifyUrl(this.buildNotifyUrl(context));
req.setCredential(credential);
DaxResult<AlipayTransferResp> result = alipayChannelClient.transfer(req);
@@ -97,4 +101,19 @@ public class AlipayTransferService {
}
return bo;
}
/// 生成支付宝转账异步通知地址(支付宝→平台)
///
/// 路径约定: `{backendBaseUrl}/unipay/callback/{mchNo}/{channelMchNo}/alipay`
/// (与支付/退款共用统一回调端点, 由 [cn.daxpay.open.channel.alipay.service.callback.AlipayCallbackService]
/// 按表单参数区分: 含 out_biz_no 且无 out_request_no/out_trade_no → 转账回调)
private String buildNotifyUrl(TransferStrategyContext context) {
String base = platformUrlConfigService.getUrlConfig().getBackendBaseUrl();
if (StrUtil.isBlank(base)) {
// backendBaseUrl 未配置时抛清晰异常
throw new BizInfoException(DaxPayErrorCode.CONFIG_ERROR, "error.common.backendBaseUrlNotConfigured");
}
return StrUtil.format("{}/unipay/callback/{}/{}/alipay",
base, context.getMchNo(), context.getChannelMchNo());
}
}

View File

@@ -8,6 +8,7 @@ import cn.daxpay.open.channel.douyin.client.req.DouyinRefundSyncReq;
import cn.daxpay.open.channel.douyin.client.req.DouyinSyncReq;
import cn.daxpay.open.channel.douyin.client.req.DouyinTransferReq;
import cn.daxpay.open.channel.douyin.client.resp.DouyinCallbackParseResp;
import cn.daxpay.open.channel.douyin.client.resp.DouyinTransferCallbackParseResp;
import cn.daxpay.open.channel.douyin.client.resp.DouyinCloseResp;
import cn.daxpay.open.channel.douyin.client.resp.DouyinPayResp;
import cn.daxpay.open.channel.douyin.client.resp.DouyinRefundResp;
@@ -62,4 +63,8 @@ public interface DouyinChannelClient {
/// 退款回调验签解析(转发到子应用验签)
@PostExchange("/channel/douyin/callback/parse-refund")
DaxResult<DouyinCallbackParseResp> parseRefundCallback(@RequestBody DouyinCallbackParseReq req);
/// 转账回调验签解析(转发到子应用验签)
@PostExchange("/channel/douyin/callback/parse-transfer")
DaxResult<DouyinTransferCallbackParseResp> parseTransferCallback(@RequestBody DouyinCallbackParseReq req);
}

View File

@@ -0,0 +1,28 @@
package cn.daxpay.open.channel.douyin.client.resp;
import lombok.Data;
import lombok.experimental.Accessors;
/// # 抖音转账回调验签解析响应(子应用 → 主应用)
///
/// 与子应用 dax-pay-channel-one 的 `DouyinTransferCallbackParseResp` 镜像。
/// 仅承载转账回调的结构化业务数据, 与支付/退款回调响应 [DouyinCallbackParseResp] 解耦。
@Data
@Accessors(chain = true)
public class DouyinTransferCallbackParseResp {
/// 通道转账单号(转账回调: order_id = 通道转账单号 outTransferNo, 抖音转账通知无商户单号)
private String transferBillNo;
/// 转账状态(转账回调: SUCCESS/FAIL/ACCEPTED/TRANSFERING)
private String transferState;
/// 转账状态描述(转账回调: status_desc, 含失败原因)
private String transferStatusDesc;
/// 成功时间(RFC3339, 转账完成时间)
private String successTime;
/// 验签是否通过
private boolean verified;
}

View File

@@ -2,6 +2,7 @@ package cn.daxpay.open.channel.douyin.controller.callback;
import cn.daxpay.open.channel.douyin.service.callback.DouyinPayCallbackService;
import cn.daxpay.open.channel.douyin.service.callback.DouyinRefundCallbackService;
import cn.daxpay.open.channel.douyin.service.callback.DouyinTransferCallbackService;
import cn.daxpay.open.payment.common.context.MerchantContextLoader;
import cn.daxpay.open.platform.core.annotation.IgnoreAuth;
import io.swagger.v3.oas.annotations.Operation;
@@ -27,6 +28,7 @@ public class DouyinCallbackController {
private final MerchantContextLoader merchantContextLoader;
private final DouyinPayCallbackService payCallbackService;
private final DouyinRefundCallbackService refundCallbackService;
private final DouyinTransferCallbackService transferCallbackService;
/// 抖音支付回调
@Operation(summary = "抖音支付回调")
@@ -47,4 +49,14 @@ public class DouyinCallbackController {
merchantContextLoader.bindMchNoForCallback(mchNo);
return refundCallbackService.refundHandle(mchNo, channelMchNo, request);
}
/// 抖音转账回调
@Operation(summary = "抖音转账回调")
@PostMapping("/transfer")
public String transferNotify(@PathVariable("mchNo") String mchNo,
@PathVariable("channelMchNo") String channelMchNo,
HttpServletRequest request) {
merchantContextLoader.bindMchNoForCallback(mchNo);
return transferCallbackService.transferHandle(mchNo, channelMchNo, request);
}
}

View File

@@ -0,0 +1,174 @@
package cn.daxpay.open.channel.douyin.service.callback;
import cn.daxpay.open.channel.douyin.client.DouyinChannelClient;
import cn.daxpay.open.channel.douyin.client.credential.DouyinSdkCredential;
import cn.daxpay.open.channel.douyin.client.req.DouyinCallbackParseReq;
import cn.daxpay.open.channel.douyin.client.resp.DouyinTransferCallbackParseResp;
import cn.daxpay.open.channel.douyin.code.DouyinPayCode;
import cn.daxpay.open.channel.douyin.dao.direct.DouyinDirectChannelMerchantManager;
import cn.daxpay.open.channel.douyin.entity.direct.DouyinDirectChannelMerchant;
import cn.daxpay.open.channel.douyin.entity.direct.DouyinDirectKeyConfig;
import cn.daxpay.open.channel.douyin.service.direct.DouyinDirectKeyConfigService;
import cn.daxpay.open.payment.common.result.DaxResult;
import cn.daxpay.open.payment.trade.record.service.PayCallbackRecordService;
import cn.daxpay.open.payment.trade.runtime.bo.CallbackData;
import cn.daxpay.open.payment.trade.transfer.runtime.service.TransferCallbackService;
import cn.daxpay.open.platform.core.enums.pay.notice.CallbackStatusEnum;
import cn.hutool.core.util.StrUtil;
import cn.hutool.extra.servlet.JakartaServletUtil;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.time.OffsetDateTime;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
/// # 抖音转账回调处理服务
///
/// 抖音商家转账异步通知 → 主应用接收原始 header + body → 按 channelMchNo 组装凭证 →
/// 转发到子应用(channel-one)用 [com.douyinpay.api.notification.NotificationParser] 验签解密 →
/// 构建 [CallbackData] 交由 [TransferCallbackService] 更新转账单状态。
///
/// 状态映射与同步路径([cn.daxpay.open.channel.douyin.service.payment.transfer.DouyinTransferService])对齐:
/// SUCCESS → success / FAIL → close / 其余中间态(ACCEPTED/TRANSFERING)直接返回成功应答, 不调
/// [TransferCallbackService#transferCallback], 避免被 [TransferCallbackService#doTransferCallback]
/// 对非 success/close 的一律 fail 处理误置失败。
///
/// 抖音转账通知([com.douyinpay.api.transfer.models.TransferPayeeNotification])仅含 order_id(通道转账单号),
/// 不含商户单号 out_bill_no, 故 [CallbackData] 的 tradeNo 留空, 反查走 [TransferCallbackService#resolveTrade]
/// 的 outTransferNo 兜底(findByOutTransferNo)。
///
/// 记录保存注意: [TransferCallbackService#transferCallback] 内部已落回调记录,
/// 故本服务在调用成功后不再重复保存(与 [DouyinPayCallbackService] 在 payCallback 后再 save 的模式不同)。
@Slf4j
@Service
@RequiredArgsConstructor
public class DouyinTransferCallbackService {
/// 转账成功状态
private static final String STATE_SUCCESS = "SUCCESS";
/// 转账失败终态
private static final String STATE_FAIL = "FAIL";
/// 转账处理中状态(收到此类通知直接返回成功应答, 不流转状态)
private static final List<String> STATE_PROCESSING = List.of("ACCEPTED", "TRANSFERING");
private final DouyinChannelClient douyinChannelClient;
private final DouyinDirectKeyConfigService keyConfigService;
private final DouyinDirectChannelMerchantManager channelMerchantManager;
private final TransferCallbackService transferCallbackService;
private final PayCallbackRecordService payCallbackRecordService;
/// 转账回调处理
///
/// @param mchNo 商户号(装载租户上下文)
/// @param channelMchNo 通道商户号(凭证组装主键)
/// @param request 原始请求(含抖音验签 header + 加密 body)
/// @return 抖音要求应答串(SUCCESS/FAIL JSON)
public String transferHandle(String mchNo, String channelMchNo, HttpServletRequest request) {
// 1. 提取回调原始数据
String body = JakartaServletUtil.getBody(request);
Map<String, String> headerMap = JakartaServletUtil.getHeaderMap(request);
// 2. 组装凭证(回调验签只需 mchId/serial/privateKey/encryptKey, 不需 douyinAppId)
DouyinSdkCredential credential = this.buildCredential(channelMchNo);
// 3. 转发到子应用验签
DouyinCallbackParseReq req = new DouyinCallbackParseReq();
req.setCredential(credential);
req.setBody(body);
req.setSerial(this.getHeader(headerMap, DouyinPayCode.HEADER_SERIAL));
req.setNonce(this.getHeader(headerMap, DouyinPayCode.HEADER_NONCE));
req.setSignature(this.getHeader(headerMap, DouyinPayCode.HEADER_SIGNATURE));
req.setTimestamp(this.getHeader(headerMap, DouyinPayCode.HEADER_TIMESTAMP));
DaxResult<DouyinTransferCallbackParseResp> result = douyinChannelClient.parseTransferCallback(req);
if (result.getCode() != 0 || result.getData() == null || !result.getData().isVerified()) {
log.error("抖音转账回调验签失败: channelMchNo={}", channelMchNo);
CallbackData failData = new CallbackData();
Map<String, Object> notify = new HashMap<>();
notify.put("body", body);
notify.put("headers", headerMap);
failData.setCallbackData(notify);
failData.setCallbackStatus(CallbackStatusEnum.FAIL);
failData.setCallbackErrorMsg("抖音转账回调验签失败");
payCallbackRecordService.saveTransfer(channelMchNo, failData);
return DouyinPayCode.NOTIFY_FAIL;
}
// 4. 解析结果
DouyinTransferCallbackParseResp resp = result.getData();
String state = resp.getTransferState();
// 处理中中间态: 直接返回成功应答, 不调 transferCallback(避免被 doTransferCallback 误置失败)
if (STATE_PROCESSING.contains(state)) {
log.info("抖音转账回调中间态, 忽略: transferBillNo={}, state={}", resp.getTransferBillNo(), state);
return DouyinPayCode.NOTIFY_SUCCESS;
}
// 5. 构建回调数据
CallbackData callbackData = new CallbackData();
Map<String, Object> notify = new HashMap<>();
notify.put("body", body);
notify.put("transferBillNo", resp.getTransferBillNo());
notify.put("transferState", state);
notify.put("statusDesc", resp.getTransferStatusDesc());
notify.put("successTime", resp.getSuccessTime());
callbackData.setCallbackData(notify);
// 抖音转账通知无商户单号, tradeNo 留空, 反查走 outTransferNo(orderId)兜底
callbackData.setTradeNo(null);
callbackData.setOutTradeNo(resp.getTransferBillNo());
// 完成时间(RFC3339)
if (StrUtil.isNotBlank(resp.getSuccessTime())) {
try {
callbackData.setFinishTime(OffsetDateTime.parse(resp.getSuccessTime()));
} catch (Exception e) {
log.warn("抖音转账回调时间解析失败: successTime={}", resp.getSuccessTime());
}
}
// 状态映射(与同步路径对齐)
if (Objects.equals(state, STATE_SUCCESS)) {
callbackData.setTradeStatus(CallbackStatusEnum.SUCCESS.getCode());
} else if (Objects.equals(state, STATE_FAIL)) {
callbackData.setTradeStatus(CallbackStatusEnum.CLOSE.getCode());
callbackData.setTradeErrorMsg(resp.getTransferStatusDesc());
} else {
// 未知状态保持处理中, 直接返回成功应答不流转, 交后续同步轮询确认
log.warn("抖音转账回调未知状态, 忽略: transferBillNo={}, state={}", resp.getTransferBillNo(), state);
return DouyinPayCode.NOTIFY_SUCCESS;
}
try {
transferCallbackService.transferCallback(channelMchNo, "douyin", callbackData);
} catch (Exception e) {
log.error("抖音转账回调业务处理失败: outTransferNo={}", callbackData.getOutTradeNo(), e);
callbackData.setCallbackStatus(CallbackStatusEnum.EXCEPTION).setCallbackErrorMsg(e.getMessage());
payCallbackRecordService.saveTransfer(channelMchNo, callbackData);
return DouyinPayCode.NOTIFY_FAIL;
}
return DouyinPayCode.NOTIFY_SUCCESS;
}
/// 组装回调验签凭证(只需 mchId + 密钥, 不经过 ConfigAssembler 避免依赖 capability)
private DouyinSdkCredential buildCredential(String channelMchNo) {
DouyinDirectKeyConfig keyConfig = keyConfigService.findByChannelMchNo(channelMchNo);
DouyinDirectChannelMerchant merchant = channelMerchantManager.lambdaQuery()
.eq(DouyinDirectChannelMerchant::getChannelMchNo, channelMchNo)
.oneOpt()
.orElse(null);
var credential = new DouyinSdkCredential();
credential.setMchId(merchant != null ? merchant.getDyMchId() : null);
credential.setMerchantSerialNumber(keyConfig.getMerchantSerialNumber());
credential.setMerchantPrivateKey(keyConfig.getMerchantPrivateKey());
credential.setEncryptKey(keyConfig.getEncryptKey());
return credential;
}
/// 获取 header(大小写兼容)
private String getHeader(Map<String, String> headerMap, String name) {
String value = headerMap.get(name);
return value != null ? value : headerMap.get(name.toLowerCase());
}
}

View File

@@ -8,6 +8,7 @@ import cn.daxpay.open.channel.wechat.client.req.WechatRefundSyncReq;
import cn.daxpay.open.channel.wechat.client.req.WechatSyncReq;
import cn.daxpay.open.channel.wechat.client.req.WechatTransferReq;
import cn.daxpay.open.channel.wechat.client.resp.WechatCallbackParseResp;
import cn.daxpay.open.channel.wechat.client.resp.WechatTransferCallbackParseResp;
import cn.daxpay.open.channel.wechat.client.resp.WechatCloseResp;
import cn.daxpay.open.channel.wechat.client.resp.WechatPayResp;
import cn.daxpay.open.channel.wechat.client.resp.WechatRefundResp;
@@ -84,4 +85,8 @@ public interface WechatChannelClient {
/// 退款回调验签解析(转发到子应用验签)
@PostExchange("/channel/wechat/callback/parse-refund")
DaxResult<WechatCallbackParseResp> parseRefundCallback(@RequestBody WechatCallbackParseReq req);
/// 转账回调验签解析(转发到子应用验签)
@PostExchange("/channel/wechat/callback/parse-transfer")
DaxResult<WechatTransferCallbackParseResp> parseTransferCallback(@RequestBody WechatCallbackParseReq req);
}

View File

@@ -6,7 +6,7 @@ import lombok.experimental.Accessors;
/// # 微信回调验签解析响应
///
/// 子应用使用微信 SDK 验签并解密回调后, 返回结构化业务数据。
/// 兼容支付退款两种回调: 通过 tradeType 区分。
/// 仅承载支付/退款回调; 转账回调见 [WechatTransferCallbackParseResp]。通过 tradeType 区分支付与退款
@Data
@Accessors(chain = true)
public class WechatCallbackParseResp {

View File

@@ -0,0 +1,31 @@
package cn.daxpay.open.channel.wechat.client.resp;
import lombok.Data;
import lombok.experimental.Accessors;
/// # 微信转账回调验签解析响应(子应用 → 主应用)
///
/// 与子应用 dax-pay-channel-one 的 `WechatTransferCallbackParseResp` 镜像。
/// 仅承载转账回调的结构化业务数据, 与支付/退款回调响应 [WechatCallbackParseResp] 解耦。
@Data
@Accessors(chain = true)
public class WechatTransferCallbackParseResp {
/// 商户转账单号(转账回调: out_bill_no = 平台转账单号 transferNo)
private String outBillNo;
/// 微信转账单号(转账回调: transfer_bill_no = 通道转账单号 outTransferNo)
private String transferBillNo;
/// 转账状态(转账回调: SUCCESS/FAIL/CANCELLED/ACCEPTED/PROCESSING/WAIT_USER_CONFIRM/TRANSFERING/CANCELING)
private String transferState;
/// 转账失败原因(转账回调: fail_reason)
private String failReason;
/// 转账状态更新时间(转账回调: update_time, RFC3339 东八区)
private String updateTime;
/// 验签是否通过
private boolean verified;
}

View File

@@ -2,6 +2,7 @@ package cn.daxpay.open.channel.wechat.controller.callback;
import cn.daxpay.open.channel.wechat.service.callback.WechatPayCallbackService;
import cn.daxpay.open.channel.wechat.service.callback.WechatRefundCallbackService;
import cn.daxpay.open.channel.wechat.service.callback.WechatTransferCallbackService;
import cn.daxpay.open.payment.common.context.MerchantContextLoader;
import cn.daxpay.open.platform.core.annotation.IgnoreAuth;
import io.swagger.v3.oas.annotations.Operation;
@@ -27,6 +28,7 @@ public class WechatCallbackController {
private final MerchantContextLoader merchantContextLoader;
private final WechatPayCallbackService wechatPayCallbackService;
private final WechatRefundCallbackService wechatRefundCallbackService;
private final WechatTransferCallbackService wechatTransferCallbackService;
/// 微信支付回调(直连)
@Operation(summary = "微信支付回调")
@@ -66,4 +68,14 @@ public class WechatCallbackController {
merchantContextLoader.bindMchNoForCallback(mchNo);
return wechatRefundCallbackService.isvRefundHandle(mchNo, channelMchNo, request);
}
/// 微信转账回调(直连, 转账无服务商模式)
@Operation(summary = "微信转账回调")
@PostMapping("/transfer")
public String transferNotify(@PathVariable("mchNo") String mchNo,
@PathVariable("channelMchNo") String channelMchNo,
HttpServletRequest request) {
merchantContextLoader.bindMchNoForCallback(mchNo);
return wechatTransferCallbackService.transferHandle(mchNo, channelMchNo, request);
}
}

View File

@@ -0,0 +1,157 @@
package cn.daxpay.open.channel.wechat.service.callback;
import cn.daxpay.open.channel.wechat.client.WechatChannelClient;
import cn.daxpay.open.channel.wechat.client.credential.WechatSdkCredential;
import cn.daxpay.open.channel.wechat.client.req.WechatCallbackParseReq;
import cn.daxpay.open.channel.wechat.client.resp.WechatTransferCallbackParseResp;
import cn.daxpay.open.channel.wechat.code.WechatCode;
import cn.daxpay.open.channel.wechat.service.direct.WechatDirectConfigAssembler;
import cn.daxpay.open.payment.common.result.DaxResult;
import cn.daxpay.open.payment.trade.record.service.PayCallbackRecordService;
import cn.daxpay.open.payment.trade.runtime.bo.CallbackData;
import cn.daxpay.open.payment.trade.transfer.runtime.service.TransferCallbackService;
import cn.daxpay.open.platform.core.enums.pay.notice.CallbackStatusEnum;
import cn.hutool.core.util.StrUtil;
import cn.hutool.extra.servlet.JakartaServletUtil;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.time.OffsetDateTime;
import java.time.format.DateTimeFormatter;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
/// # 微信转账回调处理服务
///
/// 微信商家转账异步通知 → 主应用接收原始 header + body → 组装凭证(直连, 转账无服务商模式) →
/// 转发到子应用用 [com.github.binarywang.wxpay.service.NotificationParser] 验签解密 →
/// 构建 [CallbackData] 交由 [TransferCallbackService] 更新转账单状态。
///
/// 状态映射与同步路径([cn.daxpay.open.channel.wechat.service.payment.transfer.WechatTransferService])对齐:
/// SUCCESS → success / FAIL·CANCELLED → close / 其余中间态直接返回成功应答, 不调 [TransferCallbackService#transferCallback],
/// 避免被 [TransferCallbackService#doTransferCallback] 对非 success/close 的一律 fail 处理误置失败。
///
/// 记录保存注意: [TransferCallbackService#transferCallback] 内部已落回调记录,
/// 故本服务在调用成功后不再重复保存(与 [WechatPayCallbackService] 在 payCallback 后再 save 的模式不同)。
@Slf4j
@Service
@RequiredArgsConstructor
public class WechatTransferCallbackService {
/// 转账成功状态
private static final String STATE_SUCCESS = "SUCCESS";
/// 转账失败/撤销终态
private static final List<String> STATE_FAIL_CLOSE = List.of("FAIL", "CANCELLED");
/// 转账处理中状态(收到此类通知直接返回成功应答, 不流转状态)
private static final List<String> STATE_PROCESSING =
List.of("ACCEPTED", "PROCESSING", "WAIT_USER_CONFIRM", "TRANSFERING", "CANCELING");
/// 微信转账时间格式(RFC3339, 带毫秒与东八区偏移, 与同步路径一致)
private static final DateTimeFormatter WECHAT_TRANSFER_TIME_FORMATTER =
DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSSXXX");
private final WechatChannelClient wechatChannelClient;
private final WechatDirectConfigAssembler wechatDirectConfigAssembler;
private final TransferCallbackService transferCallbackService;
private final PayCallbackRecordService payCallbackRecordService;
/// 转账回调处理(直连, 转账无服务商模式)
///
/// @param mchNo 商户号(装载租户上下文, 通道商户归属校验)
/// @param channelMchNo 通道商户号(凭证组装主键)
/// @param request 原始请求(含微信验签 header + 加密 body)
/// @return 微信要求应答串(SUCCESS/FAIL JSON)
public String transferHandle(String mchNo, String channelMchNo, HttpServletRequest request) {
// 1. 提取回调原始数据
String body = JakartaServletUtil.getBody(request);
Map<String, String> headerMap = JakartaServletUtil.getHeaderMap(request);
// 2. 组装凭证(转账仅直连, 回调不解析应用, 只装载密钥与证书并校验通道商户归属)
WechatSdkCredential credential = wechatDirectConfigAssembler.buildCallbackConfig(mchNo, channelMchNo);
// 3. 转发到子应用验签
WechatCallbackParseReq req = new WechatCallbackParseReq();
req.setCredential(credential);
req.setBody(body);
req.setSerial(this.getHeader(headerMap, WechatCode.HEADER_SERIAL));
req.setNonce(this.getHeader(headerMap, WechatCode.HEADER_NONCE));
req.setSignature(this.getHeader(headerMap, WechatCode.HEADER_SIGNATURE));
req.setTimestamp(this.getHeader(headerMap, WechatCode.HEADER_TIMESTAMP));
DaxResult<WechatTransferCallbackParseResp> result = wechatChannelClient.parseTransferCallback(req);
if (result.getCode() != 0 || result.getData() == null || !result.getData().isVerified()) {
log.error("微信转账回调验签失败: channelMchNo={}", channelMchNo);
CallbackData failData = new CallbackData();
Map<String, Object> notify = new HashMap<>();
notify.put("body", body);
notify.put("headers", headerMap);
failData.setCallbackData(notify);
failData.setCallbackStatus(CallbackStatusEnum.FAIL);
failData.setCallbackErrorMsg("微信转账回调验签失败");
payCallbackRecordService.saveTransfer(channelMchNo, failData);
return WechatCode.NOTIFY_FAIL;
}
// 4. 解析结果
WechatTransferCallbackParseResp resp = result.getData();
String state = resp.getTransferState();
// 处理中中间态: 直接返回成功应答, 不调 transferCallback(避免被 doTransferCallback 误置失败)
if (STATE_PROCESSING.contains(state)) {
log.info("微信转账回调中间态, 忽略: outBillNo={}, state={}", resp.getOutBillNo(), state);
return WechatCode.NOTIFY_SUCCESS;
}
// 5. 构建回调数据
CallbackData callbackData = new CallbackData();
Map<String, Object> notify = new HashMap<>();
notify.put("body", body);
notify.put("outBillNo", resp.getOutBillNo());
notify.put("transferBillNo", resp.getTransferBillNo());
notify.put("transferState", state);
notify.put("failReason", resp.getFailReason());
notify.put("updateTime", resp.getUpdateTime());
callbackData.setCallbackData(notify);
// 反查字段: outBillNo=平台转账单号 transferNo / transferBillNo=通道转账单号 outTransferNo
callbackData.setTradeNo(resp.getOutBillNo());
callbackData.setOutTradeNo(resp.getTransferBillNo());
// 完成时间(RFC3339 东八区, 与同步路径同 formatter)
if (StrUtil.isNotBlank(resp.getUpdateTime())) {
try {
callbackData.setFinishTime(OffsetDateTime.parse(resp.getUpdateTime(), WECHAT_TRANSFER_TIME_FORMATTER));
} catch (Exception e) {
log.warn("微信转账回调时间解析失败: updateTime={}", resp.getUpdateTime());
}
}
// 状态映射(与同步路径对齐)
if (Objects.equals(state, STATE_SUCCESS)) {
callbackData.setTradeStatus(CallbackStatusEnum.SUCCESS.getCode());
} else if (STATE_FAIL_CLOSE.contains(state)) {
callbackData.setTradeStatus(CallbackStatusEnum.CLOSE.getCode());
callbackData.setTradeErrorMsg(resp.getFailReason());
} else {
// 未知状态保持处理中, 直接返回成功应答不流转, 交后续同步轮询确认
log.warn("微信转账回调未知状态, 忽略: outBillNo={}, state={}", resp.getOutBillNo(), state);
return WechatCode.NOTIFY_SUCCESS;
}
try {
transferCallbackService.transferCallback(channelMchNo, "wechat", callbackData);
} catch (Exception e) {
log.error("微信转账回调业务处理失败: tradeNo={}", callbackData.getTradeNo(), e);
callbackData.setCallbackStatus(CallbackStatusEnum.EXCEPTION).setCallbackErrorMsg(e.getMessage());
payCallbackRecordService.saveTransfer(channelMchNo, callbackData);
return WechatCode.NOTIFY_FAIL;
}
return WechatCode.NOTIFY_SUCCESS;
}
/// 获取 header(大小写兼容)
private String getHeader(Map<String, String> headerMap, String name) {
String value = headerMap.get(name);
return value != null ? value : headerMap.get(name.toLowerCase());
}
}

View File

@@ -1,6 +1,8 @@
package cn.daxpay.open.channel.wechat.strategy.direct.transfer;
import cn.daxpay.open.channel.wechat.client.credential.WechatSdkCredential;
import cn.daxpay.open.channel.wechat.dao.direct.WechatDirectChannelMerchantManager;
import cn.daxpay.open.channel.wechat.entity.direct.WechatDirectChannelMerchant;
import cn.daxpay.open.channel.wechat.service.direct.WechatDirectConfigAssembler;
import cn.daxpay.open.channel.wechat.service.payment.transfer.WechatTransferService;
import cn.daxpay.open.payment.strategy.transfer.AbsTransferStrategy;
@@ -9,6 +11,7 @@ import cn.daxpay.open.payment.trade.transfer.bo.TransferResultBo;
import cn.daxpay.open.payment.trade.transfer.enums.TransferPayeeTypeEnum;
import cn.daxpay.open.payment.trade.transfer.param.TransferParam;
import cn.daxpay.open.platform.core.code.CommonErrorCode;
import cn.daxpay.open.platform.core.code.DaxPayErrorCode;
import cn.daxpay.open.platform.core.exception.BizInfoException;
import cn.hutool.core.util.StrUtil;
import lombok.RequiredArgsConstructor;
@@ -34,6 +37,7 @@ public class WechatTransferStrategy extends AbsTransferStrategy {
private final WechatTransferService wechatTransferService;
private final WechatDirectConfigAssembler wechatDirectConfigAssembler;
private final WechatDirectChannelMerchantManager wechatDirectChannelMerchantManager;
@Override
public String getChannel() {
@@ -77,8 +81,21 @@ public class WechatTransferStrategy extends AbsTransferStrategy {
return wechatTransferService.sync(context, credential);
}
/// 组装通道调用凭证(转账无 capability/appId 维度, 走平台默认应用绑定)
/// 组装通道调用凭证并注入转账场景
///
/// 转账场景(transfer_scene)从通道商户配置读取, 经上下文回写, 由编排层在"处理中"镜像落库。
private WechatSdkCredential buildCredential(TransferStrategyContext context) {
WechatDirectChannelMerchant channelMerchant = wechatDirectChannelMerchantManager.lambdaQuery()
.eq(WechatDirectChannelMerchant::getChannelMchNo, context.getChannelMchNo())
.oneOpt()
.orElseThrow(() -> new BizInfoException(DaxPayErrorCode.CONFIG_NOT_EXIST,
"error.payment.channel.channelMerchantNotExist"));
if (StrUtil.isBlank(channelMerchant.getTransferScene())) {
// 微信: 转账场景未配置
throw new BizInfoException(DaxPayErrorCode.CONFIG_NOT_EXIST,
"error.channel.wechat.transferSceneNotConfigured");
}
context.setTransferScene(channelMerchant.getTransferScene());
return wechatDirectConfigAssembler.buildConfig(
context.getMchNo(), context.getChannelMchNo(), null, null);
}

View File

@@ -60,5 +60,7 @@
"transferFailed": "WeChat transfer failed: {0}",
"transferOnlyOpenid": "WeChat only supports openid payee",
"transferNameForbidden": "WeChat: payee name not allowed for amount below 0.3 CNY",
"transferNameRequired": "WeChat: payee name required for amount >= 2000 CNY"
"transferNameRequired": "WeChat: payee name required for amount >= 2000 CNY",
"transferSceneNotConfigured": "WeChat: transfer scene not configured"
}

View File

@@ -60,5 +60,7 @@
"transferFailed": "Transfer WeChat gagal: {0}",
"transferOnlyOpenid": "WeChat hanya mendukung penerima openid",
"transferNameForbidden": "WeChat: nama penerima tidak diizinkan untuk jumlah di bawah 0.3 CNY",
"transferNameRequired": "WeChat: nama penerima wajib untuk jumlah >= 2000 CNY"
"transferNameRequired": "WeChat: nama penerima wajib untuk jumlah >= 2000 CNY",
"transferSceneNotConfigured": "WeChat: skenario transfer belum dikonfigurasi"
}

View File

@@ -60,5 +60,7 @@
"transferFailed": "WeChat送金エラー: {0}",
"transferOnlyOpenid": "WeChatはopenid受取人のみサポートしています",
"transferNameForbidden": "WeChat: 0.3元未満は受取人氏名を入力できません",
"transferNameRequired": "WeChat: 2000元以上は受取人氏名が必須です"
"transferNameRequired": "WeChat: 2000元以上は受取人氏名が必須です",
"transferSceneNotConfigured": "WeChat: 送金シーンが設定されていません"
}

View File

@@ -60,5 +60,7 @@
"transferFailed": "위챗 송금 실패: {0}",
"transferOnlyOpenid": "위챗은 openid 수취인만 지원합니다",
"transferNameForbidden": "위챗: 0.3원 미만은 수취인 이름을 입력할 수 없습니다",
"transferNameRequired": "위챗: 2000원 이상은 수취인 이름이 필수입니다"
"transferNameRequired": "위챗: 2000원 이상은 수취인 이름이 필수입니다",
"transferSceneNotConfigured": "WeChat: 송금 장면이 구성되지 않았습니다"
}

View File

@@ -60,5 +60,7 @@
"transferFailed": "Pindahan WeChat gagal: {0}",
"transferOnlyOpenid": "WeChat hanya menyokong penerima openid",
"transferNameForbidden": "WeChat: nama penerima tidak dibenarkan untuk jumlah di bawah 0.3 CNY",
"transferNameRequired": "WeChat: nama penerima wajib untuk jumlah >= 2000 CNY"
"transferNameRequired": "WeChat: nama penerima wajib untuk jumlah >= 2000 CNY",
"transferSceneNotConfigured": "WeChat: senario pindahan belum dikonfigurasi"
}

View File

@@ -60,5 +60,7 @@
"transferFailed": "การโอนเงิน WeChat ล้มเหลว: {0}",
"transferOnlyOpenid": "WeChat รองรับผู้รับ openid เท่านั้น",
"transferNameForbidden": "WeChat: ไม่อนุญาตให้ใส่ชื่อผู้รับเมื่อต่ำกว่า 0.3 หยวน",
"transferNameRequired": "WeChat: ต้องใส่ชื่อผู้รับเมื่อมากกว่าหรือเท่ากับ 2000 หยวน"
"transferNameRequired": "WeChat: ต้องใส่ชื่อผู้รับเมื่อมากกว่าหรือเท่ากับ 2000 หยวน",
"transferSceneNotConfigured": "WeChat: ยังไม่ได้กำหนดสถานการณ์การโอนเงิน"
}

View File

@@ -60,5 +60,7 @@
"transferFailed": "Chuyển khoản WeChat thất bại: {0}",
"transferOnlyOpenid": "WeChat chỉ hỗ trợ người nhận openid",
"transferNameForbidden": "WeChat: không được nhập tên người nhận khi dưới 0.3 CNY",
"transferNameRequired": "WeChat: bắt buộc nhập tên người nhận khi >= 2000 CNY"
"transferNameRequired": "WeChat: bắt buộc nhập tên người nhận khi >= 2000 CNY",
"transferSceneNotConfigured": "WeChat: chưa cấu hình kịch bản chuyển khoản"
}

View File

@@ -60,5 +60,7 @@
"transferFailed": "微信转账异常: {0}",
"transferOnlyOpenid": "微信仅支持 openid 收款人",
"transferNameForbidden": "微信: 小于0.3元不允许填收款人姓名",
"transferNameRequired": "微信: 大于等于2000元必须填收款人姓名"
"transferNameRequired": "微信: 大于等于2000元必须填收款人姓名",
"transferSceneNotConfigured": "微信: 转账场景未配置"
}

View File

@@ -60,5 +60,7 @@
"transferFailed": "微信轉賬異常: {0}",
"transferOnlyOpenid": "微信僅支持 openid 收款人",
"transferNameForbidden": "微信: 少於0.3元不允許填收款人姓名",
"transferNameRequired": "微信: 大於等於2000元必須填收款人姓名"
"transferNameRequired": "微信: 大於等於2000元必須填收款人姓名",
"transferSceneNotConfigured": "微信: 轉賬場景未設定"
}

View File

@@ -60,5 +60,7 @@
"transferFailed": "微信轉帳異常: {0}",
"transferOnlyOpenid": "微信僅支持 openid 收款人",
"transferNameForbidden": "微信: 小於0.3元不允許填收款人姓名",
"transferNameRequired": "微信: 大於等於2000元必須填收款人姓名"
"transferNameRequired": "微信: 大於等於2000元必須填收款人姓名",
"transferSceneNotConfigured": "微信: 轉帳場景未設定"
}