feat(callback): 补全支付/退款回调链路(退款回调框架 + 13通道主应用侧回调接收与子应用委派解析)

- 新增退款回调框架 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
This commit is contained in:
DaxPay Dev
2026-07-09 10:51:06 +08:00
parent e4293cd04b
commit 8ffef6ca09
39 changed files with 1243 additions and 521 deletions

View File

@@ -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<AdapayRefundSyncResp> refundSync(@RequestBody AdapayRefundSyncReq req);
/// 支付回调验签解析(转发子应用)
@PostExchange("/channel/adapay/callback/parse-pay")
DaxResult<AdapayCallbackParseResp> parsePayCallback(@RequestBody AdapayCallbackParseReq req);
/// 退款回调验签解析(转发子应用)
@PostExchange("/channel/adapay/callback/parse-refund")
DaxResult<AdapayCallbackParseResp> parseRefundCallback(@RequestBody AdapayCallbackParseReq req);
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<AlipayRefundSyncResp> refundSync(@RequestBody AlipayRefundSyncReq req);
/// 支付回调验签解析(转发子应用)
@PostExchange("/channel/alipay/callback/parse-pay")
DaxResult<AlipayCallbackParseResp> parsePayCallback(@RequestBody AlipayCallbackParseReq req);
/// 退款回调验签解析(转发子应用)
@PostExchange("/channel/alipay/callback/parse-refund")
DaxResult<AlipayCallbackParseResp> parseRefundCallback(@RequestBody AlipayCallbackParseReq req);
}

View File

@@ -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<String, String> params;
}

View File

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

View File

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

View File

@@ -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<String, String> 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<String, String> 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<String, String> 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<String, String> 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<String,String>(多值取首项)
private Map<String, String> extractFormParams(HttpServletRequest request) {
Map<String, String[]> paramMap = request.getParameterMap();
Map<String, String> result = new HashMap<>(paramMap.size());
for (Map.Entry<String, String[]> entry : paramMap.entrySet()) {
String[] values = entry.getValue();
if (values != null && values.length > 0) {
result.put(entry.getKey(), values[0]);
}
}
return result;
}
}

View File

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

View File

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

View File

@@ -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<FuyouRefundSyncResp> refundSync(@RequestBody FuyouRefundSyncReq req);
/// 支付回调验签解析(转发子应用)
@PostExchange("/channel/fuyou/callback/parse-pay")
DaxResult<FuyouCallbackParseResp> parsePayCallback(@RequestBody FuyouCallbackParseReq req);
/// 退款回调验签解析(转发子应用)
@PostExchange("/channel/fuyou/callback/parse-refund")
DaxResult<FuyouCallbackParseResp> parseRefundCallback(@RequestBody FuyouCallbackParseReq req);
}

View File

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

View File

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

View File

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

View File

@@ -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<String, String> 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<String, String> params, String sign, String publicKeyStr) {
try {
// 过滤 sign 与 reserved 开头字段
Map<String, String> filtered = new TreeMap<>();
for (Map.Entry<String, String> 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<String> 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<String, String> xmlToMap(String xml) {
Map<String, Object> raw = XmlUtil.xmlToMap(xml);
Map<String, String> result = new LinkedHashMap<>(raw.size());
for (Map.Entry<String, Object> entry : raw.entrySet()) {
result.put(entry.getKey(), entry.getValue() == null ? "" : entry.getValue().toString());
}
return result;
return result.getData();
}
}

View File

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

View File

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

View File

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

View File

@@ -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<LakalaRefundSyncResp> refundSync(@RequestBody LakalaRefundSyncReq req);
/// 支付回调验签解析(转发子应用)
@PostExchange("/channel/lakala/callback/parse-pay")
DaxResult<LakalaCallbackParseResp> parsePayCallback(@RequestBody LakalaCallbackParseReq req);
/// 退款回调验签解析(转发子应用)
@PostExchange("/channel/lakala/callback/parse-refund")
DaxResult<LakalaCallbackParseResp> parseRefundCallback(@RequestBody LakalaCallbackParseReq req);
}

View File

@@ -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<String, String> headers;
}

View File

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

View File

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

View File

@@ -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<String, String> 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<String, String> 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<String, String> 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<String, String> parseAuthorization(String authorization) {
// 去掉方案前缀 LKLAPI-SHA256withRSA
int spaceIdx = authorization.indexOf(' ');
String kvPart = spaceIdx > 0 ? authorization.substring(spaceIdx + 1) : authorization;
Map<String, String> 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<String, String> 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<String, String> headerMap, String name) {
String value = headerMap.get(name);
return value != null ? value : headerMap.get(StrUtil.isBlank(name) ? name : name.toLowerCase());
}
}

View File

@@ -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<String, String> 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;
}
}

View File

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

View File

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

View File

@@ -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<VbillRefundSyncResp> refundSync(@RequestBody VbillRefundSyncReq req);
/// 支付回调验签解析(转发子应用)
@PostExchange("/channel/vbill/callback/parse-pay")
DaxResult<VbillCallbackParseResp> parsePayCallback(@RequestBody VbillCallbackParseReq req);
/// 退款回调验签解析(转发子应用)
@PostExchange("/channel/vbill/callback/parse-refund")
DaxResult<VbillCallbackParseResp> parseRefundCallback(@RequestBody VbillCallbackParseReq req);
}

View File

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

View File

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

View File

@@ -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<String, String> refundNotify(@PathVariable("mchNo") String mchNo,
@PathVariable("appId") String appId,
HttpServletRequest request) {
return vbillRefundCallbackService.refundHandle(request);
}
}

View File

@@ -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<String, String> payHandle(HttpServletRequest request) {
Map<String, String> 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<String, String> 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<String, String> 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();
}
}

View File

@@ -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<String, String> refundHandle(HttpServletRequest request) {
Map<String, String> 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;
}
}

View File

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

View File

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

View File

@@ -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<String, ?> 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;
}

View File

@@ -22,6 +22,11 @@ public class PayRefundOrderManager extends BaseManager<PayRefundOrderMapper, Pay
return findByField(PayRefundOrder::getRefundNo, refundNo);
}
/// 根据通道退款流水号查询(回调容错:部分通道仅回传其内部退款号)
public Optional<PayRefundOrder> findByOutRefundNo(String outRefundNo) {
return findByField(PayRefundOrder::getOutRefundNo, outRefundNo);
}
/// 根据原支付订单号查询退款订单数量(用于判断是否已退款)
public long countByOrderNo(String orderNo) {
return lambdaQuery()

View File

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