From 8ffef6ca098de9e68793d9e6af6ddcacd5d3f215 Mon Sep 17 00:00:00 2001 From: DaxPay Dev Date: Thu, 9 Jul 2026 10:51:06 +0800 Subject: [PATCH] =?UTF-8?q?feat(callback):=20=E8=A1=A5=E5=85=A8=E6=94=AF?= =?UTF-8?q?=E4=BB=98/=E9=80=80=E6=AC=BE=E5=9B=9E=E8=B0=83=E9=93=BE?= =?UTF-8?q?=E8=B7=AF(=E9=80=80=E6=AC=BE=E5=9B=9E=E8=B0=83=E6=A1=86?= =?UTF-8?q?=E6=9E=B6=20+=2013=E9=80=9A=E9=81=93=E4=B8=BB=E5=BA=94=E7=94=A8?= =?UTF-8?q?=E4=BE=A7=E5=9B=9E=E8=B0=83=E6=8E=A5=E6=94=B6=E4=B8=8E=E5=AD=90?= =?UTF-8?q?=E5=BA=94=E7=94=A8=E5=A7=94=E6=B4=BE=E8=A7=A3=E6=9E=90)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增退款回调框架 RefundCallbackService/RefundCallbackData(镜像 PayCallbackService, 含分布式锁/终态守卫/可退余额扣减), PayRefundOrderManager 增 findByOutRefundNo - alipay 全缺补建: AlipayCallbackController(共享支付/退款端点) + AlipayCallbackService(直连/服务商凭证按 product 分发) + Client parse 方法 + DTO - A-完成8通道(wechat/ums/douyin/hkrt/hmpay/leshua/yeepay/dougong)退款回调接入 RefundCallbackService, 替换原 TODO 日志 - adapay/lakala/fuyou/vbill 由主应用直接验签重构为转发子应用解析, 补建缺失的退款回调 Service + 控制器 /refund 端点 + Client parse 方法 + DTO --- .../adapay/client/AdapayChannelClient.java | 10 + .../client/req/AdapayCallbackParseReq.java | 18 ++ .../client/resp/AdapayCallbackParseResp.java | 36 ++++ .../callback/AdapayPayCallbackService.java | 104 ++++------ .../callback/AdapayRefundCallbackService.java | 83 ++++---- .../alipay/client/AlipayChannelClient.java | 10 + .../client/req/AlipayCallbackParseReq.java | 20 ++ .../client/resp/AlipayCallbackParseResp.java | 39 ++++ .../callback/AlipayCallbackController.java | 34 ++++ .../callback/AlipayCallbackService.java | 188 ++++++++++++++++++ .../DougongRefundCallbackService.java | 20 +- .../callback/DouyinRefundCallbackService.java | 17 +- .../fuyou/client/FuyouChannelClient.java | 10 + .../client/req/FuyouCallbackParseReq.java | 18 ++ .../client/resp/FuyouCallbackParseResp.java | 39 ++++ .../callback/FuyouCallbackController.java | 11 + .../callback/FuyouPayCallbackService.java | 145 ++++---------- .../callback/FuyouRefundCallbackService.java | 64 ++++++ .../callback/HkrtRefundCallbackService.java | 24 ++- .../callback/HmpayRefundCallbackService.java | 22 +- .../lakala/client/LakalaChannelClient.java | 10 + .../client/req/LakalaCallbackParseReq.java | 23 +++ .../client/resp/LakalaCallbackParseResp.java | 36 ++++ .../callback/LakalaCallbackController.java | 11 + .../callback/LakalaPayCallbackService.java | 169 ++++------------ .../callback/LakalaRefundCallbackService.java | 66 ++++++ .../callback/LeshuaRefundCallbackService.java | 19 +- .../callback/UmsRefundCallbackService.java | 16 +- .../vbill/client/VbillChannelClient.java | 10 + .../client/req/VbillCallbackParseReq.java | 18 ++ .../client/resp/VbillCallbackParseResp.java | 36 ++++ .../callback/VbillCallbackController.java | 11 + .../callback/VbillPayCallbackService.java | 160 ++++----------- .../callback/VbillRefundCallbackService.java | 75 +++++++ .../callback/WechatRefundCallbackService.java | 17 +- .../callback/YeepayRefundCallbackService.java | 16 +- .../common/callback/RefundCallbackData.java | 45 +++++ .../core/trade/dao/PayRefundOrderManager.java | 5 + .../trade/service/RefundCallbackService.java | 109 ++++++++++ 39 files changed, 1243 insertions(+), 521 deletions(-) create mode 100644 daxpay-channel/daxpay-channel-adapay/src/main/java/cn/daxpay/open/channel/adapay/client/req/AdapayCallbackParseReq.java create mode 100644 daxpay-channel/daxpay-channel-adapay/src/main/java/cn/daxpay/open/channel/adapay/client/resp/AdapayCallbackParseResp.java create mode 100644 daxpay-channel/daxpay-channel-alipay/src/main/java/cn/daxpay/open/channel/alipay/client/req/AlipayCallbackParseReq.java create mode 100644 daxpay-channel/daxpay-channel-alipay/src/main/java/cn/daxpay/open/channel/alipay/client/resp/AlipayCallbackParseResp.java create mode 100644 daxpay-channel/daxpay-channel-alipay/src/main/java/cn/daxpay/open/channel/alipay/controller/callback/AlipayCallbackController.java create mode 100644 daxpay-channel/daxpay-channel-alipay/src/main/java/cn/daxpay/open/channel/alipay/service/callback/AlipayCallbackService.java create mode 100644 daxpay-channel/daxpay-channel-fuyou/src/main/java/cn/daxpay/open/channel/fuyou/client/req/FuyouCallbackParseReq.java create mode 100644 daxpay-channel/daxpay-channel-fuyou/src/main/java/cn/daxpay/open/channel/fuyou/client/resp/FuyouCallbackParseResp.java create mode 100644 daxpay-channel/daxpay-channel-fuyou/src/main/java/cn/daxpay/open/channel/fuyou/service/callback/FuyouRefundCallbackService.java create mode 100644 daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/client/req/LakalaCallbackParseReq.java create mode 100644 daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/client/resp/LakalaCallbackParseResp.java create mode 100644 daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/service/callback/LakalaRefundCallbackService.java create mode 100644 daxpay-channel/daxpay-channel-vbill/src/main/java/cn/daxpay/open/channel/vbill/client/req/VbillCallbackParseReq.java create mode 100644 daxpay-channel/daxpay-channel-vbill/src/main/java/cn/daxpay/open/channel/vbill/client/resp/VbillCallbackParseResp.java create mode 100644 daxpay-channel/daxpay-channel-vbill/src/main/java/cn/daxpay/open/channel/vbill/service/callback/VbillRefundCallbackService.java create mode 100644 daxpay-payment/daxpay-payment-common/src/main/java/cn/daxpay/open/payment/common/callback/RefundCallbackData.java create mode 100644 daxpay-payment/daxpay-payment-common/src/main/java/cn/daxpay/open/payment/core/trade/service/RefundCallbackService.java diff --git a/daxpay-channel/daxpay-channel-adapay/src/main/java/cn/daxpay/open/channel/adapay/client/AdapayChannelClient.java b/daxpay-channel/daxpay-channel-adapay/src/main/java/cn/daxpay/open/channel/adapay/client/AdapayChannelClient.java index c33d4163f..af0570f86 100644 --- a/daxpay-channel/daxpay-channel-adapay/src/main/java/cn/daxpay/open/channel/adapay/client/AdapayChannelClient.java +++ b/daxpay-channel/daxpay-channel-adapay/src/main/java/cn/daxpay/open/channel/adapay/client/AdapayChannelClient.java @@ -1,10 +1,12 @@ package cn.daxpay.open.channel.adapay.client; +import cn.daxpay.open.channel.adapay.client.req.AdapayCallbackParseReq; import cn.daxpay.open.channel.adapay.client.req.AdapayCloseReq; import cn.daxpay.open.channel.adapay.client.req.AdapayPayReq; import cn.daxpay.open.channel.adapay.client.req.AdapayRefundReq; import cn.daxpay.open.channel.adapay.client.req.AdapayRefundSyncReq; import cn.daxpay.open.channel.adapay.client.req.AdapaySyncReq; +import cn.daxpay.open.channel.adapay.client.resp.AdapayCallbackParseResp; import cn.daxpay.open.channel.adapay.client.resp.AdapayCloseResp; import cn.daxpay.open.channel.adapay.client.resp.AdapayPayResp; import cn.daxpay.open.channel.adapay.client.resp.AdapayRefundResp; @@ -42,4 +44,12 @@ public interface AdapayChannelClient { /// 退款同步(查询退款状态) @PostExchange("/channel/adapay/refund-sync") DaxResult refundSync(@RequestBody AdapayRefundSyncReq req); + + /// 支付回调验签解析(转发子应用) + @PostExchange("/channel/adapay/callback/parse-pay") + DaxResult parsePayCallback(@RequestBody AdapayCallbackParseReq req); + + /// 退款回调验签解析(转发子应用) + @PostExchange("/channel/adapay/callback/parse-refund") + DaxResult parseRefundCallback(@RequestBody AdapayCallbackParseReq req); } diff --git a/daxpay-channel/daxpay-channel-adapay/src/main/java/cn/daxpay/open/channel/adapay/client/req/AdapayCallbackParseReq.java b/daxpay-channel/daxpay-channel-adapay/src/main/java/cn/daxpay/open/channel/adapay/client/req/AdapayCallbackParseReq.java new file mode 100644 index 000000000..c3285433a --- /dev/null +++ b/daxpay-channel/daxpay-channel-adapay/src/main/java/cn/daxpay/open/channel/adapay/client/req/AdapayCallbackParseReq.java @@ -0,0 +1,18 @@ +package cn.daxpay.open.channel.adapay.client.req; + +import cn.daxpay.open.channel.adapay.client.credential.AdapaySdkCredential; +import lombok.Data; + +/// # Adapay 回调验签解析请求(主应用 → 子应用) +/// +/// 与子应用 dax-pay-channel-two 的 `AdapayCallbackParseReq` 镜像。 +/// 主应用组装凭证(publicKey 为空时子应用用全局平台公钥) + 原始 body 下发, 子应用验签并解析。 +@Data +public class AdapayCallbackParseReq { + + /// 通道调用凭证(用于获取平台公钥验签, publicKey 为空时子应用用默认公钥) + private AdapaySdkCredential credential; + + /// 回调原始 body(Adapay 异步通知 JSON: {data, signature}) + private String body; +} diff --git a/daxpay-channel/daxpay-channel-adapay/src/main/java/cn/daxpay/open/channel/adapay/client/resp/AdapayCallbackParseResp.java b/daxpay-channel/daxpay-channel-adapay/src/main/java/cn/daxpay/open/channel/adapay/client/resp/AdapayCallbackParseResp.java new file mode 100644 index 000000000..265a0e519 --- /dev/null +++ b/daxpay-channel/daxpay-channel-adapay/src/main/java/cn/daxpay/open/channel/adapay/client/resp/AdapayCallbackParseResp.java @@ -0,0 +1,36 @@ +package cn.daxpay.open.channel.adapay.client.resp; + +import lombok.Data; +import lombok.experimental.Accessors; + +import java.time.OffsetDateTime; + +/// # Adapay 回调验签解析响应(子应用 → 主应用) +/// +/// 与子应用 dax-pay-channel-two 的 `AdapayCallbackParseResp` 镜像。 +/// 兼容支付与退款两种回调: 通过 tradeType 区分。 +@Data +@Accessors(chain = true) +public class AdapayCallbackParseResp { + + /// 是否验签通过 + private Boolean success; + + /// 回调类型(PAY 支付回调 / REFUND 退款回调) + private String tradeType; + + /// 商户订单号(支付回调: order_no = 平台支付交易号; 退款回调: out_refund_no = 退款单号) + private String outTradeNo; + + /// Adapay 支付对象 ID(支付回调 id / 退款回调 refund_order_id) + private String tradeNo; + + /// 通道退款流水号(退款回调 refund_order_no) + private String outRefundNo; + + /// 交易状态(抽象态 SUCCESS / FAIL) + private String tradeStatus; + + /// 完成时间(东八区 OffsetDateTime) + private OffsetDateTime finishTime; +} diff --git a/daxpay-channel/daxpay-channel-adapay/src/main/java/cn/daxpay/open/channel/adapay/service/callback/AdapayPayCallbackService.java b/daxpay-channel/daxpay-channel-adapay/src/main/java/cn/daxpay/open/channel/adapay/service/callback/AdapayPayCallbackService.java index be10d5e9d..23c5bb23c 100644 --- a/daxpay-channel/daxpay-channel-adapay/src/main/java/cn/daxpay/open/channel/adapay/service/callback/AdapayPayCallbackService.java +++ b/daxpay-channel/daxpay-channel-adapay/src/main/java/cn/daxpay/open/channel/adapay/service/callback/AdapayPayCallbackService.java @@ -1,37 +1,35 @@ package cn.daxpay.open.channel.adapay.service.callback; +import cn.daxpay.open.channel.adapay.client.AdapayChannelClient; +import cn.daxpay.open.channel.adapay.client.credential.AdapaySdkCredential; +import cn.daxpay.open.channel.adapay.client.req.AdapayCallbackParseReq; +import cn.daxpay.open.channel.adapay.client.resp.AdapayCallbackParseResp; import cn.daxpay.open.channel.adapay.code.AdapayCode; import cn.daxpay.open.payment.common.callback.CallbackData; import cn.daxpay.open.payment.core.trade.service.PayCallbackService; import cn.daxpay.open.platform.core.enums.pay.notice.CallbackStatusEnum; import cn.hutool.core.util.StrUtil; import cn.hutool.extra.servlet.JakartaServletUtil; -import cn.hutool.json.JSONObject; -import cn.hutool.json.JSONUtil; import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; -import java.nio.charset.StandardCharsets; -import java.security.KeyFactory; -import java.security.PublicKey; -import java.security.Signature; -import java.security.spec.X509EncodedKeySpec; -import java.util.Base64; +import java.util.Objects; /// # Adapay 支付回调处理服务 /// -/// Adapay 异步通知 → 主应用接收 {data, signature} → 用平台公钥 SHA1withRSA 验签 data → +/// Adapay 异步通知 → 主应用接收原始 body → 转发子应用验签与解析(平台公钥 SHA1withRSA) → /// 构建 [CallbackData] 交由 [PayCallbackService] 更新订单状态。 /// -/// 验签只需全局平台公钥(AdapayCode.PLATFORM_PUBLIC_KEY), 不需 channelMchNo, -/// 主应用直接验签(不转发子应用), 凭 order_no 反查 PayTrade。 +/// 验签只需全局平台公钥(子应用内置默认), 不需 channelMchNo, 凭 order_no 反查 PayTrade。 +/// 主应用零加密代码, 验签/解析集中在子应用 dax-pay-channel-two。 @Slf4j @Service @RequiredArgsConstructor public class AdapayPayCallbackService { + private final AdapayChannelClient adapayChannelClient; private final PayCallbackService payCallbackService; /// 支付回调处理 @@ -43,34 +41,26 @@ public class AdapayPayCallbackService { return AdapayCode.NOTIFY_FAIL; } - // 2. 解析外层 {data, signature} - JSONObject outer; - try { - outer = JSONUtil.parseObj(body); - } catch (Exception e) { - log.error("Adapay 支付回调 JSON 解析失败: body={}", body); - return AdapayCode.NOTIFY_FAIL; - } - String data = outer.getStr("data"); - String signature = outer.getStr("signature"); - if (StrUtil.isBlank(data) || StrUtil.isBlank(signature)) { - log.error("Adapay 支付回调: 缺少 data 或 signature 字段"); - return AdapayCode.NOTIFY_FAIL; - } - - // 3. 验签(平台公钥 SHA1withRSA) - if (!verifySign(data, signature)) { + // 2. 转发子应用验签解析(publicKey 为空, 子应用用全局默认平台公钥) + AdapayCallbackParseResp resp = parse(body, false); + if (resp == null || !Boolean.TRUE.equals(resp.getSuccess())) { log.error("Adapay 支付回调验签失败"); return AdapayCode.NOTIFY_FAIL; } - // 4. 解析 data 并构建 CallbackData - CallbackData callbackData = parseCallback(data); - if (callbackData == null) { - return AdapayCode.NOTIFY_FAIL; + // 3. 构建 CallbackData 交框架更新订单状态 + CallbackData callbackData = new CallbackData(); + // order_no = 下单时传入的平台 tradeNo + callbackData.setTradeNo(resp.getOutTradeNo()); + // id = Adapay 支付对象 ID + callbackData.setOutTradeNo(resp.getTradeNo()); + if (Objects.equals(resp.getTradeStatus(), "SUCCESS")) { + callbackData.setTradeStatus(CallbackStatusEnum.SUCCESS.getCode()); + } else { + callbackData.setCallbackErrorMsg("Adapay 回调状态非成功: " + resp.getTradeStatus()); } + callbackData.setFinishTime(resp.getFinishTime()); try { - // 5. 交由框架更新订单状态 payCallbackService.payCallback(callbackData); } catch (Exception e) { log.error("Adapay 支付回调业务处理失败: tradeNo={}", callbackData.getTradeNo(), e); @@ -79,43 +69,19 @@ public class AdapayPayCallbackService { return AdapayCode.NOTIFY_SUCCESS; } - /// 验证回调签名(SHA1withRSA + 平台公钥) - private boolean verifySign(String data, String signature) { - try { - byte[] keyBytes = Base64.getDecoder().decode(AdapayCode.PLATFORM_PUBLIC_KEY); - X509EncodedKeySpec keySpec = new X509EncodedKeySpec(keyBytes); - PublicKey pubKey = KeyFactory.getInstance("RSA").generatePublic(keySpec); - Signature sign = Signature.getInstance("SHA1withRSA"); - sign.initVerify(pubKey); - sign.update(data.getBytes(StandardCharsets.UTF_8)); - return sign.verify(Base64.getDecoder().decode(signature)); - } catch (Exception e) { - log.error("Adapay 回调验签异常", e); - return false; - } - } - - /// 解析 data JSON 为 CallbackData - private CallbackData parseCallback(String data) { - try { - JSONObject dataObj = JSONUtil.parseObj(data); - CallbackData callbackData = new CallbackData(); - // order_no = 下单时传入的平台 tradeNo - callbackData.setTradeNo(dataObj.getStr("order_no")); - // id = Adapay 支付对象 ID - callbackData.setOutTradeNo(dataObj.getStr("id")); - // 交易状态映射(succeeded → SUCCESS, 其他原样透传) - String status = dataObj.getStr("status"); - if ("succeeded".equals(status)) { - callbackData.setTradeStatus(CallbackStatusEnum.SUCCESS.getCode()); - } else { - callbackData.setTradeStatus(status); - callbackData.setCallbackErrorMsg("Adapay 回调状态非成功: " + status); - } - return callbackData; - } catch (Exception e) { - log.error("Adapay 回调 data 解析失败: data={}", data, e); + /// 转发子应用验签解析 + private AdapayCallbackParseResp parse(String body, boolean refund) { + AdapaySdkCredential credential = new AdapaySdkCredential(); + AdapayCallbackParseReq req = new AdapayCallbackParseReq(); + req.setCredential(credential); + req.setBody(body); + var result = refund + ? adapayChannelClient.parseRefundCallback(req) + : adapayChannelClient.parsePayCallback(req); + if (result.getCode() != 0) { + log.error("Adapay 回调: 子应用解析失败: {}", result.getMsg()); return null; } + return result.getData(); } } diff --git a/daxpay-channel/daxpay-channel-adapay/src/main/java/cn/daxpay/open/channel/adapay/service/callback/AdapayRefundCallbackService.java b/daxpay-channel/daxpay-channel-adapay/src/main/java/cn/daxpay/open/channel/adapay/service/callback/AdapayRefundCallbackService.java index 00b0b933d..3284efb62 100644 --- a/daxpay-channel/daxpay-channel-adapay/src/main/java/cn/daxpay/open/channel/adapay/service/callback/AdapayRefundCallbackService.java +++ b/daxpay-channel/daxpay-channel-adapay/src/main/java/cn/daxpay/open/channel/adapay/service/callback/AdapayRefundCallbackService.java @@ -1,34 +1,36 @@ package cn.daxpay.open.channel.adapay.service.callback; +import cn.daxpay.open.channel.adapay.client.AdapayChannelClient; +import cn.daxpay.open.channel.adapay.client.credential.AdapaySdkCredential; +import cn.daxpay.open.channel.adapay.client.req.AdapayCallbackParseReq; +import cn.daxpay.open.channel.adapay.client.resp.AdapayCallbackParseResp; import cn.daxpay.open.channel.adapay.code.AdapayCode; +import cn.daxpay.open.payment.common.callback.RefundCallbackData; +import cn.daxpay.open.payment.core.trade.service.RefundCallbackService; +import cn.daxpay.open.platform.core.enums.pay.notice.CallbackStatusEnum; import cn.hutool.core.util.StrUtil; import cn.hutool.extra.servlet.JakartaServletUtil; -import cn.hutool.json.JSONObject; -import cn.hutool.json.JSONUtil; import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; -import java.nio.charset.StandardCharsets; -import java.security.KeyFactory; -import java.security.PublicKey; -import java.security.Signature; -import java.security.spec.X509EncodedKeySpec; -import java.util.Base64; import java.util.Objects; /// # Adapay 退款回调处理服务 /// -/// Adapay 退款异步通知 → 主应用接收 {data, signature} → 用平台公钥验签 → 记录退款结果。 +/// Adapay 退款异步通知 → 主应用接收原始 body → 转发子应用验签与解析 → +/// 构建 [RefundCallbackData] 交由 [RefundCallbackService] 更新退款单状态。 /// -/// TODO 退款单状态更新待接入退款回调框架(平台目前无独立 RefundCallbackService, -/// 后续可通过 PayRefundService 或新增退款回调入口完成状态流转)。 +/// 验签只需全局平台公钥(子应用内置默认), 主应用零加密代码。 @Slf4j @Service @RequiredArgsConstructor public class AdapayRefundCallbackService { + private final AdapayChannelClient adapayChannelClient; + private final RefundCallbackService refundCallbackService; + /// 退款回调处理 public String refundHandle(HttpServletRequest request) { String body = JakartaServletUtil.getBody(request); @@ -37,51 +39,34 @@ public class AdapayRefundCallbackService { return AdapayCode.NOTIFY_FAIL; } - JSONObject outer; - try { - outer = JSONUtil.parseObj(body); - } catch (Exception e) { - log.error("Adapay 退款回调 JSON 解析失败: body={}", body); + // 转发子应用验签解析(publicKey 为空, 子应用用全局默认平台公钥) + AdapaySdkCredential credential = new AdapaySdkCredential(); + AdapayCallbackParseReq req = new AdapayCallbackParseReq(); + req.setCredential(credential); + req.setBody(body); + var result = adapayChannelClient.parseRefundCallback(req); + if (result.getCode() != 0) { + log.error("Adapay 退款回调: 子应用解析失败: {}", result.getMsg()); return AdapayCode.NOTIFY_FAIL; } - String data = outer.getStr("data"); - String signature = outer.getStr("signature"); - if (StrUtil.isBlank(data) || StrUtil.isBlank(signature)) { - log.error("Adapay 退款回调: 缺少 data 或 signature 字段"); - return AdapayCode.NOTIFY_FAIL; - } - - // 验签 - if (!verifySign(data, signature)) { + AdapayCallbackParseResp resp = result.getData(); + if (resp == null || !Boolean.TRUE.equals(resp.getSuccess())) { log.error("Adapay 退款回调验签失败"); return AdapayCode.NOTIFY_FAIL; } - // 解析退款结果 - JSONObject dataObj = JSONUtil.parseObj(data); - String outRefundNo = dataObj.getStr("refund_order_no"); - String status = dataObj.getStr("status"); - log.info("Adapay 退款回调: outRefundNo={}, status={}", outRefundNo, status); - if (Objects.equals("succeeded", status)) { - // TODO 退款成功, 更新退款单状态(待接入退款回调框架) - log.info("Adapay 退款成功: outRefundNo={}", outRefundNo); + // 构建退款回调数据, 交框架更新退款单状态 + RefundCallbackData callbackData = new RefundCallbackData(); + // out_refund_no = 平台退款号 + callbackData.setRefundNo(resp.getOutTradeNo()); + callbackData.setOutRefundNo(resp.getOutRefundNo()); + if (Objects.equals(resp.getTradeStatus(), "SUCCESS")) { + callbackData.setTradeStatus(CallbackStatusEnum.SUCCESS.getCode()); + } else { + callbackData.setTradeErrorMsg("Adapay 退款状态非成功: " + resp.getTradeStatus()); } + callbackData.setFinishTime(resp.getFinishTime()); + refundCallbackService.refundCallback(callbackData); return AdapayCode.NOTIFY_SUCCESS; } - - /// 验证回调签名(SHA1withRSA + 平台公钥, 与支付回调一致) - private boolean verifySign(String data, String signature) { - try { - byte[] keyBytes = Base64.getDecoder().decode(AdapayCode.PLATFORM_PUBLIC_KEY); - X509EncodedKeySpec keySpec = new X509EncodedKeySpec(keyBytes); - PublicKey pubKey = KeyFactory.getInstance("RSA").generatePublic(keySpec); - Signature sign = Signature.getInstance("SHA1withRSA"); - sign.initVerify(pubKey); - sign.update(data.getBytes(StandardCharsets.UTF_8)); - return sign.verify(Base64.getDecoder().decode(signature)); - } catch (Exception e) { - log.error("Adapay 退款回调验签异常", e); - return false; - } - } } 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 dadfd7c21..41b8a01ae 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 @@ -1,10 +1,12 @@ package cn.daxpay.open.channel.alipay.client; +import cn.daxpay.open.channel.alipay.client.req.AlipayCallbackParseReq; import cn.daxpay.open.channel.alipay.client.req.AlipayCloseReq; import cn.daxpay.open.channel.alipay.client.req.AlipayPayReq; import cn.daxpay.open.channel.alipay.client.req.AlipayRefundReq; import cn.daxpay.open.channel.alipay.client.req.AlipayRefundSyncReq; import cn.daxpay.open.channel.alipay.client.req.AlipaySyncReq; +import cn.daxpay.open.channel.alipay.client.resp.AlipayCallbackParseResp; 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; @@ -41,4 +43,12 @@ public interface AlipayChannelClient { /// 退款同步(查询退款状态) @PostExchange("/channel/alipay/refund-sync") DaxResult refundSync(@RequestBody AlipayRefundSyncReq req); + + /// 支付回调验签解析(转发子应用) + @PostExchange("/channel/alipay/callback/parse-pay") + DaxResult parsePayCallback(@RequestBody AlipayCallbackParseReq req); + + /// 退款回调验签解析(转发子应用) + @PostExchange("/channel/alipay/callback/parse-refund") + DaxResult parseRefundCallback(@RequestBody AlipayCallbackParseReq req); } diff --git a/daxpay-channel/daxpay-channel-alipay/src/main/java/cn/daxpay/open/channel/alipay/client/req/AlipayCallbackParseReq.java b/daxpay-channel/daxpay-channel-alipay/src/main/java/cn/daxpay/open/channel/alipay/client/req/AlipayCallbackParseReq.java new file mode 100644 index 000000000..76e10e71c --- /dev/null +++ b/daxpay-channel/daxpay-channel-alipay/src/main/java/cn/daxpay/open/channel/alipay/client/req/AlipayCallbackParseReq.java @@ -0,0 +1,20 @@ +package cn.daxpay.open.channel.alipay.client.req; + +import cn.daxpay.open.channel.alipay.client.credential.AlipaySdkCredential; +import lombok.Data; + +import java.util.Map; + +/// # 支付宝回调验签解析请求(主应用 → 子应用) +/// +/// 与子应用 dax-pay-channel-one 的 `AlipayCallbackParseReq` 镜像, 跨 HTTP 传输时字段对齐。 +/// 主应用组装凭证 + 原始表单参数后下发, 子应用用公钥/证书验签并解析。 +@Data +public class AlipayCallbackParseReq { + + /// 通道调用凭证(用于获取支付宝公钥/证书验签) + private AlipaySdkCredential credential; + + /// 回调原始表单参数(支付宝异步通知全部 form 参数, 含 sign / sign_type) + private Map params; +} 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 new file mode 100644 index 000000000..197561b2d --- /dev/null +++ b/daxpay-channel/daxpay-channel-alipay/src/main/java/cn/daxpay/open/channel/alipay/client/resp/AlipayCallbackParseResp.java @@ -0,0 +1,39 @@ +package cn.daxpay.open.channel.alipay.client.resp; + +import lombok.Data; +import lombok.experimental.Accessors; + +import java.time.OffsetDateTime; + +/// # 支付宝回调验签解析响应(子应用 → 主应用) +/// +/// 与子应用 dax-pay-channel-one 的 `AlipayCallbackParseResp` 镜像。 +/// 兼容支付与退款两种回调: 通过 tradeType 区分。 +@Data +@Accessors(chain = true) +public class AlipayCallbackParseResp { + + /// 是否验签通过 + private Boolean success; + + /// 回调类型(PAY 支付回调 / REFUND 退款回调) + private String tradeType; + + /// 商户订单号(支付回调: out_trade_no = 平台支付交易号; 退款回调: out_request_no = 退款单号) + private String outTradeNo; + + /// 支付宝交易号(trade_no, 支付回调) + private String tradeNo; + + /// 支付宝退款流水号(退款回调) + private String outRefundNo; + + /// 交易状态(抽象态 SUCCESS / FAIL) + private String tradeStatus; + + /// 金额(单位: 分, 元转分) + private Long amount; + + /// 完成时间(东八区 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 new file mode 100644 index 000000000..b3750ceac --- /dev/null +++ b/daxpay-channel/daxpay-channel-alipay/src/main/java/cn/daxpay/open/channel/alipay/controller/callback/AlipayCallbackController.java @@ -0,0 +1,34 @@ +package cn.daxpay.open.channel.alipay.controller.callback; + +import cn.daxpay.open.channel.alipay.service.callback.AlipayCallbackService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/// # 支付宝支付回调通知控制器 +/// +/// 支付宝异步通知入口(支付/退款共用同一端点, 旧版约定无 /pay 后缀, 见 AlipayPayService.buildNotifyUrl), +/// 不走 Sa-Token 认证(由安全配置放行 `/unipay/callback/**`)。 +/// 路径带 mchNo/appId 仅作占位与未来路由, 验签所需公钥/证书凭 out_trade_no 反查订单后组装下发子应用。 +@Tag(name = "支付宝支付回调通知控制器") +@RestController +@RequestMapping("/unipay/callback/{mchNo}/{appId}/alipay") +@RequiredArgsConstructor +public class AlipayCallbackController { + + private final AlipayCallbackService alipayCallbackService; + + /// 支付宝回调(支付/退款统一入口, 由服务层按表单参数区分) + @Operation(summary = "支付宝支付/退款回调") + @PostMapping + public String notify(@PathVariable("mchNo") String mchNo, + @PathVariable("appId") String appId, + HttpServletRequest request) { + return alipayCallbackService.handle(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 new file mode 100644 index 000000000..f2721ebb7 --- /dev/null +++ b/daxpay-channel/daxpay-channel-alipay/src/main/java/cn/daxpay/open/channel/alipay/service/callback/AlipayCallbackService.java @@ -0,0 +1,188 @@ +package cn.daxpay.open.channel.alipay.service.callback; + +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.service.direct.AlipayDirectConfigAssembler; +import cn.daxpay.open.channel.alipay.service.isv.AlipayIsvConfigAssembler; +import cn.daxpay.open.payment.common.callback.CallbackData; +import cn.daxpay.open.payment.common.callback.RefundCallbackData; +import cn.daxpay.open.payment.core.trade.dao.NormalPayOrderManager; +import cn.daxpay.open.payment.core.trade.dao.PayRefundOrderManager; +import cn.daxpay.open.payment.core.trade.dao.PayTradeManager; +import cn.daxpay.open.payment.core.trade.entity.NormalPayOrder; +import cn.daxpay.open.payment.core.trade.entity.PayRefundOrder; +import cn.daxpay.open.payment.core.trade.entity.PayTrade; +import cn.daxpay.open.payment.core.trade.service.PayCallbackService; +import cn.daxpay.open.payment.core.trade.service.RefundCallbackService; +import cn.daxpay.open.platform.core.enums.pay.channel.ProductEnum; +import cn.daxpay.open.platform.core.enums.pay.notice.CallbackStatusEnum; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; + +/// # 支付宝回调处理服务(支付/退款统一入口) +/// +/// 支付宝支付与退款共用同一回调端点 `/unipay/callback/{mchNo}/{appId}/alipay`(无 /pay 后缀, +/// 旧版约定, 见 AlipayPayService.buildNotifyUrl), 通过表单参数区分: +/// - 含 `out_request_no` → 退款回调 +/// - 否则 → 支付回调 +/// +/// 流程: 主应用接收原始表单 → 凭 out_trade_no/out_request_no 反查订单 → 组装通道凭证(直连/服务商) → +/// 转发子应用验签解析 → 构建 CallbackData/RefundCallbackData 交框架更新订单状态。 +/// 主应用零加密代码, 验签与字段解析集中在子应用 dax-pay-channel-one。 +@Slf4j +@Service +@RequiredArgsConstructor +public class AlipayCallbackService { + + private static final String NOTIFY_SUCCESS = "success"; + private static final String NOTIFY_FAIL = "fail"; + + private final AlipayChannelClient alipayChannelClient; + private final AlipayDirectConfigAssembler alipayDirectConfigAssembler; + private final AlipayIsvConfigAssembler alipayIsvConfigAssembler; + private final PayTradeManager payTradeManager; + private final NormalPayOrderManager normalPayOrderManager; + private final PayRefundOrderManager payRefundOrderManager; + private final PayCallbackService payCallbackService; + private final RefundCallbackService refundCallbackService; + + /// 回调统一处理(自动区分支付/退款) + public String handle(HttpServletRequest request) { + // 1. 提取全部表单参数 + Map params = extractFormParams(request); + if (params.isEmpty()) { + log.error("支付宝回调: 表单参数为空"); + return NOTIFY_FAIL; + } + + // 2. 区分支付/退款(退款回调含 out_request_no) + boolean refund = params.containsKey("out_request_no"); + try { + if (refund) { + return handleRefund(params); + } + return handlePay(params); + } catch (Exception e) { + log.error("支付宝回调业务处理失败", e); + return NOTIFY_FAIL; + } + } + + /// 支付回调处理 + private String handlePay(Map params) { + // 凭 out_trade_no(平台交易号) 反查凭证 + String tradeNo = params.get("out_trade_no"); + AlipaySdkCredential credential = resolveCredentialByTradeNo(tradeNo); + if (credential == null) { + log.error("支付宝支付回调: 无法解析通道凭证, tradeNo={}", tradeNo); + return NOTIFY_FAIL; + } + AlipayCallbackParseResp resp = parse(params, credential, false); + if (resp == null || !Boolean.TRUE.equals(resp.getSuccess())) { + log.error("支付宝支付回调验签失败: tradeNo={}", tradeNo); + return NOTIFY_FAIL; + } + CallbackData callbackData = new CallbackData(); + callbackData.setCallbackData(params); + callbackData.setTradeNo(resp.getOutTradeNo()); + callbackData.setOutTradeNo(resp.getTradeNo()); + if (Objects.equals(resp.getTradeStatus(), "SUCCESS")) { + callbackData.setTradeStatus(CallbackStatusEnum.SUCCESS.getCode()); + } else { + callbackData.setCallbackErrorMsg("支付宝回调状态非成功: " + resp.getTradeStatus()); + } + callbackData.setFinishTime(resp.getFinishTime()); + payCallbackService.payCallback(callbackData); + return NOTIFY_SUCCESS; + } + + /// 退款回调处理 + private String handleRefund(Map params) { + // out_request_no 为平台退款号, 凭原支付订单号反查凭证 + String refundNo = params.get("out_request_no"); + PayRefundOrder refundOrder = payRefundOrderManager.findByRefundNo(refundNo).orElse(null); + if (refundOrder == null) { + log.error("支付宝退款回调: 退款单不存在 refundNo={}", refundNo); + return NOTIFY_FAIL; + } + AlipaySdkCredential credential = resolveCredentialByTradeNo(refundOrder.getOrderNo()); + if (credential == null) { + log.error("支付宝退款回调: 无法解析通道凭证, refundNo={}", refundNo); + return NOTIFY_FAIL; + } + AlipayCallbackParseResp resp = parse(params, credential, true); + if (resp == null || !Boolean.TRUE.equals(resp.getSuccess())) { + log.error("支付宝退款回调验签失败: refundNo={}", refundNo); + return NOTIFY_FAIL; + } + RefundCallbackData callbackData = new RefundCallbackData(); + callbackData.setCallbackData(params); + callbackData.setRefundNo(refundNo); + callbackData.setOutRefundNo(resp.getOutRefundNo()); + if (Objects.equals(resp.getTradeStatus(), "SUCCESS")) { + callbackData.setTradeStatus(CallbackStatusEnum.SUCCESS.getCode()); + } else { + callbackData.setTradeErrorMsg("支付宝退款回调状态非成功: " + resp.getTradeStatus()); + } + callbackData.setFinishTime(resp.getFinishTime()); + refundCallbackService.refundCallback(callbackData); + return NOTIFY_SUCCESS; + } + + /// 转发子应用验签解析 + private AlipayCallbackParseResp parse(Map params, AlipaySdkCredential credential, boolean refund) { + AlipayCallbackParseReq req = new AlipayCallbackParseReq(); + req.setCredential(credential); + req.setParams(params); + var result = refund + ? alipayChannelClient.parseRefundCallback(req) + : alipayChannelClient.parsePayCallback(req); + if (result.getCode() != 0) { + log.error("支付宝回调: 子应用解析失败: {}", result.getMsg()); + return null; + } + return result.getData(); + } + + /// 凭原支付交易号反查通道凭证(直连/服务商自动分发) + private AlipaySdkCredential resolveCredentialByTradeNo(String tradeNo) { + if (tradeNo == null || tradeNo.isBlank()) { + return null; + } + PayTrade trade = payTradeManager.findByTradeNo(tradeNo).orElse(null); + if (trade == null) { + return null; + } + NormalPayOrder normal = normalPayOrderManager.findById(trade.getContainerId()).orElse(null); + if (normal == null) { + return null; + } + // 按支付产品分发: 服务商 / 直连 + if (ProductEnum.ALIPAY_ISV.getCode().equals(trade.getProduct())) { + return alipayIsvConfigAssembler.buildConfig(trade.getMchNo()); + } + return alipayDirectConfigAssembler.buildConfig( + trade.getMchNo(), normal.getChannelMchNo(), normal.getCapability()); + } + + /// 提取 request 全部表单参数为 Map(多值取首项) + private Map extractFormParams(HttpServletRequest request) { + Map paramMap = request.getParameterMap(); + Map result = new HashMap<>(paramMap.size()); + for (Map.Entry entry : paramMap.entrySet()) { + String[] values = entry.getValue(); + if (values != null && values.length > 0) { + result.put(entry.getKey(), values[0]); + } + } + return result; + } +} diff --git a/daxpay-channel/daxpay-channel-dougong/src/main/java/cn/daxpay/open/channel/dougong/service/callback/DougongRefundCallbackService.java b/daxpay-channel/daxpay-channel-dougong/src/main/java/cn/daxpay/open/channel/dougong/service/callback/DougongRefundCallbackService.java index 3a669d07e..cde8fb8d8 100644 --- a/daxpay-channel/daxpay-channel-dougong/src/main/java/cn/daxpay/open/channel/dougong/service/callback/DougongRefundCallbackService.java +++ b/daxpay-channel/daxpay-channel-dougong/src/main/java/cn/daxpay/open/channel/dougong/service/callback/DougongRefundCallbackService.java @@ -6,8 +6,11 @@ import cn.daxpay.open.channel.dougong.client.req.DougongCallbackParseReq; import cn.daxpay.open.channel.dougong.client.resp.DougongCallbackParseResp; import cn.daxpay.open.channel.dougong.dao.isv.DougongIsvKeyConfigManager; import cn.daxpay.open.channel.dougong.entity.isv.DougongIsvKeyConfig; +import cn.daxpay.open.payment.common.callback.RefundCallbackData; import cn.daxpay.open.payment.common.result.DaxResult; +import cn.daxpay.open.payment.core.trade.service.RefundCallbackService; 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; import cn.hutool.extra.servlet.JakartaServletUtil; import jakarta.servlet.http.HttpServletRequest; @@ -31,6 +34,7 @@ public class DougongRefundCallbackService { private final DougongChannelClient dougongChannelClient; private final DougongIsvKeyConfigManager dougongIsvKeyConfigManager; + private final RefundCallbackService refundCallbackService; /// 退款回调处理 public String refundHandle(HttpServletRequest request) { @@ -61,14 +65,18 @@ public class DougongRefundCallbackService { } DougongCallbackParseResp resp = result.getData(); - // 4. 记录退款回调结果, TODO 接入退款单状态更新框架 - log.info("斗拱退款回调: outRefundNo={}, tradeNo={}, refundStatus={}, amount={}, finishTime={}", - resp.getOutTradeNo(), resp.getTradeNo(), resp.getTradeStatus(), - resp.getAmount(), resp.getFinishTime()); + // 4. 构建退款回调数据, 交框架更新退款单状态 + RefundCallbackData callbackData = new RefundCallbackData(); + // out_trade_no = 平台退款号(下单时透传), trade_no = 汇付流水号 + callbackData.setRefundNo(resp.getOutTradeNo()); + callbackData.setOutRefundNo(resp.getTradeNo()); if (REFUND_STATUS_SUCCESS.equals(resp.getTradeStatus())) { - // TODO 退款成功, 更新退款单状态(待接入退款回调框架) - log.info("斗拱退款成功: outRefundNo={}", resp.getOutTradeNo()); + callbackData.setTradeStatus(CallbackStatusEnum.SUCCESS.getCode()); + } else { + callbackData.setTradeErrorMsg("斗拱退款状态非成功: " + resp.getTradeStatus()); } + callbackData.setFinishTime(resp.getFinishTime()); + refundCallbackService.refundCallback(callbackData); // 汇付要求返回 RECV_ORD_ID_{hfSeqId} 表示接收成功 return "RECV_ORD_ID_" + resp.getTradeNo(); } diff --git a/daxpay-channel/daxpay-channel-douyin/src/main/java/cn/daxpay/open/channel/douyin/service/callback/DouyinRefundCallbackService.java b/daxpay-channel/daxpay-channel-douyin/src/main/java/cn/daxpay/open/channel/douyin/service/callback/DouyinRefundCallbackService.java index 34acdd264..237953e4f 100644 --- a/daxpay-channel/daxpay-channel-douyin/src/main/java/cn/daxpay/open/channel/douyin/service/callback/DouyinRefundCallbackService.java +++ b/daxpay-channel/daxpay-channel-douyin/src/main/java/cn/daxpay/open/channel/douyin/service/callback/DouyinRefundCallbackService.java @@ -9,7 +9,10 @@ import cn.daxpay.open.channel.douyin.dao.direct.DouyinDirectChannelMerchantManag 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.callback.RefundCallbackData; import cn.daxpay.open.payment.common.result.DaxResult; +import cn.daxpay.open.payment.core.trade.service.RefundCallbackService; +import cn.daxpay.open.platform.core.enums.pay.notice.CallbackStatusEnum; import cn.hutool.extra.servlet.JakartaServletUtil; import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; @@ -34,6 +37,7 @@ public class DouyinRefundCallbackService { private final DouyinChannelClient douyinChannelClient; private final DouyinDirectKeyConfigService keyConfigService; private final DouyinDirectChannelMerchantManager channelMerchantManager; + private final RefundCallbackService refundCallbackService; /// 退款回调处理 public String refundHandle(String mchNo, String channelMchNo, HttpServletRequest request) { @@ -65,13 +69,16 @@ public class DouyinRefundCallbackService { return DouyinPayCode.NOTIFY_FAIL; } - // 4. 记录退款回调结果, TODO 接入退款单状态更新框架 - log.info("抖音退款回调: outRefundNo={}, refundId={}, refundStatus={}, amount={}", - resp.getOutRefundNo(), resp.getRefundId(), resp.getRefundStatus(), resp.getAmount()); + // 4. 构建退款回调数据, 交框架更新退款单状态 + RefundCallbackData callbackData = new RefundCallbackData(); + callbackData.setRefundNo(resp.getOutRefundNo()); + callbackData.setOutRefundNo(resp.getRefundId()); if (Objects.equals(DouyinPayCode.REFUND_STATUS_SUCCESS, resp.getRefundStatus())) { - // TODO 退款成功, 更新退款单状态(待接入退款回调框架) - log.info("抖音退款成功: outRefundNo={}", resp.getOutRefundNo()); + callbackData.setTradeStatus(CallbackStatusEnum.SUCCESS.getCode()); + } else { + callbackData.setTradeErrorMsg("抖音退款状态非成功: " + resp.getRefundStatus()); } + refundCallbackService.refundCallback(callbackData); return DouyinPayCode.NOTIFY_SUCCESS; } diff --git a/daxpay-channel/daxpay-channel-fuyou/src/main/java/cn/daxpay/open/channel/fuyou/client/FuyouChannelClient.java b/daxpay-channel/daxpay-channel-fuyou/src/main/java/cn/daxpay/open/channel/fuyou/client/FuyouChannelClient.java index 8ae449a3f..cba12675b 100644 --- a/daxpay-channel/daxpay-channel-fuyou/src/main/java/cn/daxpay/open/channel/fuyou/client/FuyouChannelClient.java +++ b/daxpay-channel/daxpay-channel-fuyou/src/main/java/cn/daxpay/open/channel/fuyou/client/FuyouChannelClient.java @@ -1,10 +1,12 @@ package cn.daxpay.open.channel.fuyou.client; +import cn.daxpay.open.channel.fuyou.client.req.FuyouCallbackParseReq; import cn.daxpay.open.channel.fuyou.client.req.FuyouCloseReq; import cn.daxpay.open.channel.fuyou.client.req.FuyouPayReq; import cn.daxpay.open.channel.fuyou.client.req.FuyouRefundReq; import cn.daxpay.open.channel.fuyou.client.req.FuyouRefundSyncReq; import cn.daxpay.open.channel.fuyou.client.req.FuyouSyncReq; +import cn.daxpay.open.channel.fuyou.client.resp.FuyouCallbackParseResp; import cn.daxpay.open.channel.fuyou.client.resp.FuyouCloseResp; import cn.daxpay.open.channel.fuyou.client.resp.FuyouPayResp; import cn.daxpay.open.channel.fuyou.client.resp.FuyouRefundResp; @@ -42,4 +44,12 @@ public interface FuyouChannelClient { /// 退款同步(查询退款状态) @PostExchange("/channel/fuyou/refund-sync") DaxResult refundSync(@RequestBody FuyouRefundSyncReq req); + + /// 支付回调验签解析(转发子应用) + @PostExchange("/channel/fuyou/callback/parse-pay") + DaxResult parsePayCallback(@RequestBody FuyouCallbackParseReq req); + + /// 退款回调验签解析(转发子应用) + @PostExchange("/channel/fuyou/callback/parse-refund") + DaxResult parseRefundCallback(@RequestBody FuyouCallbackParseReq req); } diff --git a/daxpay-channel/daxpay-channel-fuyou/src/main/java/cn/daxpay/open/channel/fuyou/client/req/FuyouCallbackParseReq.java b/daxpay-channel/daxpay-channel-fuyou/src/main/java/cn/daxpay/open/channel/fuyou/client/req/FuyouCallbackParseReq.java new file mode 100644 index 000000000..62dc87e81 --- /dev/null +++ b/daxpay-channel/daxpay-channel-fuyou/src/main/java/cn/daxpay/open/channel/fuyou/client/req/FuyouCallbackParseReq.java @@ -0,0 +1,18 @@ +package cn.daxpay.open.channel.fuyou.client.req; + +import cn.daxpay.open.channel.fuyou.client.credential.FuyouSdkCredential; +import lombok.Data; + +/// # 富友回调验签解析请求(主应用 → 子应用) +/// +/// 与子应用 dax-pay-channel-two 的 `FuyouCallbackParseReq` 镜像。 +/// 主应用组装凭证 + 原始 `req` 表单参数值(URL 编码 XML, GBK) 下发, 子应用验签并解析。 +@Data +public class FuyouCallbackParseReq { + + /// 通道调用凭证(用于获取富友公钥验签) + private FuyouSdkCredential credential; + + /// 回调原始 `req` 表单参数值(URL 编码的 XML 字符串, GBK) + private String reqParam; +} diff --git a/daxpay-channel/daxpay-channel-fuyou/src/main/java/cn/daxpay/open/channel/fuyou/client/resp/FuyouCallbackParseResp.java b/daxpay-channel/daxpay-channel-fuyou/src/main/java/cn/daxpay/open/channel/fuyou/client/resp/FuyouCallbackParseResp.java new file mode 100644 index 000000000..2de0380ab --- /dev/null +++ b/daxpay-channel/daxpay-channel-fuyou/src/main/java/cn/daxpay/open/channel/fuyou/client/resp/FuyouCallbackParseResp.java @@ -0,0 +1,39 @@ +package cn.daxpay.open.channel.fuyou.client.resp; + +import lombok.Data; +import lombok.experimental.Accessors; + +import java.time.OffsetDateTime; + +/// # 富友回调验签解析响应(子应用 → 主应用) +/// +/// 与子应用 dax-pay-channel-two 的 `FuyouCallbackParseResp` 镜像。 +/// 兼容支付与退款两种回调: 通过 tradeType 区分。 +/// +/// 注意: outTradeNo 为富友 `mchnt_order_no`(关联订单号), 非平台交易号, +/// 主应用需凭 relationOrderNo 反查 PayTrade 得到真实 tradeNo。 +@Data +@Accessors(chain = true) +public class FuyouCallbackParseResp { + + /// 是否验签通过 + private Boolean success; + + /// 回调类型(PAY 支付回调 / REFUND 退款回调) + private String tradeType; + + /// 关联订单号(mchnt_order_no, 主应用据此反查平台 tradeNo) + private String outTradeNo; + + /// 富友交易号(transaction_id) + private String tradeNo; + + /// 通道退款流水号(退款回调) + private String outRefundNo; + + /// 交易状态(抽象态 SUCCESS / FAIL, 富友回调仅成功时通知, 验签通过即 SUCCESS) + private String tradeStatus; + + /// 完成时间(东八区 OffsetDateTime) + private OffsetDateTime finishTime; +} diff --git a/daxpay-channel/daxpay-channel-fuyou/src/main/java/cn/daxpay/open/channel/fuyou/controller/callback/FuyouCallbackController.java b/daxpay-channel/daxpay-channel-fuyou/src/main/java/cn/daxpay/open/channel/fuyou/controller/callback/FuyouCallbackController.java index 41aa86ec0..8fcc14f5e 100644 --- a/daxpay-channel/daxpay-channel-fuyou/src/main/java/cn/daxpay/open/channel/fuyou/controller/callback/FuyouCallbackController.java +++ b/daxpay-channel/daxpay-channel-fuyou/src/main/java/cn/daxpay/open/channel/fuyou/controller/callback/FuyouCallbackController.java @@ -1,6 +1,7 @@ package cn.daxpay.open.channel.fuyou.controller.callback; import cn.daxpay.open.channel.fuyou.service.callback.FuyouPayCallbackService; +import cn.daxpay.open.channel.fuyou.service.callback.FuyouRefundCallbackService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; @@ -22,6 +23,7 @@ import org.springframework.web.bind.annotation.RestController; public class FuyouCallbackController { private final FuyouPayCallbackService fuyouPayCallbackService; + private final FuyouRefundCallbackService fuyouRefundCallbackService; /// 富友支付回调 /// @@ -33,4 +35,13 @@ public class FuyouCallbackController { @RequestParam("req") String req) { return fuyouPayCallbackService.payHandle(req); } + + /// 富友退款回调 + @Operation(summary = "富友退款回调") + @PostMapping("/refund") + public String refundNotify(@PathVariable("mchNo") String mchNo, + @PathVariable("appId") String appId, + @RequestParam("req") String req) { + return fuyouRefundCallbackService.refundHandle(req); + } } diff --git a/daxpay-channel/daxpay-channel-fuyou/src/main/java/cn/daxpay/open/channel/fuyou/service/callback/FuyouPayCallbackService.java b/daxpay-channel/daxpay-channel-fuyou/src/main/java/cn/daxpay/open/channel/fuyou/service/callback/FuyouPayCallbackService.java index c05556fea..0e06fbe67 100644 --- a/daxpay-channel/daxpay-channel-fuyou/src/main/java/cn/daxpay/open/channel/fuyou/service/callback/FuyouPayCallbackService.java +++ b/daxpay-channel/daxpay-channel-fuyou/src/main/java/cn/daxpay/open/channel/fuyou/service/callback/FuyouPayCallbackService.java @@ -1,5 +1,9 @@ package cn.daxpay.open.channel.fuyou.service.callback; +import cn.daxpay.open.channel.fuyou.client.FuyouChannelClient; +import cn.daxpay.open.channel.fuyou.client.credential.FuyouSdkCredential; +import cn.daxpay.open.channel.fuyou.client.req.FuyouCallbackParseReq; +import cn.daxpay.open.channel.fuyou.client.resp.FuyouCallbackParseResp; import cn.daxpay.open.channel.fuyou.dao.isv.FuyouIsvKeyConfigManager; import cn.daxpay.open.channel.fuyou.entity.isv.FuyouIsvKeyConfig; import cn.daxpay.open.payment.common.callback.CallbackData; @@ -8,44 +12,21 @@ import cn.daxpay.open.payment.core.trade.entity.PayTrade; import cn.daxpay.open.payment.core.trade.service.PayCallbackService; import cn.daxpay.open.platform.core.enums.pay.channel.ProductEnum; import cn.daxpay.open.platform.core.enums.pay.notice.CallbackStatusEnum; -import cn.hutool.core.date.DatePattern; -import cn.hutool.core.map.MapUtil; import cn.hutool.core.util.StrUtil; -import cn.hutool.core.util.XmlUtil; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; -import java.net.URLDecoder; -import java.nio.charset.Charset; -import java.nio.charset.StandardCharsets; -import java.security.KeyFactory; -import java.security.PublicKey; -import java.security.Signature; -import java.security.spec.X509EncodedKeySpec; -import java.time.LocalDateTime; -import java.time.OffsetDateTime; -import java.time.ZoneOffset; -import java.time.format.DateTimeFormatter; -import java.util.ArrayList; -import java.util.Base64; -import java.util.Collections; -import java.util.LinkedHashMap; -import java.util.Map; import java.util.Objects; -import java.util.TreeMap; /// # 富友支付回调处理服务 /// -/// 富友异步通知 → 主应用接收 `req` 参数(URL编码的XML) → GBK 解码 → xmlToMap → -/// 用全局服务商公钥 MD5withRSA 验签 → 凭 mchnt_order_no(关联订单号) 反查 PayTrade → +/// 富友异步通知 → 主应用接收 `req` 参数(URL编码的XML, GBK) → 转发子应用验签与解析 +/// (富友公钥 MD5withRSA + GBK) → 凭 mchnt_order_no(关联订单号) 反查 PayTrade → /// 构建 [CallbackData] 交由 [PayCallbackService] 更新订单状态。 /// -/// 富友回调签名机制(与响应签名一致): -/// - 整个回调 Map(去 sign 与 reserved 开头字段)按 key 字典序拼接 `k1=v1&k2=v2` -/// - 用富友公钥 MD5withRSA + GBK 验证 sign(Base64) -/// -/// 成功响应: 返回 "1"(富友约定); 验签/解析失败返回 "0"。 +/// 富友回调用关联订单号(mchnt_order_no)而非平台交易号, 故主应用保留 DB 反查步骤。 +/// 主应用零加密代码, 验签/解析集中在子应用 dax-pay-channel-two。 @Slf4j @Service @RequiredArgsConstructor @@ -56,14 +37,7 @@ public class FuyouPayCallbackService { /// 富友回调失败响应 public static final String RESP_FAIL = "0"; - /// 富友字符集(GBK) - private static final Charset CHARSET = Charset.forName("GBK"); - /// 东八区 - private static final ZoneOffset CST = ZoneOffset.ofHours(8); - /// 富友回调时间格式(yyyyMMddHHmmss) - private static final DateTimeFormatter PAY_TIME_FORMATTER = - DateTimeFormatter.ofPattern(DatePattern.PURE_DATETIME_PATTERN); - + private final FuyouChannelClient fuyouChannelClient; private final FuyouIsvKeyConfigManager fuyouIsvKeyConfigManager; private final PayTradeManager payTradeManager; private final PayCallbackService payCallbackService; @@ -75,17 +49,7 @@ public class FuyouPayCallbackService { return RESP_FAIL; } - // 1. GBK 解码 + XML 转 Map - Map callbackParam; - try { - String xml = URLDecoder.decode(reqParam, CHARSET); - callbackParam = xmlToMap(xml); - } catch (Exception e) { - log.error("富友支付回调解析失败", e); - return RESP_FAIL; - } - - // 2. 获取全局服务商公钥(只读查询) + // 1. 获取全局服务商公钥(只读查询) FuyouIsvKeyConfig keyConfig = fuyouIsvKeyConfigManager.findByProduct(ProductEnum.FUYOU_PAY.getCode()) .orElse(null); if (keyConfig == null || StrUtil.isBlank(keyConfig.getPublicKey())) { @@ -93,15 +57,15 @@ public class FuyouPayCallbackService { return RESP_FAIL; } - // 3. 验签(MD5withRSA + GBK) - String sign = callbackParam.get("sign"); - if (StrUtil.isBlank(sign) || !verifySign(callbackParam, sign, keyConfig.getPublicKey())) { + // 2. 转发子应用验签解析 + FuyouCallbackParseResp resp = parse(reqParam, keyConfig.getPublicKey(), false); + if (resp == null || !Boolean.TRUE.equals(resp.getSuccess())) { log.error("富友支付回调验签失败"); return RESP_FAIL; } - // 4. 凭关联订单号(mchnt_order_no)反查平台交易号 - String relationOrderNo = MapUtil.getStr(callbackParam, "mchnt_order_no"); + // 3. 凭关联订单号(mchnt_order_no)反查平台交易号 + String relationOrderNo = resp.getOutTradeNo(); PayTrade trade = payTradeManager.lambdaQuery() .eq(PayTrade::getRelationOrderNo, relationOrderNo) .oneOpt() @@ -111,23 +75,17 @@ public class FuyouPayCallbackService { return RESP_FAIL; } - // 5. 构建 CallbackData + // 4. 构建 CallbackData 交框架更新订单 CallbackData callbackData = new CallbackData(); - callbackData.setCallbackData(callbackParam); callbackData.setTradeNo(trade.getTradeNo()); - callbackData.setOutTradeNo(MapUtil.getStr(callbackParam, "transaction_id")); + callbackData.setOutTradeNo(resp.getTradeNo()); // 富友支付回调通知仅出现在支付成功时 - callbackData.setTradeStatus("SUCCESS"); - // 完成时间(yyyyMMddHHmmss → OffsetDateTime) - String finishTime = MapUtil.getStr(callbackParam, "txn_fin_ts"); - if (StrUtil.isNotBlank(finishTime)) { - try { - callbackData.setFinishTime(LocalDateTime.parse(finishTime.trim(), PAY_TIME_FORMATTER).atOffset(CST)); - } catch (Exception e) { - log.warn("富友回调时间解析失败: txn_fin_ts={}", finishTime); - } + if (Objects.equals(resp.getTradeStatus(), "SUCCESS")) { + callbackData.setTradeStatus(CallbackStatusEnum.SUCCESS.getCode()); + } else { + callbackData.setCallbackErrorMsg("富友回调状态非成功: " + resp.getTradeStatus()); } - + callbackData.setFinishTime(resp.getFinishTime()); try { payCallbackService.payCallback(callbackData); return RESP_SUCCESS; @@ -137,51 +95,20 @@ public class FuyouPayCallbackService { } } - /// 验证回调签名: 去 sign 与 reserved 开头字段, 字典序拼接, MD5withRSA + GBK 验签 - private boolean verifySign(Map params, String sign, String publicKeyStr) { - try { - // 过滤 sign 与 reserved 开头字段 - Map filtered = new TreeMap<>(); - for (Map.Entry entry : params.entrySet()) { - String key = entry.getKey(); - if (key.equalsIgnoreCase("sign") || (key.length() >= 8 && key.substring(0, 8).equalsIgnoreCase("reserved"))) { - continue; - } - filtered.put(key, entry.getValue() == null ? "" : entry.getValue()); - } - // 字典序拼接 k1=v1&k2=v2 - ArrayList keys = new ArrayList<>(filtered.keySet()); - Collections.sort(keys); - StringBuilder sb = new StringBuilder(); - for (int i = 0; i < keys.size(); i++) { - if (i > 0) { - sb.append("&"); - } - sb.append(keys.get(i)).append("=").append(filtered.get(keys.get(i))); - } - String signSource = sb.toString(); - - // MD5withRSA + GBK 验签 - byte[] keyBytes = Base64.getDecoder().decode(publicKeyStr.replaceAll("[\\s*\t\n\r]", "")); - X509EncodedKeySpec keySpec = new X509EncodedKeySpec(keyBytes); - PublicKey publicKey = KeyFactory.getInstance("RSA").generatePublic(keySpec); - Signature signature = Signature.getInstance("MD5withRSA"); - signature.initVerify(publicKey); - signature.update(signSource.getBytes(CHARSET)); - return signature.verify(Base64.getDecoder().decode(sign)); - } catch (Exception e) { - log.error("富友回调验签异常", e); - return false; + /// 转发子应用验签解析 + FuyouCallbackParseResp parse(String reqParam, String publicKey, boolean refund) { + FuyouSdkCredential credential = new FuyouSdkCredential(); + credential.setPublicKey(publicKey); + FuyouCallbackParseReq req = new FuyouCallbackParseReq(); + req.setCredential(credential); + req.setReqParam(reqParam); + var result = refund + ? fuyouChannelClient.parseRefundCallback(req) + : fuyouChannelClient.parsePayCallback(req); + if (result.getCode() != 0) { + log.error("富友回调: 子应用解析失败: {}", result.getMsg()); + return null; } - } - - /// XML 字符串转 Map(值统一转 String) - private Map xmlToMap(String xml) { - Map raw = XmlUtil.xmlToMap(xml); - Map result = new LinkedHashMap<>(raw.size()); - for (Map.Entry entry : raw.entrySet()) { - result.put(entry.getKey(), entry.getValue() == null ? "" : entry.getValue().toString()); - } - return result; + return result.getData(); } } diff --git a/daxpay-channel/daxpay-channel-fuyou/src/main/java/cn/daxpay/open/channel/fuyou/service/callback/FuyouRefundCallbackService.java b/daxpay-channel/daxpay-channel-fuyou/src/main/java/cn/daxpay/open/channel/fuyou/service/callback/FuyouRefundCallbackService.java new file mode 100644 index 000000000..5f3bda65b --- /dev/null +++ b/daxpay-channel/daxpay-channel-fuyou/src/main/java/cn/daxpay/open/channel/fuyou/service/callback/FuyouRefundCallbackService.java @@ -0,0 +1,64 @@ +package cn.daxpay.open.channel.fuyou.service.callback; + +import cn.daxpay.open.channel.fuyou.client.resp.FuyouCallbackParseResp; +import cn.daxpay.open.channel.fuyou.dao.isv.FuyouIsvKeyConfigManager; +import cn.daxpay.open.channel.fuyou.entity.isv.FuyouIsvKeyConfig; +import cn.daxpay.open.payment.common.callback.RefundCallbackData; +import cn.daxpay.open.payment.core.trade.service.RefundCallbackService; +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; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.util.Objects; + +/// # 富友退款回调处理服务 +/// +/// 富友退款异步通知 → 主应用接收 `req` 参数 → 转发子应用验签与解析 → +/// 构建 [RefundCallbackData] 交由 [RefundCallbackService] 更新退款单状态。 +/// +/// 富友退款回调用 mchnt_order_no 作为退款单标识, 主应用据此作为 refundNo 反查退款单。 +@Slf4j +@Service +@RequiredArgsConstructor +public class FuyouRefundCallbackService { + + private final FuyouPayCallbackService fuyouPayCallbackService; + private final FuyouIsvKeyConfigManager fuyouIsvKeyConfigManager; + private final RefundCallbackService refundCallbackService; + + /// 退款回调处理 + public String refundHandle(String reqParam) { + if (StrUtil.isBlank(reqParam)) { + log.error("富友退款回调: req 参数为空"); + return FuyouPayCallbackService.RESP_FAIL; + } + FuyouIsvKeyConfig keyConfig = fuyouIsvKeyConfigManager.findByProduct(ProductEnum.FUYOU_PAY.getCode()) + .orElse(null); + if (keyConfig == null || StrUtil.isBlank(keyConfig.getPublicKey())) { + log.error("富友退款回调: 服务商密钥未配置, 无法验签"); + return FuyouPayCallbackService.RESP_FAIL; + } + + FuyouCallbackParseResp resp = fuyouPayCallbackService.parse(reqParam, keyConfig.getPublicKey(), true); + if (resp == null || !Boolean.TRUE.equals(resp.getSuccess())) { + log.error("富友退款回调验签失败"); + return FuyouPayCallbackService.RESP_FAIL; + } + + RefundCallbackData callbackData = new RefundCallbackData(); + // mchnt_order_no 作为退款单标识反查退款单 + callbackData.setRefundNo(resp.getOutTradeNo()); + callbackData.setOutRefundNo(resp.getOutRefundNo()); + if (Objects.equals(resp.getTradeStatus(), "SUCCESS")) { + callbackData.setTradeStatus(CallbackStatusEnum.SUCCESS.getCode()); + } else { + callbackData.setTradeErrorMsg("富友退款状态非成功: " + resp.getTradeStatus()); + } + callbackData.setFinishTime(resp.getFinishTime()); + refundCallbackService.refundCallback(callbackData); + return FuyouPayCallbackService.RESP_SUCCESS; + } +} diff --git a/daxpay-channel/daxpay-channel-hkrt/src/main/java/cn/daxpay/open/channel/hkrt/service/callback/HkrtRefundCallbackService.java b/daxpay-channel/daxpay-channel-hkrt/src/main/java/cn/daxpay/open/channel/hkrt/service/callback/HkrtRefundCallbackService.java index fed831f0b..31e75faee 100644 --- a/daxpay-channel/daxpay-channel-hkrt/src/main/java/cn/daxpay/open/channel/hkrt/service/callback/HkrtRefundCallbackService.java +++ b/daxpay-channel/daxpay-channel-hkrt/src/main/java/cn/daxpay/open/channel/hkrt/service/callback/HkrtRefundCallbackService.java @@ -6,7 +6,10 @@ import cn.daxpay.open.channel.hkrt.client.req.HkrtCallbackParseReq; import cn.daxpay.open.channel.hkrt.client.resp.HkrtCallbackParseResp; import cn.daxpay.open.channel.hkrt.dao.isv.HkrtIsvKeyConfigManager; import cn.daxpay.open.channel.hkrt.entity.isv.HkrtIsvKeyConfig; +import cn.daxpay.open.payment.common.callback.RefundCallbackData; +import cn.daxpay.open.payment.core.trade.service.RefundCallbackService; import cn.daxpay.open.platform.core.enums.pay.channel.ProductEnum; +import cn.daxpay.open.platform.core.enums.pay.notice.CallbackStatusEnum; import cn.hutool.extra.servlet.JakartaServletUtil; import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; @@ -37,6 +40,7 @@ public class HkrtRefundCallbackService { private final HkrtIsvKeyConfigManager hkrtIsvKeyConfigManager; private final HkrtChannelClient hkrtChannelClient; + private final RefundCallbackService refundCallbackService; /// 退款回调处理 public String refundHandle(HttpServletRequest request) { @@ -57,16 +61,18 @@ public class HkrtRefundCallbackService { return NOTIFY_FAIL; } - // 4. 记录退款回调结果, TODO 接入退款单状态更新框架 - String outRefundNo = resp.getOutTradeNo(); - String refundNo = resp.getTradeNo(); - String refundStatus = resp.getTradeStatus(); - log.info("海科融通退款回调: outRefundNo={}, refundNo={}, refundStatus={}, amount={}, finishTime={}", - outRefundNo, refundNo, refundStatus, resp.getAmount(), resp.getFinishTime()); - if (Objects.equals(REFUND_STATUS_SUCCESS, refundStatus)) { - // TODO 退款成功, 更新退款单状态(待接入退款回调框架) - log.info("海科融通退款成功: outRefundNo={}", outRefundNo); + // 4. 构建退款回调数据, 交框架更新退款单状态 + RefundCallbackData callbackData = new RefundCallbackData(); + // out_trade_no = 平台退款号(下单时透传), trade_no = 海科退款流水号 + callbackData.setRefundNo(resp.getOutTradeNo()); + callbackData.setOutRefundNo(resp.getTradeNo()); + if (Objects.equals(REFUND_STATUS_SUCCESS, resp.getTradeStatus())) { + callbackData.setTradeStatus(CallbackStatusEnum.SUCCESS.getCode()); + } else { + callbackData.setTradeErrorMsg("海科融通退款状态非成功: " + resp.getTradeStatus()); } + callbackData.setFinishTime(resp.getFinishTime()); + refundCallbackService.refundCallback(callbackData); return NOTIFY_SUCCESS; } diff --git a/daxpay-channel/daxpay-channel-hmpay/src/main/java/cn/daxpay/open/channel/hmpay/service/callback/HmpayRefundCallbackService.java b/daxpay-channel/daxpay-channel-hmpay/src/main/java/cn/daxpay/open/channel/hmpay/service/callback/HmpayRefundCallbackService.java index 3eab2cad6..48af0b2bd 100644 --- a/daxpay-channel/daxpay-channel-hmpay/src/main/java/cn/daxpay/open/channel/hmpay/service/callback/HmpayRefundCallbackService.java +++ b/daxpay-channel/daxpay-channel-hmpay/src/main/java/cn/daxpay/open/channel/hmpay/service/callback/HmpayRefundCallbackService.java @@ -6,7 +6,10 @@ import cn.daxpay.open.channel.hmpay.client.req.HmpayCallbackParseReq; import cn.daxpay.open.channel.hmpay.client.resp.HmpayCallbackParseResp; import cn.daxpay.open.channel.hmpay.dao.isv.HmpayIsvKeyConfigManager; import cn.daxpay.open.channel.hmpay.entity.isv.HmpayIsvKeyConfig; +import cn.daxpay.open.payment.common.callback.RefundCallbackData; import cn.daxpay.open.payment.common.result.DaxResult; +import cn.daxpay.open.payment.core.trade.service.RefundCallbackService; +import cn.daxpay.open.platform.core.enums.pay.notice.CallbackStatusEnum; import cn.daxpay.open.platform.core.enums.pay.channel.ProductEnum; import cn.hutool.core.util.StrUtil; import cn.hutool.extra.servlet.JakartaServletUtil; @@ -15,6 +18,8 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; +import java.util.Objects; + /// # 河马付退款回调处理服务 /// /// 杉德退款异步通知 → 主应用接收原始 body → 转发子应用用杉德公钥 RSA 验签 → 记录退款回调结果。 @@ -31,6 +36,7 @@ public class HmpayRefundCallbackService { private final HmpayChannelClient hmpayChannelClient; private final HmpayIsvKeyConfigManager hmpayIsvKeyConfigManager; + private final RefundCallbackService refundCallbackService; /// 退款回调处理 public String refundHandle(HttpServletRequest request) { @@ -61,11 +67,17 @@ public class HmpayRefundCallbackService { } HmpayCallbackParseResp resp = result.getData(); - // 4. 记录退款回调结果, TODO 接入退款单状态更新框架 - log.info("河马付退款回调: outRefundNo={}, tradeNo={}, amount={}, finishTime={}", - resp.getOutTradeNo(), resp.getTradeNo(), resp.getAmount(), resp.getFinishTime()); - // TODO 退款成功, 更新退款单状态(待接入退款回调框架) - log.info("河马付退款成功: outRefundNo={}", resp.getOutTradeNo()); + // 4. 构建退款回调数据, 交框架更新退款单状态 + RefundCallbackData callbackData = new RefundCallbackData(); + callbackData.setRefundNo(resp.getOutTradeNo()); + callbackData.setOutRefundNo(resp.getTradeNo()); + if (Objects.equals("SUCCESS", resp.getTradeStatus())) { + callbackData.setTradeStatus(CallbackStatusEnum.SUCCESS.getCode()); + } else { + callbackData.setTradeErrorMsg("河马付退款状态非成功: " + resp.getTradeStatus()); + } + callbackData.setFinishTime(resp.getFinishTime()); + refundCallbackService.refundCallback(callbackData); // 杉德要求返回 success 表示接收成功 return NOTIFY_SUCCESS; } diff --git a/daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/client/LakalaChannelClient.java b/daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/client/LakalaChannelClient.java index 788f61148..a732bcfb2 100644 --- a/daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/client/LakalaChannelClient.java +++ b/daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/client/LakalaChannelClient.java @@ -1,10 +1,12 @@ package cn.daxpay.open.channel.lakala.client; +import cn.daxpay.open.channel.lakala.client.req.LakalaCallbackParseReq; import cn.daxpay.open.channel.lakala.client.req.LakalaCloseReq; import cn.daxpay.open.channel.lakala.client.req.LakalaPayReq; import cn.daxpay.open.channel.lakala.client.req.LakalaRefundReq; import cn.daxpay.open.channel.lakala.client.req.LakalaRefundSyncReq; import cn.daxpay.open.channel.lakala.client.req.LakalaSyncReq; +import cn.daxpay.open.channel.lakala.client.resp.LakalaCallbackParseResp; import cn.daxpay.open.channel.lakala.client.resp.LakalaCloseResp; import cn.daxpay.open.channel.lakala.client.resp.LakalaPayResp; import cn.daxpay.open.channel.lakala.client.resp.LakalaRefundResp; @@ -42,4 +44,12 @@ public interface LakalaChannelClient { /// 退款同步(查询退款状态) @PostExchange("/channel/lakala/refund-sync") DaxResult refundSync(@RequestBody LakalaRefundSyncReq req); + + /// 支付回调验签解析(转发子应用) + @PostExchange("/channel/lakala/callback/parse-pay") + DaxResult parsePayCallback(@RequestBody LakalaCallbackParseReq req); + + /// 退款回调验签解析(转发子应用) + @PostExchange("/channel/lakala/callback/parse-refund") + DaxResult parseRefundCallback(@RequestBody LakalaCallbackParseReq req); } diff --git a/daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/client/req/LakalaCallbackParseReq.java b/daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/client/req/LakalaCallbackParseReq.java new file mode 100644 index 000000000..e39fdb403 --- /dev/null +++ b/daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/client/req/LakalaCallbackParseReq.java @@ -0,0 +1,23 @@ +package cn.daxpay.open.channel.lakala.client.req; + +import cn.daxpay.open.channel.lakala.client.credential.LakalaSdkCredential; +import lombok.Data; + +import java.util.Map; + +/// # 拉卡拉回调验签解析请求(主应用 → 子应用) +/// +/// 与子应用 dax-pay-channel-two 的 `LakalaCallbackParseReq` 镜像。 +/// 主应用组装凭证 + 原始 body + header(含 Authorization) 下发, 子应用验签并解析。 +@Data +public class LakalaCallbackParseReq { + + /// 通道调用凭证(用于获取拉卡拉公钥证书验签) + private LakalaSdkCredential credential; + + /// 回调原始 body(拉卡拉异步通知 JSON) + private String body; + + /// 回调头(需含 Authorization: LKLAPI-SHA256withRSA ...) + private Map headers; +} diff --git a/daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/client/resp/LakalaCallbackParseResp.java b/daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/client/resp/LakalaCallbackParseResp.java new file mode 100644 index 000000000..0359752ac --- /dev/null +++ b/daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/client/resp/LakalaCallbackParseResp.java @@ -0,0 +1,36 @@ +package cn.daxpay.open.channel.lakala.client.resp; + +import lombok.Data; +import lombok.experimental.Accessors; + +import java.time.OffsetDateTime; + +/// # 拉卡拉回调验签解析响应(子应用 → 主应用) +/// +/// 与子应用 dax-pay-channel-two 的 `LakalaCallbackParseResp` 镜像。 +/// 兼容支付与退款两种回调: 通过 tradeType 区分。 +@Data +@Accessors(chain = true) +public class LakalaCallbackParseResp { + + /// 是否验签通过 + private Boolean success; + + /// 回调类型(PAY 支付回调 / REFUND 退款回调) + private String tradeType; + + /// 商户订单号(支付回调: out_trade_no = 平台支付交易号; 退款回调: out_refund_no = 退款单号) + private String outTradeNo; + + /// 拉卡拉交易号(支付回调 trade_no) + private String tradeNo; + + /// 通道退款流水号(退款回调) + private String outRefundNo; + + /// 交易状态(抽象态 SUCCESS / FAIL) + private String tradeStatus; + + /// 完成时间(东八区 OffsetDateTime) + private OffsetDateTime finishTime; +} diff --git a/daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/controller/callback/LakalaCallbackController.java b/daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/controller/callback/LakalaCallbackController.java index 047ad085c..0a2427aa1 100644 --- a/daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/controller/callback/LakalaCallbackController.java +++ b/daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/controller/callback/LakalaCallbackController.java @@ -1,6 +1,7 @@ package cn.daxpay.open.channel.lakala.controller.callback; import cn.daxpay.open.channel.lakala.service.callback.LakalaPayCallbackService; +import cn.daxpay.open.channel.lakala.service.callback.LakalaRefundCallbackService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.servlet.http.HttpServletRequest; @@ -22,6 +23,7 @@ import org.springframework.web.bind.annotation.RestController; public class LakalaCallbackController { private final LakalaPayCallbackService lakalaPayCallbackService; + private final LakalaRefundCallbackService lakalaRefundCallbackService; /// 拉卡拉支付回调 @Operation(summary = "拉卡拉支付回调") @@ -31,4 +33,13 @@ public class LakalaCallbackController { HttpServletRequest request) { return lakalaPayCallbackService.payHandle(request); } + + /// 拉卡拉退款回调 + @Operation(summary = "拉卡拉退款回调") + @PostMapping("/refund") + public String refundNotify(@PathVariable("mchNo") String mchNo, + @PathVariable("appId") String appId, + HttpServletRequest request) { + return lakalaRefundCallbackService.refundHandle(request); + } } diff --git a/daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/service/callback/LakalaPayCallbackService.java b/daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/service/callback/LakalaPayCallbackService.java index 2a45f155f..ea26c844a 100644 --- a/daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/service/callback/LakalaPayCallbackService.java +++ b/daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/service/callback/LakalaPayCallbackService.java @@ -1,40 +1,33 @@ package cn.daxpay.open.channel.lakala.service.callback; +import cn.daxpay.open.channel.lakala.client.LakalaChannelClient; +import cn.daxpay.open.channel.lakala.client.credential.LakalaSdkCredential; +import cn.daxpay.open.channel.lakala.client.req.LakalaCallbackParseReq; +import cn.daxpay.open.channel.lakala.client.resp.LakalaCallbackParseResp; import cn.daxpay.open.channel.lakala.dao.isv.LakalaIsvKeyConfigManager; import cn.daxpay.open.channel.lakala.entity.isv.LakalaIsvKeyConfig; import cn.daxpay.open.payment.common.callback.CallbackData; import cn.daxpay.open.payment.core.trade.service.PayCallbackService; 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; 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.nio.charset.StandardCharsets; -import java.security.KeyFactory; -import java.security.Signature; -import java.security.cert.CertificateFactory; -import java.security.cert.X509Certificate; -import java.time.OffsetDateTime; -import java.time.ZoneOffset; -import java.time.format.DateTimeFormatter; -import java.util.Base64; import java.util.Map; import java.util.Objects; /// # 拉卡拉支付回调处理服务 /// -/// 拉卡拉异步通知 → 主应用接收原始 header + body → 用全局服务商公钥 RSA2 验签 → +/// 拉卡拉异步通知 → 主应用接收原始 header + body → 转发子应用验签与解析 +/// (Authorization 头 SHA256withRSA + 拉卡拉公钥证书) → /// 构建 [CallbackData] 交由 [PayCallbackService] 更新订单状态。 /// -/// 拉卡拉回调签名机制(安全统一通知规范): -/// - Authorization header: `LKLAPI-SHA256withRSA timestamp="...",nonce_str="...",signature="..."` -/// - 验签原文: `timestamp + "\n" + nonce_str + "\n" + body + "\n"` -/// - 公钥为 X509 证书(PEM), 与响应验签共用 -/// -/// 主应用直接验签(签名算法简单, 无需转发子应用)。 +/// 验签只需全局服务商公钥(从 LakalaIsvKeyConfig 读取), 凭 out_trade_no 反查 PayTrade。 +/// 主应用零加密代码, 验签/解析集中在子应用 dax-pay-channel-two。 @Slf4j @Service @RequiredArgsConstructor @@ -42,11 +35,8 @@ public class LakalaPayCallbackService { private static final String NOTIFY_SUCCESS = "success"; private static final String NOTIFY_FAIL = "fail"; - /// 拉卡拉属国内通道, 回调时间字段(trade_time)为东八区本地时间字面量(yyyyMMddHHmmss), - /// 显式钉死 +08:00 偏移, 避免 OffsetDateTime.parse 因缺 OFFSET_SECONDS 解析失败 - private static final DateTimeFormatter LKL_TIME_FORMATTER = - DateTimeFormatter.ofPattern("yyyyMMddHHmmss").withZone(ZoneOffset.ofHours(8)); + private final LakalaChannelClient lakalaChannelClient; private final LakalaIsvKeyConfigManager lakalaIsvKeyConfigManager; private final PayCallbackService payCallbackService; @@ -56,7 +46,7 @@ public class LakalaPayCallbackService { String body = JakartaServletUtil.getBody(request); Map headerMap = JakartaServletUtil.getHeaderMap(request); - // 2. 获取全局服务商公钥(只读查询, 不创建记录) + // 2. 获取全局服务商公钥(只读查询) LakalaIsvKeyConfig keyConfig = lakalaIsvKeyConfigManager.findByProduct(ProductEnum.LAKALA_PAY.getCode()) .orElse(null); if (keyConfig == null || keyConfig.getPublicKey() == null) { @@ -64,20 +54,24 @@ public class LakalaPayCallbackService { return NOTIFY_FAIL; } - // 3. 验签 - if (!verifyCallbackSign(headerMap, body, keyConfig.getPublicKey())) { + // 3. 转发子应用验签解析 + LakalaCallbackParseResp resp = parse(body, headerMap, keyConfig.getPublicKey(), false); + if (resp == null || !Boolean.TRUE.equals(resp.getSuccess())) { log.error("拉卡拉支付回调验签失败"); return NOTIFY_FAIL; } - // 4. 解析回调数据并构建 CallbackData - CallbackData callbackData = parseCallback(body); - if (callbackData == null) { - log.error("拉卡拉支付回调解析失败: body={}", body); - return NOTIFY_FAIL; + // 4. 构建 CallbackData 交框架更新订单状态 + CallbackData callbackData = new CallbackData(); + callbackData.setTradeNo(resp.getOutTradeNo()); + callbackData.setOutTradeNo(resp.getTradeNo()); + if (Objects.equals(resp.getTradeStatus(), "SUCCESS")) { + callbackData.setTradeStatus(CallbackStatusEnum.SUCCESS.getCode()); + } else { + callbackData.setCallbackErrorMsg("拉卡拉回调状态非成功: " + resp.getTradeStatus()); } + callbackData.setFinishTime(resp.getFinishTime()); try { - // 5. 交由框架更新订单状态 payCallbackService.payCallback(callbackData); } catch (Exception e) { log.error("拉卡拉支付回调业务处理失败: tradeNo={}", callbackData.getTradeNo(), e); @@ -86,104 +80,27 @@ public class LakalaPayCallbackService { return NOTIFY_SUCCESS; } - /// 验证回调签名(从 Authorization header 解析 timestamp/nonce_str/signature) - @SuppressWarnings("unchecked") - private boolean verifyCallbackSign(Map headerMap, String body, String publicKey) { - // Authorization 可能是任意大小写 header 名 - String authorization = headerMap.getOrDefault("Authorization", - headerMap.getOrDefault("authorization", "")); - if (authorization.isEmpty()) { - return false; - } - // 解析 authorization: LKLAPI-SHA256withRSA timestamp="xxx",nonce_str="xxx",signature="xxx" - Map authMap = parseAuthorization(authorization); - String timestamp = authMap.get("timestamp"); - String nonceStr = authMap.get("nonce_str"); - String signature = authMap.get("signature"); - if (signature == null || timestamp == null || nonceStr == null) { - return false; - } - // 验签原文: timestamp\n nonce_str\n body\n - String source = timestamp + "\n" + nonceStr + "\n" + body + "\n"; - try { - X509Certificate cert = loadCertificate(publicKey); - Signature sign = Signature.getInstance("SHA256withRSA"); - sign.initVerify(cert); - sign.update(source.getBytes(StandardCharsets.UTF_8)); - return sign.verify(Base64.getDecoder().decode(signature)); - } catch (Exception e) { - log.error("拉卡拉回调验签异常", e); - return false; - } - } - - /// 解析 Authorization header 为 key-value Map - private Map parseAuthorization(String authorization) { - // 去掉方案前缀 LKLAPI-SHA256withRSA - int spaceIdx = authorization.indexOf(' '); - String kvPart = spaceIdx > 0 ? authorization.substring(spaceIdx + 1) : authorization; - Map map = new java.util.HashMap<>(); - for (String pair : kvPart.split(",")) { - int eqIdx = pair.indexOf('='); - if (eqIdx > 0) { - String key = pair.substring(0, eqIdx).trim(); - // 去掉值的引号 - String value = pair.substring(eqIdx + 1).trim(); - if (value.startsWith("\"") && value.endsWith("\"")) { - value = value.substring(1, value.length() - 1); - } - map.put(key, value); - } - } - return map; - } - - /// 加载 X509 公钥证书(PEM) - private X509Certificate loadCertificate(String publicKey) throws Exception { - String key = publicKey - .replace("-----BEGIN CERTIFICATE-----", "") - .replace("-----END CERTIFICATE-----", "") - .replaceAll("\\s+", ""); - byte[] decoded = Base64.getDecoder().decode(key); - CertificateFactory cf = CertificateFactory.getInstance("X509"); - return (X509Certificate) cf.generateCertificate(new java.io.ByteArrayInputStream(decoded)); - } - - /// 解析拉卡拉回调 JSON 为 CallbackData - private CallbackData parseCallback(String body) { - try { - cn.hutool.json.JSONObject json = cn.hutool.json.JSONUtil.parseObj(body); - // 拉卡拉回调外层为 resp_data 包裹(与响应一致), 兼容直接业务数据 - cn.hutool.json.JSONObject data = json.getJSONObject("resp_data"); - if (data == null) { - data = json; - } - CallbackData callbackData = new CallbackData(); - // out_trade_no = 下单时传入的平台 tradeNo - callbackData.setTradeNo(data.getStr("out_trade_no")); - // trade_no = 拉卡拉交易号 - callbackData.setOutTradeNo(data.getStr("trade_no")); - // 交易状态映射 - String tradeState = data.getStr("trade_state"); - if (Objects.equals(tradeState, "SUCCESS")) { - callbackData.setTradeStatus(CallbackStatusEnum.SUCCESS.getCode()); - } else { - callbackData.setTradeStatus(tradeState); - callbackData.setCallbackErrorMsg("拉卡拉回调状态非成功: " + tradeState); - } - // 完成时间(yyyyMMddHHmmss → OffsetDateTime) - String tradeTime = data.getStr("trade_time"); - if (tradeTime != null && !tradeTime.isBlank()) { - try { - callbackData.setFinishTime(OffsetDateTime.parse(tradeTime, LKL_TIME_FORMATTER)); - } catch (Exception e) { - log.warn("拉卡拉回调时间解析失败: tradeTime={}", tradeTime); - } - } - return callbackData; - } catch (Exception e) { - log.error("拉卡拉回调 JSON 解析失败", e); + /// 转发子应用验签解析 + LakalaCallbackParseResp parse(String body, Map headerMap, String publicKey, boolean refund) { + LakalaSdkCredential credential = new LakalaSdkCredential(); + credential.setPublicKey(publicKey); + LakalaCallbackParseReq req = new LakalaCallbackParseReq(); + req.setCredential(credential); + req.setBody(body); + req.setHeaders(headerMap); + var result = refund + ? lakalaChannelClient.parseRefundCallback(req) + : lakalaChannelClient.parsePayCallback(req); + if (result.getCode() != 0) { + log.error("拉卡拉回调: 子应用解析失败: {}", result.getMsg()); return null; } + return result.getData(); + } + + /// 提取 header(大小写兼容) + static String getHeader(Map headerMap, String name) { + String value = headerMap.get(name); + return value != null ? value : headerMap.get(StrUtil.isBlank(name) ? name : name.toLowerCase()); } } diff --git a/daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/service/callback/LakalaRefundCallbackService.java b/daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/service/callback/LakalaRefundCallbackService.java new file mode 100644 index 000000000..8df8b4cf1 --- /dev/null +++ b/daxpay-channel/daxpay-channel-lakala/src/main/java/cn/daxpay/open/channel/lakala/service/callback/LakalaRefundCallbackService.java @@ -0,0 +1,66 @@ +package cn.daxpay.open.channel.lakala.service.callback; + +import cn.daxpay.open.channel.lakala.client.resp.LakalaCallbackParseResp; +import cn.daxpay.open.channel.lakala.dao.isv.LakalaIsvKeyConfigManager; +import cn.daxpay.open.channel.lakala.entity.isv.LakalaIsvKeyConfig; +import cn.daxpay.open.payment.common.callback.RefundCallbackData; +import cn.daxpay.open.payment.core.trade.service.RefundCallbackService; +import cn.daxpay.open.platform.core.enums.pay.channel.ProductEnum; +import cn.daxpay.open.platform.core.enums.pay.notice.CallbackStatusEnum; +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.util.Map; +import java.util.Objects; + +/// # 拉卡拉退款回调处理服务 +/// +/// 拉卡拉退款异步通知 → 主应用接收 → 转发子应用验签与解析 → +/// 构建 [RefundCallbackData] 交由 [RefundCallbackService] 更新退款单状态。 +@Slf4j +@Service +@RequiredArgsConstructor +public class LakalaRefundCallbackService { + + private static final String NOTIFY_SUCCESS = "success"; + private static final String NOTIFY_FAIL = "fail"; + + private final LakalaPayCallbackService lakalaPayCallbackService; + private final LakalaIsvKeyConfigManager lakalaIsvKeyConfigManager; + private final RefundCallbackService refundCallbackService; + + /// 退款回调处理 + public String refundHandle(HttpServletRequest request) { + String body = JakartaServletUtil.getBody(request); + Map headerMap = JakartaServletUtil.getHeaderMap(request); + + LakalaIsvKeyConfig keyConfig = lakalaIsvKeyConfigManager.findByProduct(ProductEnum.LAKALA_PAY.getCode()) + .orElse(null); + if (keyConfig == null || keyConfig.getPublicKey() == null) { + log.error("拉卡拉退款回调: 服务商密钥未配置, 无法验签"); + return NOTIFY_FAIL; + } + + LakalaCallbackParseResp resp = lakalaPayCallbackService.parse(body, headerMap, keyConfig.getPublicKey(), true); + if (resp == null || !Boolean.TRUE.equals(resp.getSuccess())) { + log.error("拉卡拉退款回调验签失败"); + return NOTIFY_FAIL; + } + + RefundCallbackData callbackData = new RefundCallbackData(); + // out_refund_no = 平台退款号 + callbackData.setRefundNo(resp.getOutTradeNo()); + callbackData.setOutRefundNo(resp.getOutRefundNo()); + if (Objects.equals(resp.getTradeStatus(), "SUCCESS")) { + callbackData.setTradeStatus(CallbackStatusEnum.SUCCESS.getCode()); + } else { + callbackData.setTradeErrorMsg("拉卡拉退款状态非成功: " + resp.getTradeStatus()); + } + callbackData.setFinishTime(resp.getFinishTime()); + refundCallbackService.refundCallback(callbackData); + return NOTIFY_SUCCESS; + } +} diff --git a/daxpay-channel/daxpay-channel-leshua/src/main/java/cn/daxpay/open/channel/leshua/service/callback/LeshuaRefundCallbackService.java b/daxpay-channel/daxpay-channel-leshua/src/main/java/cn/daxpay/open/channel/leshua/service/callback/LeshuaRefundCallbackService.java index 9e84b557e..9cbb48642 100644 --- a/daxpay-channel/daxpay-channel-leshua/src/main/java/cn/daxpay/open/channel/leshua/service/callback/LeshuaRefundCallbackService.java +++ b/daxpay-channel/daxpay-channel-leshua/src/main/java/cn/daxpay/open/channel/leshua/service/callback/LeshuaRefundCallbackService.java @@ -7,7 +7,10 @@ import cn.daxpay.open.channel.leshua.client.resp.LeshuaCallbackParseResp; import cn.daxpay.open.channel.leshua.code.LeshuaCode; import cn.daxpay.open.channel.leshua.dao.isv.LeshuaIsvKeyConfigManager; import cn.daxpay.open.channel.leshua.entity.isv.LeshuaIsvKeyConfig; +import cn.daxpay.open.payment.common.callback.RefundCallbackData; import cn.daxpay.open.payment.common.result.DaxResult; +import cn.daxpay.open.payment.core.trade.service.RefundCallbackService; +import cn.daxpay.open.platform.core.enums.pay.notice.CallbackStatusEnum; import cn.daxpay.open.platform.core.enums.pay.channel.ProductEnum; import cn.hutool.core.util.StrUtil; import cn.hutool.extra.servlet.JakartaServletUtil; @@ -33,6 +36,7 @@ public class LeshuaRefundCallbackService { private final LeshuaChannelClient leshuaChannelClient; private final LeshuaIsvKeyConfigManager leshuaIsvKeyConfigManager; + private final RefundCallbackService refundCallbackService; /// 退款回调处理 public String refundHandle(HttpServletRequest request) { @@ -65,14 +69,17 @@ public class LeshuaRefundCallbackService { } LeshuaCallbackParseResp resp = result.getData(); - // 4. 记录退款回调结果, TODO 接入退款单状态更新框架 - log.info("乐刷退款回调: outRefundNo={}, leshuaOrderId={}, refundStatus={}, amount={}, finishTime={}", - resp.getOutTradeNo(), resp.getLeshuaOrderId(), resp.getTradeStatus(), - resp.getAmount(), resp.getFinishTime()); + // 4. 构建退款回调数据, 交框架更新退款单状态 + RefundCallbackData callbackData = new RefundCallbackData(); + callbackData.setRefundNo(resp.getOutTradeNo()); + callbackData.setOutRefundNo(resp.getLeshuaOrderId()); if (Objects.equals(resp.getTradeStatus(), LeshuaCode.REFUND_STATUS_SUCCESS)) { - // TODO 退款成功, 更新退款单状态(待接入退款回调框架) - log.info("乐刷退款成功: outRefundNo={}", resp.getOutTradeNo()); + callbackData.setTradeStatus(CallbackStatusEnum.SUCCESS.getCode()); + } else { + callbackData.setTradeErrorMsg("乐刷退款状态非成功: " + resp.getTradeStatus()); } + callbackData.setFinishTime(resp.getFinishTime()); + refundCallbackService.refundCallback(callbackData); return NOTIFY_SUCCESS; } } diff --git a/daxpay-channel/daxpay-channel-ums/src/main/java/cn/daxpay/open/channel/ums/service/callback/UmsRefundCallbackService.java b/daxpay-channel/daxpay-channel-ums/src/main/java/cn/daxpay/open/channel/ums/service/callback/UmsRefundCallbackService.java index 3ff407f87..1f519a56d 100644 --- a/daxpay-channel/daxpay-channel-ums/src/main/java/cn/daxpay/open/channel/ums/service/callback/UmsRefundCallbackService.java +++ b/daxpay-channel/daxpay-channel-ums/src/main/java/cn/daxpay/open/channel/ums/service/callback/UmsRefundCallbackService.java @@ -6,7 +6,10 @@ import cn.daxpay.open.channel.ums.client.req.UmsCallbackParseReq; import cn.daxpay.open.channel.ums.client.resp.UmsCallbackParseResp; import cn.daxpay.open.channel.ums.code.UmsCode; import cn.daxpay.open.channel.ums.service.direct.UmsDirectConfigAssembler; +import cn.daxpay.open.payment.common.callback.RefundCallbackData; import cn.daxpay.open.payment.common.result.DaxResult; +import cn.daxpay.open.payment.core.trade.service.RefundCallbackService; +import cn.daxpay.open.platform.core.enums.pay.notice.CallbackStatusEnum; import cn.hutool.core.util.StrUtil; import cn.hutool.extra.servlet.JakartaServletUtil; import cn.hutool.json.JSONObject; @@ -33,6 +36,7 @@ public class UmsRefundCallbackService { private final UmsChannelClient umsChannelClient; private final UmsDirectConfigAssembler configAssembler; + private final RefundCallbackService refundCallbackService; /// 退款回调处理 public String refundHandle(String mchNo, String channelMchNo, HttpServletRequest request) { @@ -52,14 +56,16 @@ public class UmsRefundCallbackService { return UmsCode.NOTIFY_FAIL; } - // 4. 记录退款回调结果, TODO 接入退款单状态更新框架 + // 4. 构建退款回调数据, 交框架更新退款单状态 UmsCallbackParseResp resp = result.getData(); - log.info("银联商务退款回调: outRefundNo={}, tradeStatus={}, amount={}", - resp.getOutRefundNo(), resp.getTradeStatus(), resp.getAmount()); + RefundCallbackData callbackData = new RefundCallbackData(); + callbackData.setRefundNo(resp.getOutRefundNo()); if (Objects.equals(UmsCode.TRADE_STATUS_SUCCESS, resp.getTradeStatus())) { - // TODO 退款成功, 更新退款单状态(待接入退款回调框架) - log.info("银联商务退款成功: outRefundNo={}", resp.getOutRefundNo()); + callbackData.setTradeStatus(CallbackStatusEnum.SUCCESS.getCode()); + } else { + callbackData.setTradeErrorMsg("银联商务退款状态非成功: " + resp.getTradeStatus()); } + refundCallbackService.refundCallback(callbackData); return UmsCode.NOTIFY_SUCCESS; } diff --git a/daxpay-channel/daxpay-channel-vbill/src/main/java/cn/daxpay/open/channel/vbill/client/VbillChannelClient.java b/daxpay-channel/daxpay-channel-vbill/src/main/java/cn/daxpay/open/channel/vbill/client/VbillChannelClient.java index 46e6ebbc7..34f4e9d7d 100644 --- a/daxpay-channel/daxpay-channel-vbill/src/main/java/cn/daxpay/open/channel/vbill/client/VbillChannelClient.java +++ b/daxpay-channel/daxpay-channel-vbill/src/main/java/cn/daxpay/open/channel/vbill/client/VbillChannelClient.java @@ -1,10 +1,12 @@ package cn.daxpay.open.channel.vbill.client; +import cn.daxpay.open.channel.vbill.client.req.VbillCallbackParseReq; import cn.daxpay.open.channel.vbill.client.req.VbillCloseReq; import cn.daxpay.open.channel.vbill.client.req.VbillPayReq; import cn.daxpay.open.channel.vbill.client.req.VbillRefundReq; import cn.daxpay.open.channel.vbill.client.req.VbillRefundSyncReq; import cn.daxpay.open.channel.vbill.client.req.VbillSyncReq; +import cn.daxpay.open.channel.vbill.client.resp.VbillCallbackParseResp; import cn.daxpay.open.channel.vbill.client.resp.VbillCloseResp; import cn.daxpay.open.channel.vbill.client.resp.VbillPayResp; import cn.daxpay.open.channel.vbill.client.resp.VbillRefundResp; @@ -42,4 +44,12 @@ public interface VbillChannelClient { /// 退款同步(查询退款状态) @PostExchange("/channel/vbill/refund-sync") DaxResult refundSync(@RequestBody VbillRefundSyncReq req); + + /// 支付回调验签解析(转发子应用) + @PostExchange("/channel/vbill/callback/parse-pay") + DaxResult parsePayCallback(@RequestBody VbillCallbackParseReq req); + + /// 退款回调验签解析(转发子应用) + @PostExchange("/channel/vbill/callback/parse-refund") + DaxResult parseRefundCallback(@RequestBody VbillCallbackParseReq req); } diff --git a/daxpay-channel/daxpay-channel-vbill/src/main/java/cn/daxpay/open/channel/vbill/client/req/VbillCallbackParseReq.java b/daxpay-channel/daxpay-channel-vbill/src/main/java/cn/daxpay/open/channel/vbill/client/req/VbillCallbackParseReq.java new file mode 100644 index 000000000..86bdc5d08 --- /dev/null +++ b/daxpay-channel/daxpay-channel-vbill/src/main/java/cn/daxpay/open/channel/vbill/client/req/VbillCallbackParseReq.java @@ -0,0 +1,18 @@ +package cn.daxpay.open.channel.vbill.client.req; + +import cn.daxpay.open.channel.vbill.client.credential.VbillSdkCredential; +import lombok.Data; + +/// # 随行付回调验签解析请求(主应用 → 子应用) +/// +/// 与子应用 dax-pay-channel-two 的 `VbillCallbackParseReq` 镜像。 +/// 主应用组装凭证 + 原始 body 下发, 子应用用天阙公钥 SHA1withRSA 验签并解析。 +@Data +public class VbillCallbackParseReq { + + /// 通道调用凭证(用于获取天阙公钥验签) + private VbillSdkCredential credential; + + /// 回调原始 body(随行付异步通知 JSON) + private String body; +} diff --git a/daxpay-channel/daxpay-channel-vbill/src/main/java/cn/daxpay/open/channel/vbill/client/resp/VbillCallbackParseResp.java b/daxpay-channel/daxpay-channel-vbill/src/main/java/cn/daxpay/open/channel/vbill/client/resp/VbillCallbackParseResp.java new file mode 100644 index 000000000..8d4b3c608 --- /dev/null +++ b/daxpay-channel/daxpay-channel-vbill/src/main/java/cn/daxpay/open/channel/vbill/client/resp/VbillCallbackParseResp.java @@ -0,0 +1,36 @@ +package cn.daxpay.open.channel.vbill.client.resp; + +import lombok.Data; +import lombok.experimental.Accessors; + +import java.time.OffsetDateTime; + +/// # 随行付回调验签解析响应(子应用 → 主应用) +/// +/// 与子应用 dax-pay-channel-two 的 `VbillCallbackParseResp` 镜像。 +/// 兼容支付与退款两种回调: 通过 tradeType 区分。 +@Data +@Accessors(chain = true) +public class VbillCallbackParseResp { + + /// 是否验签通过 + private Boolean success; + + /// 回调类型(PAY 支付回调 / REFUND 退款回调) + private String tradeType; + + /// 商户订单号(支付回调: ordNo = 平台支付交易号; 退款回调: 退款单号) + private String outTradeNo; + + /// 随行付网关订单号(支付回调 uuid) + private String tradeNo; + + /// 通道退款流水号(退款回调) + private String outRefundNo; + + /// 交易状态(抽象态 SUCCESS / FAIL) + private String tradeStatus; + + /// 完成时间(东八区 OffsetDateTime) + private OffsetDateTime finishTime; +} diff --git a/daxpay-channel/daxpay-channel-vbill/src/main/java/cn/daxpay/open/channel/vbill/controller/callback/VbillCallbackController.java b/daxpay-channel/daxpay-channel-vbill/src/main/java/cn/daxpay/open/channel/vbill/controller/callback/VbillCallbackController.java index 4659e9493..7b8cfa157 100644 --- a/daxpay-channel/daxpay-channel-vbill/src/main/java/cn/daxpay/open/channel/vbill/controller/callback/VbillCallbackController.java +++ b/daxpay-channel/daxpay-channel-vbill/src/main/java/cn/daxpay/open/channel/vbill/controller/callback/VbillCallbackController.java @@ -1,6 +1,7 @@ package cn.daxpay.open.channel.vbill.controller.callback; import cn.daxpay.open.channel.vbill.service.callback.VbillPayCallbackService; +import cn.daxpay.open.channel.vbill.service.callback.VbillRefundCallbackService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.servlet.http.HttpServletRequest; @@ -24,6 +25,7 @@ import java.util.Map; public class VbillCallbackController { private final VbillPayCallbackService vbillPayCallbackService; + private final VbillRefundCallbackService vbillRefundCallbackService; /// 随行付支付回调 /// @@ -35,4 +37,13 @@ public class VbillCallbackController { HttpServletRequest request) { return vbillPayCallbackService.payHandle(request); } + + /// 随行付退款回调 + @Operation(summary = "随行付退款回调") + @PostMapping("/refund") + public Map refundNotify(@PathVariable("mchNo") String mchNo, + @PathVariable("appId") String appId, + HttpServletRequest request) { + return vbillRefundCallbackService.refundHandle(request); + } } diff --git a/daxpay-channel/daxpay-channel-vbill/src/main/java/cn/daxpay/open/channel/vbill/service/callback/VbillPayCallbackService.java b/daxpay-channel/daxpay-channel-vbill/src/main/java/cn/daxpay/open/channel/vbill/service/callback/VbillPayCallbackService.java index 436f9a83a..ef6b3ba21 100644 --- a/daxpay-channel/daxpay-channel-vbill/src/main/java/cn/daxpay/open/channel/vbill/service/callback/VbillPayCallbackService.java +++ b/daxpay-channel/daxpay-channel-vbill/src/main/java/cn/daxpay/open/channel/vbill/service/callback/VbillPayCallbackService.java @@ -1,46 +1,33 @@ package cn.daxpay.open.channel.vbill.service.callback; +import cn.daxpay.open.channel.vbill.client.VbillChannelClient; +import cn.daxpay.open.channel.vbill.client.credential.VbillSdkCredential; +import cn.daxpay.open.channel.vbill.client.req.VbillCallbackParseReq; +import cn.daxpay.open.channel.vbill.client.resp.VbillCallbackParseResp; import cn.daxpay.open.channel.vbill.dao.isv.VbillIsvKeyConfigManager; import cn.daxpay.open.channel.vbill.entity.isv.VbillIsvKeyConfig; import cn.daxpay.open.payment.common.callback.CallbackData; import cn.daxpay.open.payment.core.trade.service.PayCallbackService; import cn.daxpay.open.platform.core.enums.pay.channel.ProductEnum; import cn.daxpay.open.platform.core.enums.pay.notice.CallbackStatusEnum; -import cn.hutool.core.date.DatePattern; -import cn.hutool.core.map.MapUtil; import cn.hutool.core.util.StrUtil; import cn.hutool.extra.servlet.JakartaServletUtil; -import cn.hutool.json.JSONObject; -import cn.hutool.json.JSONUtil; import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; -import java.math.BigDecimal; -import java.nio.charset.StandardCharsets; -import java.security.KeyFactory; -import java.security.PublicKey; -import java.security.Signature; -import java.security.spec.X509EncodedKeySpec; -import java.time.LocalDateTime; -import java.time.OffsetDateTime; -import java.time.ZoneOffset; -import java.time.format.DateTimeFormatter; -import java.util.Base64; import java.util.HashMap; import java.util.Map; import java.util.Objects; -import java.util.TreeMap; /// # 随行付支付回调处理服务 /// -/// 随行付(天阙科技)异步通知 → 主应用接收 JSON body → 用全局服务商公钥 SHA1withRSA 验签 → +/// 随行付(天阙科技)异步通知 → 主应用接收 JSON body → 转发子应用用天阙公钥 SHA1withRSA 验签 → /// 构建 [CallbackData] 交由 [PayCallbackService] 更新订单状态。 /// -/// 随行付回调签名机制(与响应签名一致): -/// - 整个回调 JSON(去 sign 字段)按 key 字典序拼接 `k1=v1&k2=v2` -/// - 用天阙公钥 SHA1withRSA 验证 sign(Base64) +/// 验签只需全局服务商公钥(从 VbillIsvKeyConfig 读取), 凭 ordNo 反查 PayTrade。 +/// 主应用零加密代码, 验签/解析集中在子应用 dax-pay-channel-two。 /// /// 成功响应: 返回 `{"code":"success","msg":"成功"}` JSON; 验签/解析失败返回 code=fail。 @Slf4j @@ -49,22 +36,17 @@ import java.util.TreeMap; public class VbillPayCallbackService { /// 回调成功响应 code - private static final String RESP_CODE_SUCCESS = "success"; - private static final String RESP_CODE_FAIL = "fail"; - - /// 随行付属国内通道, 回调时间字段(payTime)为东八区本地时间字面量(yyyyMMddHHmmss), - /// 显式钉死 +08:00 偏移, 避免 OffsetDateTime.parse 因缺 OFFSET_SECONDS 解析失败 - private static final DateTimeFormatter PAY_TIME_FORMATTER = - DateTimeFormatter.ofPattern(DatePattern.PURE_DATETIME_PATTERN).withZone(ZoneOffset.ofHours(8)); + public static final String RESP_CODE_SUCCESS = "success"; + public static final String RESP_CODE_FAIL = "fail"; + private final VbillChannelClient vbillChannelClient; private final VbillIsvKeyConfigManager vbillIsvKeyConfigManager; private final PayCallbackService payCallbackService; /// 支付回调处理 public Map payHandle(HttpServletRequest request) { Map resp = new HashMap<>(4); - // 1. 读取回调原始 JSON - String body = readBody(request); + String body = JakartaServletUtil.getBody(request); if (StrUtil.isBlank(body)) { log.error("随行付支付回调: body 为空"); resp.put("code", RESP_CODE_FAIL); @@ -72,7 +54,7 @@ public class VbillPayCallbackService { return resp; } - // 2. 获取全局服务商公钥(只读查询, 不创建记录) + // 获取全局服务商公钥(只读查询) VbillIsvKeyConfig keyConfig = vbillIsvKeyConfigManager.findByProduct(ProductEnum.VBILL_PAY.getCode()) .orElse(null); if (keyConfig == null || keyConfig.getPublicKey() == null) { @@ -82,25 +64,27 @@ public class VbillPayCallbackService { return resp; } - // 3. 验签(整个回调 JSON 去 sign 后字典序签名) - if (!verifyCallbackSign(body, keyConfig.getPublicKey())) { + // 转发子应用验签解析 + VbillCallbackParseResp parseResp = parse(body, keyConfig.getPublicKey(), false); + if (parseResp == null || !Boolean.TRUE.equals(parseResp.getSuccess())) { log.error("随行付支付回调验签失败"); resp.put("code", RESP_CODE_FAIL); resp.put("msg", "验签失败"); return resp; } - // 4. 解析回调数据并构建 CallbackData - CallbackData callbackData = parseCallback(body); - if (callbackData == null) { - log.error("随行付支付回调解析失败: body={}", body); - resp.put("code", RESP_CODE_FAIL); - resp.put("msg", "解析失败"); - return resp; + // 构建 CallbackData 交框架更新订单状态 + CallbackData callbackData = new CallbackData(); + // ordNo = 下单时传入的平台 tradeNo + callbackData.setTradeNo(parseResp.getOutTradeNo()); + callbackData.setOutTradeNo(parseResp.getTradeNo()); + if (Objects.equals(parseResp.getTradeStatus(), "SUCCESS")) { + callbackData.setTradeStatus(CallbackStatusEnum.SUCCESS.getCode()); + } else { + callbackData.setCallbackErrorMsg("随行付回调状态非成功: " + parseResp.getTradeStatus()); } - + callbackData.setFinishTime(parseResp.getFinishTime()); try { - // 5. 交由框架更新订单状态 payCallbackService.payCallback(callbackData); resp.put("code", RESP_CODE_SUCCESS); resp.put("msg", "成功"); @@ -112,88 +96,20 @@ public class VbillPayCallbackService { return resp; } - /// 验证回调签名: 整个 JSON 去 sign, 字典序拼接, SHA1withRSA 验签 - private boolean verifyCallbackSign(String body, String publicKeyStr) { - try { - JSONObject json = JSONUtil.parseObj(body); - String sign = json.getStr("sign"); - if (StrUtil.isBlank(sign)) { - return false; - } - // 构建待验签字符串(去 sign, 其余按字典序拼接; 嵌套对象保留 JSON 字符串) - Map flatMap = new TreeMap<>(); - for (String key : json.keySet()) { - if ("sign".equals(key)) { - continue; - } - Object value = json.get(key); - if (value != null && !value.toString().isEmpty()) { - flatMap.put(key, value.toString()); - } - } - StringBuilder sb = new StringBuilder(); - boolean first = true; - for (Map.Entry entry : flatMap.entrySet()) { - if (!first) { - sb.append("&"); - } - sb.append(entry.getKey()).append("=").append(entry.getValue()); - first = false; - } - String signSource = sb.toString(); - - // SHA1withRSA 验签 - byte[] keyBytes = Base64.getDecoder().decode(publicKeyStr.replaceAll("[\\s*\t\n\r]", "")); - X509EncodedKeySpec keySpec = new X509EncodedKeySpec(keyBytes); - PublicKey publicKey = KeyFactory.getInstance("RSA").generatePublic(keySpec); - Signature signature = Signature.getInstance("SHA1withRSA"); - signature.initVerify(publicKey); - signature.update(signSource.getBytes(StandardCharsets.UTF_8)); - return signature.verify(Base64.getDecoder().decode(sign)); - } catch (Exception e) { - log.error("随行付回调验签异常", e); - return false; - } - } - - /// 解析随行付回调 JSON 为 CallbackData - private CallbackData parseCallback(String body) { - try { - JSONObject json = JSONUtil.parseObj(body); - CallbackData callbackData = new CallbackData(); - // ordNo = 下单时传入的平台 tradeNo - callbackData.setTradeNo(json.getStr("ordNo")); - // uuid = 随行付网关订单号 - callbackData.setOutTradeNo(json.getStr("uuid")); - // 支付状态(随行付支付回调恒为成功才会通知) - callbackData.setTradeStatus(CallbackStatusEnum.SUCCESS.getCode()); - // 完成时间(yyyyMMddHHmmss → OffsetDateTime) - String payTime = json.getStr("payTime"); - if (StrUtil.isNotBlank(payTime)) { - try { - callbackData.setFinishTime(OffsetDateTime.parse(payTime.trim(), PAY_TIME_FORMATTER)); - } catch (Exception e) { - log.warn("随行付回调时间解析失败: payTime={}", payTime); - } - } - return callbackData; - } catch (Exception e) { - log.error("随行付回调 JSON 解析失败", e); + /// 转发子应用验签解析 + VbillCallbackParseResp parse(String body, String publicKey, boolean refund) { + VbillSdkCredential credential = new VbillSdkCredential(); + credential.setPublicKey(publicKey); + VbillCallbackParseReq req = new VbillCallbackParseReq(); + req.setCredential(credential); + req.setBody(body); + var result = refund + ? vbillChannelClient.parseRefundCallback(req) + : vbillChannelClient.parsePayCallback(req); + if (result.getCode() != 0) { + log.error("随行付回调: 子应用解析失败: {}", result.getMsg()); return null; } - } - - /// 元(BigDecimal 字符串) → 分(Long) [备用, 当前 CallbackData 不含金额字段] - private Long yuanToFen(String yuan) { - try { - return new BigDecimal(yuan.trim()).multiply(new BigDecimal("100")).longValue(); - } catch (Exception e) { - return null; - } - } - - /// 读取 request body - private String readBody(HttpServletRequest request) { - return JakartaServletUtil.getBody(request); + return result.getData(); } } diff --git a/daxpay-channel/daxpay-channel-vbill/src/main/java/cn/daxpay/open/channel/vbill/service/callback/VbillRefundCallbackService.java b/daxpay-channel/daxpay-channel-vbill/src/main/java/cn/daxpay/open/channel/vbill/service/callback/VbillRefundCallbackService.java new file mode 100644 index 000000000..1cc14a81e --- /dev/null +++ b/daxpay-channel/daxpay-channel-vbill/src/main/java/cn/daxpay/open/channel/vbill/service/callback/VbillRefundCallbackService.java @@ -0,0 +1,75 @@ +package cn.daxpay.open.channel.vbill.service.callback; + +import cn.daxpay.open.channel.vbill.client.resp.VbillCallbackParseResp; +import cn.daxpay.open.channel.vbill.dao.isv.VbillIsvKeyConfigManager; +import cn.daxpay.open.channel.vbill.entity.isv.VbillIsvKeyConfig; +import cn.daxpay.open.payment.common.callback.RefundCallbackData; +import cn.daxpay.open.payment.core.trade.service.RefundCallbackService; +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; +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.util.HashMap; +import java.util.Map; +import java.util.Objects; + +/// # 随行付退款回调处理服务 +/// +/// 随行付退款异步通知 → 主应用接收 → 转发子应用验签与解析 → +/// 构建 [RefundCallbackData] 交由 [RefundCallbackService] 更新退款单状态。 +@Slf4j +@Service +@RequiredArgsConstructor +public class VbillRefundCallbackService { + + private final VbillPayCallbackService vbillPayCallbackService; + private final VbillIsvKeyConfigManager vbillIsvKeyConfigManager; + private final RefundCallbackService refundCallbackService; + + /// 退款回调处理 + public Map refundHandle(HttpServletRequest request) { + Map resp = new HashMap<>(4); + String body = JakartaServletUtil.getBody(request); + if (StrUtil.isBlank(body)) { + resp.put("code", VbillPayCallbackService.RESP_CODE_FAIL); + resp.put("msg", "body 为空"); + return resp; + } + VbillIsvKeyConfig keyConfig = vbillIsvKeyConfigManager.findByProduct(ProductEnum.VBILL_PAY.getCode()) + .orElse(null); + if (keyConfig == null || keyConfig.getPublicKey() == null) { + log.error("随行付退款回调: 服务商密钥未配置, 无法验签"); + resp.put("code", VbillPayCallbackService.RESP_CODE_FAIL); + resp.put("msg", "密钥未配置"); + return resp; + } + + VbillCallbackParseResp parseResp = vbillPayCallbackService.parse(body, keyConfig.getPublicKey(), true); + if (parseResp == null || !Boolean.TRUE.equals(parseResp.getSuccess())) { + log.error("随行付退款回调验签失败"); + resp.put("code", VbillPayCallbackService.RESP_CODE_FAIL); + resp.put("msg", "验签失败"); + return resp; + } + + RefundCallbackData callbackData = new RefundCallbackData(); + // ordNo 作为退款单号反查退款单 + callbackData.setRefundNo(parseResp.getOutTradeNo()); + callbackData.setOutRefundNo(parseResp.getOutRefundNo()); + if (Objects.equals(parseResp.getTradeStatus(), "SUCCESS")) { + callbackData.setTradeStatus(CallbackStatusEnum.SUCCESS.getCode()); + } else { + callbackData.setTradeErrorMsg("随行付退款状态非成功: " + parseResp.getTradeStatus()); + } + callbackData.setFinishTime(parseResp.getFinishTime()); + refundCallbackService.refundCallback(callbackData); + resp.put("code", VbillPayCallbackService.RESP_CODE_SUCCESS); + resp.put("msg", "成功"); + return resp; + } +} diff --git a/daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/service/callback/WechatRefundCallbackService.java b/daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/service/callback/WechatRefundCallbackService.java index e3fe64480..3a8afed4c 100644 --- a/daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/service/callback/WechatRefundCallbackService.java +++ b/daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/service/callback/WechatRefundCallbackService.java @@ -6,7 +6,10 @@ import cn.daxpay.open.channel.wechat.client.req.WechatCallbackParseReq; import cn.daxpay.open.channel.wechat.client.resp.WechatCallbackParseResp; import cn.daxpay.open.channel.wechat.code.WechatCode; import cn.daxpay.open.channel.wechat.service.direct.WechatDirectConfigAssembler; +import cn.daxpay.open.payment.common.callback.RefundCallbackData; import cn.daxpay.open.payment.common.result.DaxResult; +import cn.daxpay.open.payment.core.trade.service.RefundCallbackService; +import cn.daxpay.open.platform.core.enums.pay.notice.CallbackStatusEnum; import cn.hutool.extra.servlet.JakartaServletUtil; import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; @@ -28,6 +31,7 @@ public class WechatRefundCallbackService { private final WechatChannelClient wechatChannelClient; private final WechatDirectConfigAssembler wechatDirectConfigAssembler; + private final RefundCallbackService refundCallbackService; /// 退款回调处理 public String refundHandle(String mchNo, String channelMchNo, HttpServletRequest request) { @@ -53,14 +57,17 @@ public class WechatRefundCallbackService { return WechatCode.NOTIFY_FAIL; } - // 4. 记录退款回调结果, TODO 接入退款单状态更新框架 + // 4. 构建退款回调数据, 交框架更新退款单状态 WechatCallbackParseResp resp = result.getData(); - log.info("微信退款回调: outRefundNo={}, refundId={}, refundStatus={}, amount={}", - resp.getOutRefundNo(), resp.getRefundId(), resp.getRefundStatus(), resp.getAmount()); + RefundCallbackData callbackData = new RefundCallbackData(); + callbackData.setRefundNo(resp.getOutRefundNo()); + callbackData.setOutRefundNo(resp.getRefundId()); if (Objects.equals(WechatCode.REFUND_STATUS_SUCCESS, resp.getRefundStatus())) { - // TODO 退款成功, 更新退款单状态(待接入退款回调框架) - log.info("微信退款成功: outRefundNo={}", resp.getOutRefundNo()); + callbackData.setTradeStatus(CallbackStatusEnum.SUCCESS.getCode()); + } else { + callbackData.setTradeErrorMsg("微信退款状态非成功: " + resp.getRefundStatus()); } + refundCallbackService.refundCallback(callbackData); return WechatCode.NOTIFY_SUCCESS; } diff --git a/daxpay-channel/daxpay-channel-yeepay/src/main/java/cn/daxpay/open/channel/yeepay/service/callback/YeepayRefundCallbackService.java b/daxpay-channel/daxpay-channel-yeepay/src/main/java/cn/daxpay/open/channel/yeepay/service/callback/YeepayRefundCallbackService.java index 7bc8c1323..dfa55ee46 100644 --- a/daxpay-channel/daxpay-channel-yeepay/src/main/java/cn/daxpay/open/channel/yeepay/service/callback/YeepayRefundCallbackService.java +++ b/daxpay-channel/daxpay-channel-yeepay/src/main/java/cn/daxpay/open/channel/yeepay/service/callback/YeepayRefundCallbackService.java @@ -6,7 +6,10 @@ import cn.daxpay.open.channel.yeepay.client.req.YeepayCallbackParseReq; import cn.daxpay.open.channel.yeepay.client.resp.YeepayCallbackParseResp; import cn.daxpay.open.channel.yeepay.code.YeepayCode; import cn.daxpay.open.channel.yeepay.service.direct.YeepayDirectConfigAssembler; +import cn.daxpay.open.payment.common.callback.RefundCallbackData; import cn.daxpay.open.payment.common.result.DaxResult; +import cn.daxpay.open.payment.core.trade.service.RefundCallbackService; +import cn.daxpay.open.platform.core.enums.pay.notice.CallbackStatusEnum; import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -27,6 +30,7 @@ public class YeepayRefundCallbackService { private final YeepayChannelClient yeepayChannelClient; private final YeepayDirectConfigAssembler configAssembler; + private final RefundCallbackService refundCallbackService; /// 退款回调处理 public String refundHandle(String mchNo, String channelMchNo, HttpServletRequest request) { @@ -48,14 +52,16 @@ public class YeepayRefundCallbackService { return YeepayCode.NOTIFY_FAIL; } - // 4. 记录退款回调结果, TODO 接入退款单状态更新框架 + // 4. 构建退款回调数据, 交框架更新退款单状态 YeepayCallbackParseResp resp = result.getData(); - log.info("易宝退款回调: outRefundNo={}, tradeStatus={}, amount={}", - resp.getOutRefundNo(), resp.getTradeStatus(), resp.getAmount()); + RefundCallbackData callbackData = new RefundCallbackData(); + callbackData.setRefundNo(resp.getOutRefundNo()); if (Objects.equals(YeepayCode.TRADE_STATUS_SUCCESS, resp.getTradeStatus())) { - // TODO 退款成功, 更新退款单状态(待接入退款回调框架) - log.info("易宝退款成功: outRefundNo={}", resp.getOutRefundNo()); + callbackData.setTradeStatus(CallbackStatusEnum.SUCCESS.getCode()); + } else { + callbackData.setTradeErrorMsg("易宝退款状态非成功: " + resp.getTradeStatus()); } + refundCallbackService.refundCallback(callbackData); return YeepayCode.NOTIFY_SUCCESS; } } diff --git a/daxpay-payment/daxpay-payment-common/src/main/java/cn/daxpay/open/payment/common/callback/RefundCallbackData.java b/daxpay-payment/daxpay-payment-common/src/main/java/cn/daxpay/open/payment/common/callback/RefundCallbackData.java new file mode 100644 index 000000000..c9152b897 --- /dev/null +++ b/daxpay-payment/daxpay-payment-common/src/main/java/cn/daxpay/open/payment/common/callback/RefundCallbackData.java @@ -0,0 +1,45 @@ +package cn.daxpay.open.payment.common.callback; + +import cn.daxpay.open.platform.core.enums.pay.notice.CallbackStatusEnum; +import lombok.Data; +import lombok.experimental.Accessors; + +import java.time.OffsetDateTime; +import java.util.HashMap; +import java.util.Map; + +/// # 退款回调数据(函数传参,非线程上下文) +/// +/// 通道退款回调解析后的结果数据,由退款回调入口创建并显式传递给 [RefundCallbackService]。 +/// 与 [CallbackData] 对称:支付回调用 tradeNo 反查支付单,退款回调用 refundNo 反查退款单。 +/// 不挂 ThreadLocal,避免线程池复用时的脏数据残留。 +@Data +@Accessors(chain = true) +public class RefundCallbackData { + + /// 回调数据内容(原始解析结果,供日志/溯源) + private Map callbackData = new HashMap<>(); + + /// 平台退款号(对应通道 out_request_no,反查退款单的主键) + private String refundNo; + + /// 通道退款流水号(三方通道返回的退款流水号) + private String outRefundNo; + + /// 退款交易状态(抽象态, success 表示退款成功) + /// @see CallbackStatusEnum + private String tradeStatus; + + /// 退款交易错误信息 + private String tradeErrorMsg; + + /// 退款完成时间(UTC) + private OffsetDateTime finishTime; + + /// 回调信息错误信息 + private String callbackErrorMsg; + + /// 回调处理状态 + /// @see CallbackStatusEnum + private CallbackStatusEnum callbackStatus = CallbackStatusEnum.SUCCESS; +} diff --git a/daxpay-payment/daxpay-payment-common/src/main/java/cn/daxpay/open/payment/core/trade/dao/PayRefundOrderManager.java b/daxpay-payment/daxpay-payment-common/src/main/java/cn/daxpay/open/payment/core/trade/dao/PayRefundOrderManager.java index 5cd8bd80f..02db8fec1 100644 --- a/daxpay-payment/daxpay-payment-common/src/main/java/cn/daxpay/open/payment/core/trade/dao/PayRefundOrderManager.java +++ b/daxpay-payment/daxpay-payment-common/src/main/java/cn/daxpay/open/payment/core/trade/dao/PayRefundOrderManager.java @@ -22,6 +22,11 @@ public class PayRefundOrderManager extends BaseManager findByOutRefundNo(String outRefundNo) { + return findByField(PayRefundOrder::getOutRefundNo, outRefundNo); + } + /// 根据原支付订单号查询退款订单数量(用于判断是否已退款) public long countByOrderNo(String orderNo) { return lambdaQuery() diff --git a/daxpay-payment/daxpay-payment-common/src/main/java/cn/daxpay/open/payment/core/trade/service/RefundCallbackService.java b/daxpay-payment/daxpay-payment-common/src/main/java/cn/daxpay/open/payment/core/trade/service/RefundCallbackService.java new file mode 100644 index 000000000..e3ebca9ca --- /dev/null +++ b/daxpay-payment/daxpay-payment-common/src/main/java/cn/daxpay/open/payment/core/trade/service/RefundCallbackService.java @@ -0,0 +1,109 @@ +package cn.daxpay.open.payment.core.trade.service; + +import cn.daxpay.open.payment.common.callback.RefundCallbackData; +import cn.daxpay.open.payment.common.enums.RefundOrderStatusEnum; +import cn.daxpay.open.payment.core.trade.dao.PayRefundOrderManager; +import cn.daxpay.open.payment.core.trade.dao.PayTradeManager; +import cn.daxpay.open.payment.core.trade.entity.PayRefundOrder; +import cn.daxpay.open.payment.core.trade.entity.PayTrade; +import cn.daxpay.open.platform.core.enums.pay.notice.CallbackStatusEnum; +import com.baomidou.lock.LockInfo; +import com.baomidou.lock.LockTemplate; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Objects; + +/// # 退款回调处理 +/// +/// 与 [PayCallbackService] 对称:支付回调用 tradeNo 反查支付单, +/// 退款回调用 refundNo 反查退款单, 完成退款单状态流转与可退余额扣减。 +/// +/// 回调数据通过函数参数显式传递([RefundCallbackData]),不依赖线程上下文。 +@Slf4j +@Service +@RequiredArgsConstructor +public class RefundCallbackService { + + private final PayRefundOrderManager payRefundOrderManager; + private final PayTradeManager payTradeManager; + private final LockTemplate lockTemplate; + + /// 退款统一回调处理 + @Transactional(rollbackFor = Exception.class) + public void refundCallback(RefundCallbackData callbackData) { + LockInfo lock = lockTemplate.lock("callback:refund:" + callbackData.getRefundNo(), 10000, 200); + if (Objects.isNull(lock)) { + callbackData.setCallbackStatus(CallbackStatusEnum.IGNORE) + .setCallbackErrorMsg("退款回调正在处理中,忽略本次回调请求"); + log.warn("退款号: {} 回调正在处理中,忽略本次回调请求", callbackData.getRefundNo()); + return; + } + try { + PayRefundOrder refundOrder = payRefundOrderManager.findByRefundNo(callbackData.getRefundNo()) + .orElse(null); + // 容错:部分通道仅回传其内部退款号, 用通道退款流水号反查 + if (Objects.isNull(refundOrder) && Objects.nonNull(callbackData.getOutRefundNo())) { + refundOrder = payRefundOrderManager.findByOutRefundNo(callbackData.getOutRefundNo()) + .orElse(null); + } + if (Objects.isNull(refundOrder)) { + callbackData.setCallbackStatus(CallbackStatusEnum.NOT_FOUND) + .setCallbackErrorMsg("退款订单不存在"); + log.warn("退款回调: 退款订单不存在 refundNo={} outRefundNo={}", + callbackData.getRefundNo(), callbackData.getOutRefundNo()); + return; + } + // 终态守卫: 已成功/失败/关闭的退款单不再重复处理 + String oldStatus = refundOrder.getStatus(); + if (Objects.equals(oldStatus, RefundOrderStatusEnum.SUCCESS.getCode()) + || Objects.equals(oldStatus, RefundOrderStatusEnum.FAIL.getCode()) + || Objects.equals(oldStatus, RefundOrderStatusEnum.CLOSE.getCode())) { + callbackData.setCallbackStatus(CallbackStatusEnum.IGNORE) + .setCallbackErrorMsg("退款单已处于终态,忽略回调"); + log.warn("退款回调: 退款单 {} 已处于终态 {},忽略", refundOrder.getRefundNo(), oldStatus); + return; + } + // 回填通道退款流水号 + if (Objects.nonNull(callbackData.getOutRefundNo())) { + refundOrder.setOutRefundNo(callbackData.getOutRefundNo()); + } + if (Objects.equals(CallbackStatusEnum.SUCCESS.getCode(), callbackData.getTradeStatus())) { + this.success(refundOrder, callbackData); + } else { + this.fail(refundOrder, callbackData); + } + } finally { + lockTemplate.releaseLock(lock); + } + } + + /// 退款成功: 状态置成功, 记完成时间, 扣减原支付订单可退余额 + private void success(PayRefundOrder refundOrder, RefundCallbackData callbackData) { + refundOrder.setStatus(RefundOrderStatusEnum.SUCCESS.getCode()); + if (Objects.nonNull(callbackData.getFinishTime())) { + refundOrder.setFinishTime(callbackData.getFinishTime()); + } + refundOrder.setErrorMsg(null); + payRefundOrderManager.updateById(refundOrder); + // 扣减原支付订单可退余额(与 PayRefundSyncService 同口径) + payTradeManager.findByTradeNo(refundOrder.getOrderNo()).ifPresent(trade -> { + long newBalance = (trade.getRefundableBalance() == null ? 0 : trade.getRefundableBalance()) + - refundOrder.getAmount(); + trade.setRefundableBalance(Math.max(newBalance, 0)); + payTradeManager.updateById(trade); + }); + } + + /// 退款失败: 状态置失败, 记错误信息 + private void fail(PayRefundOrder refundOrder, RefundCallbackData callbackData) { + refundOrder.setStatus(RefundOrderStatusEnum.FAIL.getCode()); + if (Objects.nonNull(callbackData.getFinishTime())) { + refundOrder.setFinishTime(callbackData.getFinishTime()); + } + refundOrder.setErrorMsg(callbackData.getTradeErrorMsg()); + payRefundOrderManager.updateById(refundOrder); + } +}