mirror of
https://gitee.com/dromara/dax-pay
synced 2026-08-10 06:46:04 +08:00
feat(transfer): 微信/支付宝/抖音转账异步回调落地, README 补充转账与风控体系说明
This commit is contained in:
@@ -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` | 易支付协议插件 — 实现易支付的订单/退款/凭证/配置对接,可直接与支持易支付协议的系统互通 |
|
||||
|
||||
## 核心技术栈
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -30,6 +30,9 @@ public class AlipayTransferReq {
|
||||
/// 收款人姓名
|
||||
private String payeeName;
|
||||
|
||||
/// 异步通知地址(支付宝→平台)
|
||||
private String notifyUrl;
|
||||
|
||||
/// 通道调用凭证
|
||||
private AlipaySdkCredential credential;
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import java.time.OffsetDateTime;
|
||||
/// # 支付宝回调验签解析响应(子应用 → 主应用)
|
||||
///
|
||||
/// 与子应用 dax-pay-channel-one 的 `AlipayCallbackParseResp` 镜像。
|
||||
/// 兼容支付与退款两种回调: 通过 tradeType 区分。
|
||||
/// 仅承载支付/退款回调; 转账回调见 [AlipayTransferCallbackParseResp]。通过 tradeType 区分。
|
||||
@Data
|
||||
@Accessors(chain = true)
|
||||
public class AlipayCallbackParseResp {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import lombok.experimental.Accessors;
|
||||
/// # 微信回调验签解析响应
|
||||
///
|
||||
/// 子应用使用微信 SDK 验签并解密回调后, 返回结构化业务数据。
|
||||
/// 兼容支付与退款两种回调: 通过 tradeType 区分。
|
||||
/// 仅承载支付/退款回调; 转账回调见 [WechatTransferCallbackParseResp]。通过 tradeType 区分支付与退款。
|
||||
@Data
|
||||
@Accessors(chain = true)
|
||||
public class WechatCallbackParseResp {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -60,5 +60,7 @@
|
||||
"transferFailed": "WeChat送金エラー: {0}",
|
||||
"transferOnlyOpenid": "WeChatはopenid受取人のみサポートしています",
|
||||
"transferNameForbidden": "WeChat: 0.3元未満は受取人氏名を入力できません",
|
||||
"transferNameRequired": "WeChat: 2000元以上は受取人氏名が必須です"
|
||||
"transferNameRequired": "WeChat: 2000元以上は受取人氏名が必須です",
|
||||
|
||||
"transferSceneNotConfigured": "WeChat: 送金シーンが設定されていません"
|
||||
}
|
||||
|
||||
@@ -60,5 +60,7 @@
|
||||
"transferFailed": "위챗 송금 실패: {0}",
|
||||
"transferOnlyOpenid": "위챗은 openid 수취인만 지원합니다",
|
||||
"transferNameForbidden": "위챗: 0.3원 미만은 수취인 이름을 입력할 수 없습니다",
|
||||
"transferNameRequired": "위챗: 2000원 이상은 수취인 이름이 필수입니다"
|
||||
"transferNameRequired": "위챗: 2000원 이상은 수취인 이름이 필수입니다",
|
||||
|
||||
"transferSceneNotConfigured": "WeChat: 송금 장면이 구성되지 않았습니다"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -60,5 +60,7 @@
|
||||
"transferFailed": "การโอนเงิน WeChat ล้มเหลว: {0}",
|
||||
"transferOnlyOpenid": "WeChat รองรับผู้รับ openid เท่านั้น",
|
||||
"transferNameForbidden": "WeChat: ไม่อนุญาตให้ใส่ชื่อผู้รับเมื่อต่ำกว่า 0.3 หยวน",
|
||||
"transferNameRequired": "WeChat: ต้องใส่ชื่อผู้รับเมื่อมากกว่าหรือเท่ากับ 2000 หยวน"
|
||||
"transferNameRequired": "WeChat: ต้องใส่ชื่อผู้รับเมื่อมากกว่าหรือเท่ากับ 2000 หยวน",
|
||||
|
||||
"transferSceneNotConfigured": "WeChat: ยังไม่ได้กำหนดสถานการณ์การโอนเงิน"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -60,5 +60,7 @@
|
||||
"transferFailed": "微信转账异常: {0}",
|
||||
"transferOnlyOpenid": "微信仅支持 openid 收款人",
|
||||
"transferNameForbidden": "微信: 小于0.3元不允许填收款人姓名",
|
||||
"transferNameRequired": "微信: 大于等于2000元必须填收款人姓名"
|
||||
"transferNameRequired": "微信: 大于等于2000元必须填收款人姓名",
|
||||
|
||||
"transferSceneNotConfigured": "微信: 转账场景未配置"
|
||||
}
|
||||
|
||||
@@ -60,5 +60,7 @@
|
||||
"transferFailed": "微信轉賬異常: {0}",
|
||||
"transferOnlyOpenid": "微信僅支持 openid 收款人",
|
||||
"transferNameForbidden": "微信: 少於0.3元不允許填收款人姓名",
|
||||
"transferNameRequired": "微信: 大於等於2000元必須填收款人姓名"
|
||||
"transferNameRequired": "微信: 大於等於2000元必須填收款人姓名",
|
||||
|
||||
"transferSceneNotConfigured": "微信: 轉賬場景未設定"
|
||||
}
|
||||
|
||||
@@ -60,5 +60,7 @@
|
||||
"transferFailed": "微信轉帳異常: {0}",
|
||||
"transferOnlyOpenid": "微信僅支持 openid 收款人",
|
||||
"transferNameForbidden": "微信: 小於0.3元不允許填收款人姓名",
|
||||
"transferNameRequired": "微信: 大於等於2000元必須填收款人姓名"
|
||||
"transferNameRequired": "微信: 大於等於2000元必須填收款人姓名",
|
||||
|
||||
"transferSceneNotConfigured": "微信: 轉帳場景未設定"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user