From d71d0adc4f23a33480805e53fc134acea3b3bdf1 Mon Sep 17 00:00:00 2001 From: daxpay Date: Thu, 6 Aug 2026 17:49:57 +0800 Subject: [PATCH] =?UTF-8?q?feat(transfer):=20=E5=BE=AE=E4=BF=A1/=E6=94=AF?= =?UTF-8?q?=E4=BB=98=E5=AE=9D/=E6=8A=96=E9=9F=B3=E8=BD=AC=E8=B4=A6?= =?UTF-8?q?=E5=BC=82=E6=AD=A5=E5=9B=9E=E8=B0=83=E8=90=BD=E5=9C=B0,=20READM?= =?UTF-8?q?E=20=E8=A1=A5=E5=85=85=E8=BD=AC=E8=B4=A6=E4=B8=8E=E9=A3=8E?= =?UTF-8?q?=E6=8E=A7=E4=BD=93=E7=B3=BB=E8=AF=B4=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 5 +- .../alipay/client/AlipayChannelClient.java | 5 + .../alipay/client/req/AlipayTransferReq.java | 3 + .../client/resp/AlipayCallbackParseResp.java | 2 +- .../resp/AlipayTransferCallbackParseResp.java | 33 ++++ .../callback/AlipayCallbackController.java | 6 +- .../callback/AlipayCallbackService.java | 100 +++++++++- .../transfer/AlipayTransferService.java | 19 ++ .../douyin/client/DouyinChannelClient.java | 5 + .../resp/DouyinTransferCallbackParseResp.java | 28 +++ .../callback/DouyinCallbackController.java | 12 ++ .../DouyinTransferCallbackService.java | 174 ++++++++++++++++++ .../wechat/client/WechatChannelClient.java | 5 + .../client/resp/WechatCallbackParseResp.java | 2 +- .../resp/WechatTransferCallbackParseResp.java | 31 ++++ .../callback/WechatCallbackController.java | 12 ++ .../WechatTransferCallbackService.java | 157 ++++++++++++++++ .../transfer/WechatTransferStrategy.java | 19 +- .../i18n/en-US/error/channel/wechat.json | 4 +- .../i18n/id-ID/error/channel/wechat.json | 4 +- .../i18n/ja-JP/error/channel/wechat.json | 4 +- .../i18n/ko-KR/error/channel/wechat.json | 4 +- .../i18n/ms-MY/error/channel/wechat.json | 4 +- .../i18n/th-TH/error/channel/wechat.json | 4 +- .../i18n/vi-VN/error/channel/wechat.json | 4 +- .../i18n/zh-CN/error/channel/wechat.json | 4 +- .../i18n/zh-HK/error/channel/wechat.json | 4 +- .../i18n/zh-TW/error/channel/wechat.json | 4 +- 28 files changed, 633 insertions(+), 25 deletions(-) create mode 100644 daxpay-channel/daxpay-channel-alipay/src/main/java/cn/daxpay/open/channel/alipay/client/resp/AlipayTransferCallbackParseResp.java create mode 100644 daxpay-channel/daxpay-channel-douyin/src/main/java/cn/daxpay/open/channel/douyin/client/resp/DouyinTransferCallbackParseResp.java create mode 100644 daxpay-channel/daxpay-channel-douyin/src/main/java/cn/daxpay/open/channel/douyin/service/callback/DouyinTransferCallbackService.java create mode 100644 daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/client/resp/WechatTransferCallbackParseResp.java create mode 100644 daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/service/callback/WechatTransferCallbackService.java diff --git a/README.md b/README.md index 2c88d16fd..df1eff99d 100644 --- a/README.md +++ b/README.md @@ -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` | 易支付协议插件 — 实现易支付的订单/退款/凭证/配置对接,可直接与支持易支付协议的系统互通 | ## 核心技术栈 diff --git a/daxpay-channel/daxpay-channel-alipay/src/main/java/cn/daxpay/open/channel/alipay/client/AlipayChannelClient.java b/daxpay-channel/daxpay-channel-alipay/src/main/java/cn/daxpay/open/channel/alipay/client/AlipayChannelClient.java index 66cfb879c..f9d6bb6d3 100644 --- a/daxpay-channel/daxpay-channel-alipay/src/main/java/cn/daxpay/open/channel/alipay/client/AlipayChannelClient.java +++ b/daxpay-channel/daxpay-channel-alipay/src/main/java/cn/daxpay/open/channel/alipay/client/AlipayChannelClient.java @@ -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 parseRefundCallback(@RequestBody AlipayCallbackParseReq req); + /// 转账回调验签解析(转发子应用) + @PostExchange("/channel/alipay/callback/parse-transfer") + DaxResult parseTransferCallback(@RequestBody AlipayCallbackParseReq req); + /// 应用授权码换取 app_auth_token(代运营授权) @PostExchange("/channel/alipay/auth/app-token") DaxResult exchangeAppAuthToken(@RequestBody AlipayAppAuthTokenReq req); diff --git a/daxpay-channel/daxpay-channel-alipay/src/main/java/cn/daxpay/open/channel/alipay/client/req/AlipayTransferReq.java b/daxpay-channel/daxpay-channel-alipay/src/main/java/cn/daxpay/open/channel/alipay/client/req/AlipayTransferReq.java index 729a863a6..cfe013412 100644 --- a/daxpay-channel/daxpay-channel-alipay/src/main/java/cn/daxpay/open/channel/alipay/client/req/AlipayTransferReq.java +++ b/daxpay-channel/daxpay-channel-alipay/src/main/java/cn/daxpay/open/channel/alipay/client/req/AlipayTransferReq.java @@ -30,6 +30,9 @@ public class AlipayTransferReq { /// 收款人姓名 private String payeeName; + /// 异步通知地址(支付宝→平台) + private String notifyUrl; + /// 通道调用凭证 private AlipaySdkCredential credential; } diff --git a/daxpay-channel/daxpay-channel-alipay/src/main/java/cn/daxpay/open/channel/alipay/client/resp/AlipayCallbackParseResp.java b/daxpay-channel/daxpay-channel-alipay/src/main/java/cn/daxpay/open/channel/alipay/client/resp/AlipayCallbackParseResp.java index 197561b2d..df593107d 100644 --- a/daxpay-channel/daxpay-channel-alipay/src/main/java/cn/daxpay/open/channel/alipay/client/resp/AlipayCallbackParseResp.java +++ b/daxpay-channel/daxpay-channel-alipay/src/main/java/cn/daxpay/open/channel/alipay/client/resp/AlipayCallbackParseResp.java @@ -8,7 +8,7 @@ import java.time.OffsetDateTime; /// # 支付宝回调验签解析响应(子应用 → 主应用) /// /// 与子应用 dax-pay-channel-one 的 `AlipayCallbackParseResp` 镜像。 -/// 兼容支付与退款两种回调: 通过 tradeType 区分。 +/// 仅承载支付/退款回调; 转账回调见 [AlipayTransferCallbackParseResp]。通过 tradeType 区分。 @Data @Accessors(chain = true) public class AlipayCallbackParseResp { diff --git a/daxpay-channel/daxpay-channel-alipay/src/main/java/cn/daxpay/open/channel/alipay/client/resp/AlipayTransferCallbackParseResp.java b/daxpay-channel/daxpay-channel-alipay/src/main/java/cn/daxpay/open/channel/alipay/client/resp/AlipayTransferCallbackParseResp.java new file mode 100644 index 000000000..220197c81 --- /dev/null +++ b/daxpay-channel/daxpay-channel-alipay/src/main/java/cn/daxpay/open/channel/alipay/client/resp/AlipayTransferCallbackParseResp.java @@ -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; +} diff --git a/daxpay-channel/daxpay-channel-alipay/src/main/java/cn/daxpay/open/channel/alipay/controller/callback/AlipayCallbackController.java b/daxpay-channel/daxpay-channel-alipay/src/main/java/cn/daxpay/open/channel/alipay/controller/callback/AlipayCallbackController.java index 0c95e1e90..d6db2d02d 100644 --- a/daxpay-channel/daxpay-channel-alipay/src/main/java/cn/daxpay/open/channel/alipay/controller/callback/AlipayCallbackController.java +++ b/daxpay-channel/daxpay-channel-alipay/src/main/java/cn/daxpay/open/channel/alipay/controller/callback/AlipayCallbackController.java @@ -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); } } diff --git a/daxpay-channel/daxpay-channel-alipay/src/main/java/cn/daxpay/open/channel/alipay/service/callback/AlipayCallbackService.java b/daxpay-channel/daxpay-channel-alipay/src/main/java/cn/daxpay/open/channel/alipay/service/callback/AlipayCallbackService.java index 50e706f8a..b8f0736e8 100644 --- a/daxpay-channel/daxpay-channel-alipay/src/main/java/cn/daxpay/open/channel/alipay/service/callback/AlipayCallbackService.java +++ b/daxpay-channel/daxpay-channel-alipay/src/main/java/cn/daxpay/open/channel/alipay/service/callback/AlipayCallbackService.java @@ -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 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 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 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 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 diff --git a/daxpay-channel/daxpay-channel-alipay/src/main/java/cn/daxpay/open/channel/alipay/service/payment/transfer/AlipayTransferService.java b/daxpay-channel/daxpay-channel-alipay/src/main/java/cn/daxpay/open/channel/alipay/service/payment/transfer/AlipayTransferService.java index 3e083d56e..3fc63e92d 100644 --- a/daxpay-channel/daxpay-channel-alipay/src/main/java/cn/daxpay/open/channel/alipay/service/payment/transfer/AlipayTransferService.java +++ b/daxpay-channel/daxpay-channel-alipay/src/main/java/cn/daxpay/open/channel/alipay/service/payment/transfer/AlipayTransferService.java @@ -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 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()); + } } diff --git a/daxpay-channel/daxpay-channel-douyin/src/main/java/cn/daxpay/open/channel/douyin/client/DouyinChannelClient.java b/daxpay-channel/daxpay-channel-douyin/src/main/java/cn/daxpay/open/channel/douyin/client/DouyinChannelClient.java index 195a1c291..af985c332 100644 --- a/daxpay-channel/daxpay-channel-douyin/src/main/java/cn/daxpay/open/channel/douyin/client/DouyinChannelClient.java +++ b/daxpay-channel/daxpay-channel-douyin/src/main/java/cn/daxpay/open/channel/douyin/client/DouyinChannelClient.java @@ -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 parseRefundCallback(@RequestBody DouyinCallbackParseReq req); + + /// 转账回调验签解析(转发到子应用验签) + @PostExchange("/channel/douyin/callback/parse-transfer") + DaxResult parseTransferCallback(@RequestBody DouyinCallbackParseReq req); } diff --git a/daxpay-channel/daxpay-channel-douyin/src/main/java/cn/daxpay/open/channel/douyin/client/resp/DouyinTransferCallbackParseResp.java b/daxpay-channel/daxpay-channel-douyin/src/main/java/cn/daxpay/open/channel/douyin/client/resp/DouyinTransferCallbackParseResp.java new file mode 100644 index 000000000..bfee97f91 --- /dev/null +++ b/daxpay-channel/daxpay-channel-douyin/src/main/java/cn/daxpay/open/channel/douyin/client/resp/DouyinTransferCallbackParseResp.java @@ -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; +} diff --git a/daxpay-channel/daxpay-channel-douyin/src/main/java/cn/daxpay/open/channel/douyin/controller/callback/DouyinCallbackController.java b/daxpay-channel/daxpay-channel-douyin/src/main/java/cn/daxpay/open/channel/douyin/controller/callback/DouyinCallbackController.java index 2deddccbc..0d18f4528 100644 --- a/daxpay-channel/daxpay-channel-douyin/src/main/java/cn/daxpay/open/channel/douyin/controller/callback/DouyinCallbackController.java +++ b/daxpay-channel/daxpay-channel-douyin/src/main/java/cn/daxpay/open/channel/douyin/controller/callback/DouyinCallbackController.java @@ -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); + } } diff --git a/daxpay-channel/daxpay-channel-douyin/src/main/java/cn/daxpay/open/channel/douyin/service/callback/DouyinTransferCallbackService.java b/daxpay-channel/daxpay-channel-douyin/src/main/java/cn/daxpay/open/channel/douyin/service/callback/DouyinTransferCallbackService.java new file mode 100644 index 000000000..046fd790e --- /dev/null +++ b/daxpay-channel/daxpay-channel-douyin/src/main/java/cn/daxpay/open/channel/douyin/service/callback/DouyinTransferCallbackService.java @@ -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 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 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 result = douyinChannelClient.parseTransferCallback(req); + if (result.getCode() != 0 || result.getData() == null || !result.getData().isVerified()) { + log.error("抖音转账回调验签失败: channelMchNo={}", channelMchNo); + CallbackData failData = new CallbackData(); + Map 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 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 headerMap, String name) { + String value = headerMap.get(name); + return value != null ? value : headerMap.get(name.toLowerCase()); + } +} diff --git a/daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/client/WechatChannelClient.java b/daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/client/WechatChannelClient.java index cf3a86279..e8aa830c4 100644 --- a/daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/client/WechatChannelClient.java +++ b/daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/client/WechatChannelClient.java @@ -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 parseRefundCallback(@RequestBody WechatCallbackParseReq req); + + /// 转账回调验签解析(转发到子应用验签) + @PostExchange("/channel/wechat/callback/parse-transfer") + DaxResult parseTransferCallback(@RequestBody WechatCallbackParseReq req); } diff --git a/daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/client/resp/WechatCallbackParseResp.java b/daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/client/resp/WechatCallbackParseResp.java index 7e033a565..fdb4a56ab 100644 --- a/daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/client/resp/WechatCallbackParseResp.java +++ b/daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/client/resp/WechatCallbackParseResp.java @@ -6,7 +6,7 @@ import lombok.experimental.Accessors; /// # 微信回调验签解析响应 /// /// 子应用使用微信 SDK 验签并解密回调后, 返回结构化业务数据。 -/// 兼容支付与退款两种回调: 通过 tradeType 区分。 +/// 仅承载支付/退款回调; 转账回调见 [WechatTransferCallbackParseResp]。通过 tradeType 区分支付与退款。 @Data @Accessors(chain = true) public class WechatCallbackParseResp { diff --git a/daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/client/resp/WechatTransferCallbackParseResp.java b/daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/client/resp/WechatTransferCallbackParseResp.java new file mode 100644 index 000000000..9cb610bb5 --- /dev/null +++ b/daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/client/resp/WechatTransferCallbackParseResp.java @@ -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; +} diff --git a/daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/controller/callback/WechatCallbackController.java b/daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/controller/callback/WechatCallbackController.java index 2c73e0903..5d0fa5caa 100644 --- a/daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/controller/callback/WechatCallbackController.java +++ b/daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/controller/callback/WechatCallbackController.java @@ -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); + } } diff --git a/daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/service/callback/WechatTransferCallbackService.java b/daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/service/callback/WechatTransferCallbackService.java new file mode 100644 index 000000000..bb0b9600e --- /dev/null +++ b/daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/service/callback/WechatTransferCallbackService.java @@ -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 STATE_FAIL_CLOSE = List.of("FAIL", "CANCELLED"); + /// 转账处理中状态(收到此类通知直接返回成功应答, 不流转状态) + private static final List 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 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 result = wechatChannelClient.parseTransferCallback(req); + if (result.getCode() != 0 || result.getData() == null || !result.getData().isVerified()) { + log.error("微信转账回调验签失败: channelMchNo={}", channelMchNo); + CallbackData failData = new CallbackData(); + Map 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 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 headerMap, String name) { + String value = headerMap.get(name); + return value != null ? value : headerMap.get(name.toLowerCase()); + } +} diff --git a/daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/strategy/direct/transfer/WechatTransferStrategy.java b/daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/strategy/direct/transfer/WechatTransferStrategy.java index 1d8a1581d..78c755a4c 100644 --- a/daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/strategy/direct/transfer/WechatTransferStrategy.java +++ b/daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/strategy/direct/transfer/WechatTransferStrategy.java @@ -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); } diff --git a/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/en-US/error/channel/wechat.json b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/en-US/error/channel/wechat.json index 396fe3818..ea28e259a 100644 --- a/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/en-US/error/channel/wechat.json +++ b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/en-US/error/channel/wechat.json @@ -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" } diff --git a/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/id-ID/error/channel/wechat.json b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/id-ID/error/channel/wechat.json index c8e832f9c..384948704 100644 --- a/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/id-ID/error/channel/wechat.json +++ b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/id-ID/error/channel/wechat.json @@ -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" } diff --git a/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/ja-JP/error/channel/wechat.json b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/ja-JP/error/channel/wechat.json index 2a26700b9..a45ed2a1f 100644 --- a/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/ja-JP/error/channel/wechat.json +++ b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/ja-JP/error/channel/wechat.json @@ -60,5 +60,7 @@ "transferFailed": "WeChat送金エラー: {0}", "transferOnlyOpenid": "WeChatはopenid受取人のみサポートしています", "transferNameForbidden": "WeChat: 0.3元未満は受取人氏名を入力できません", - "transferNameRequired": "WeChat: 2000元以上は受取人氏名が必須です" + "transferNameRequired": "WeChat: 2000元以上は受取人氏名が必須です", + + "transferSceneNotConfigured": "WeChat: 送金シーンが設定されていません" } diff --git a/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/ko-KR/error/channel/wechat.json b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/ko-KR/error/channel/wechat.json index fb7ed779e..54765abf0 100644 --- a/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/ko-KR/error/channel/wechat.json +++ b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/ko-KR/error/channel/wechat.json @@ -60,5 +60,7 @@ "transferFailed": "위챗 송금 실패: {0}", "transferOnlyOpenid": "위챗은 openid 수취인만 지원합니다", "transferNameForbidden": "위챗: 0.3원 미만은 수취인 이름을 입력할 수 없습니다", - "transferNameRequired": "위챗: 2000원 이상은 수취인 이름이 필수입니다" + "transferNameRequired": "위챗: 2000원 이상은 수취인 이름이 필수입니다", + + "transferSceneNotConfigured": "WeChat: 송금 장면이 구성되지 않았습니다" } diff --git a/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/ms-MY/error/channel/wechat.json b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/ms-MY/error/channel/wechat.json index 5ac1575b9..602c3a212 100644 --- a/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/ms-MY/error/channel/wechat.json +++ b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/ms-MY/error/channel/wechat.json @@ -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" } diff --git a/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/th-TH/error/channel/wechat.json b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/th-TH/error/channel/wechat.json index da6a733a9..74e6f0de5 100644 --- a/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/th-TH/error/channel/wechat.json +++ b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/th-TH/error/channel/wechat.json @@ -60,5 +60,7 @@ "transferFailed": "การโอนเงิน WeChat ล้มเหลว: {0}", "transferOnlyOpenid": "WeChat รองรับผู้รับ openid เท่านั้น", "transferNameForbidden": "WeChat: ไม่อนุญาตให้ใส่ชื่อผู้รับเมื่อต่ำกว่า 0.3 หยวน", - "transferNameRequired": "WeChat: ต้องใส่ชื่อผู้รับเมื่อมากกว่าหรือเท่ากับ 2000 หยวน" + "transferNameRequired": "WeChat: ต้องใส่ชื่อผู้รับเมื่อมากกว่าหรือเท่ากับ 2000 หยวน", + + "transferSceneNotConfigured": "WeChat: ยังไม่ได้กำหนดสถานการณ์การโอนเงิน" } diff --git a/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/vi-VN/error/channel/wechat.json b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/vi-VN/error/channel/wechat.json index a4d640b7c..7684373ed 100644 --- a/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/vi-VN/error/channel/wechat.json +++ b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/vi-VN/error/channel/wechat.json @@ -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" } diff --git a/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/zh-CN/error/channel/wechat.json b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/zh-CN/error/channel/wechat.json index ab1e52a00..56a209234 100644 --- a/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/zh-CN/error/channel/wechat.json +++ b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/zh-CN/error/channel/wechat.json @@ -60,5 +60,7 @@ "transferFailed": "微信转账异常: {0}", "transferOnlyOpenid": "微信仅支持 openid 收款人", "transferNameForbidden": "微信: 小于0.3元不允许填收款人姓名", - "transferNameRequired": "微信: 大于等于2000元必须填收款人姓名" + "transferNameRequired": "微信: 大于等于2000元必须填收款人姓名", + + "transferSceneNotConfigured": "微信: 转账场景未配置" } diff --git a/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/zh-HK/error/channel/wechat.json b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/zh-HK/error/channel/wechat.json index 1b85fe340..0f7cc4651 100644 --- a/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/zh-HK/error/channel/wechat.json +++ b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/zh-HK/error/channel/wechat.json @@ -60,5 +60,7 @@ "transferFailed": "微信轉賬異常: {0}", "transferOnlyOpenid": "微信僅支持 openid 收款人", "transferNameForbidden": "微信: 少於0.3元不允許填收款人姓名", - "transferNameRequired": "微信: 大於等於2000元必須填收款人姓名" + "transferNameRequired": "微信: 大於等於2000元必須填收款人姓名", + + "transferSceneNotConfigured": "微信: 轉賬場景未設定" } diff --git a/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/zh-TW/error/channel/wechat.json b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/zh-TW/error/channel/wechat.json index f35bc1e0f..8b0c37037 100644 --- a/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/zh-TW/error/channel/wechat.json +++ b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/zh-TW/error/channel/wechat.json @@ -60,5 +60,7 @@ "transferFailed": "微信轉帳異常: {0}", "transferOnlyOpenid": "微信僅支持 openid 收款人", "transferNameForbidden": "微信: 小於0.3元不允許填收款人姓名", - "transferNameRequired": "微信: 大於等於2000元必須填收款人姓名" + "transferNameRequired": "微信: 大於等於2000元必須填收款人姓名", + + "transferSceneNotConfigured": "微信: 轉帳場景未設定" }