feat(notice): 新增商户出站通知管线(任务/重试/协议SPI)

统一订单级与应用级 webhook,接入支付/退款终态与易支付协议发送,并提供管理端任务查询与手动重发。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
DaxPay Dev
2026-07-21 13:47:38 +08:00
parent 00c74d5492
commit 754a74721f
96 changed files with 2089 additions and 18 deletions

View File

@@ -62,7 +62,7 @@ public class EasyPayOrder extends MchBaseEntity implements ToResult<EasyPayOrder
/// 已退款金额(元)
private BigDecimal refundMoney;
/// 异步通知地址(本期仅落库不发送
/// 异步通知地址(由 EasyPayNoticeSender 出站推送,不写内核订单 notifyUrl
private String notifyUrl;
/// 同步跳转

View File

@@ -0,0 +1,130 @@
package cn.daxpay.open.plugin.easypay.notice;
import cn.daxpay.open.payment.trade.notice.entity.MchNoticeTask;
import cn.daxpay.open.payment.trade.notice.protocol.NoticeProtocolSender;
import cn.daxpay.open.platform.common.json.util.JacksonUtil;
import cn.daxpay.open.platform.core.enums.pay.notice.NoticeProtocolEnum;
import cn.daxpay.open.plugin.easypay.dao.EasyPayOrderManager;
import cn.daxpay.open.plugin.easypay.entity.EasyPayOrder;
import cn.daxpay.open.plugin.easypay.enums.EasyPayApiVersionEnum;
import cn.daxpay.open.plugin.easypay.result.api.v1.EasyPayCallbackV1Result;
import cn.daxpay.open.plugin.easypay.result.api.v2.EasyPayCallbackV2Result;
import cn.daxpay.open.plugin.easypay.service.config.EasyPayCredentialService;
import cn.daxpay.open.plugin.easypay.util.EasyPayUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.core.util.URLUtil;
import cn.hutool.http.HttpResponse;
import cn.hutool.http.HttpUtil;
import cn.hutool.json.JSONUtil;
import tools.jackson.core.type.TypeReference;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.nio.charset.StandardCharsets;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.Objects;
import java.util.TreeMap;
/// # 易支付协议出站发送器
///
/// content_mode=refcontent 存 EasyPayOrder.id发送时实时组装 V1/V2 GET 回调
/// AckHTTP 2xx 且 body=SUCCESS忽略大小写
@Slf4j
@Component
@RequiredArgsConstructor
public class EasyPayNoticeSender implements NoticeProtocolSender {
private static final DateTimeFormatter NORM =
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss").withZone(ZoneId.of("Asia/Shanghai"));
private final EasyPayOrderManager easyPayOrderManager;
private final EasyPayCredentialService easyPayCredentialService;
@Override
public String protocol() {
return NoticeProtocolEnum.EASY_PAY.getCode();
}
@Override
public NoticeSendResult send(MchNoticeTask task) {
NoticeSendResult result = new NoticeSendResult();
Long easyPayOrderId = JSONUtil.parseObj(task.getContent()).getLong("id");
if (easyPayOrderId == null) {
return result.setSuccess(false).setErrorMsg("easy pay ref missing id");
}
EasyPayOrder order = easyPayOrderManager.findByIdNotTenant(easyPayOrderId).orElse(null);
if (order == null) {
return result.setSuccess(false).setErrorMsg("easy pay order not found: " + easyPayOrderId);
}
if (Objects.equals(order.getApiVersion(), EasyPayApiVersionEnum.V1.getCode())) {
return sendV1(task, order, result);
}
return sendV2(task, order, result);
}
private NoticeSendResult sendV1(MchNoticeTask task, EasyPayOrder order, NoticeSendResult result) {
var credential = easyPayCredentialService.getAndCheck(order.getPid());
var callback = new EasyPayCallbackV1Result()
.setPid(order.getPid())
.setTradeNo(order.getTradeNo())
.setOutTradeNo(order.getOutTradeNo())
.setType(order.getType())
.setName(order.getName())
.setMoney(order.getMoney() == null ? null : order.getMoney().toPlainString())
.setTradeStatus("TRADE_SUCCESS")
.setParam(order.getParam())
.setSignType("MD5");
// 仅一次 MD5 签名(修复商业版重复 setSign
callback.setSign(EasyPayUtil.signByMd5(callback, credential.getMd5Key()));
return doGet(task.getUrl(), callback, result);
}
private NoticeSendResult sendV2(MchNoticeTask task, EasyPayOrder order, NoticeSendResult result) {
var credential = easyPayCredentialService.getAndCheck(order.getPid());
var callback = new EasyPayCallbackV2Result()
.setPid(order.getPid())
.setTradeNo(order.getTradeNo())
.setOutTradeNo(order.getOutTradeNo())
.setApiTradeNo(order.getApiTradeNo())
.setType(order.getType())
.setTradeStatus("TRADE_SUCCESS")
.setAddTime(order.getAddTime() == null ? null : NORM.format(order.getAddTime().toInstant()))
.setEndTime(order.getEndTime() == null ? null : NORM.format(order.getEndTime().toInstant()))
.setName(order.getName())
.setMoney(order.getMoney() == null ? null : order.getMoney().toPlainString())
.setParam(order.getParam())
.setBuyer(order.getBuyer())
.setTimestamp(String.valueOf(System.currentTimeMillis() / 1000))
.setSignType("RSA");
callback.setSign(EasyPayUtil.signByRsa(callback, credential.getPlatformPrivateKey()));
return doGet(task.getUrl(), callback, result);
}
private NoticeSendResult doGet(String baseUrl, Object callback, NoticeSendResult result) {
String body = null;
Integer httpStatus = null;
try {
TreeMap<String, String> map = JacksonUtil.toBean(JacksonUtil.toJson(callback),
new TypeReference<TreeMap<String, String>>() {});
String query = URLUtil.buildQuery(map, StandardCharsets.UTF_8);
String fullUrl = baseUrl.contains("?") ? baseUrl + "&" + query : baseUrl + "?" + query;
result.setRequestDigest(StrUtil.sub(fullUrl, 0, 500));
HttpResponse response = HttpUtil.createGet(fullUrl).timeout(15000).execute();
httpStatus = response.getStatus();
body = response.body();
} catch (Exception e) {
log.error("易支付通知发送失败, url={}", baseUrl, e);
return result.setSuccess(false).setHttpStatus(httpStatus).setErrorMsg(e.getMessage());
}
result.setHttpStatus(httpStatus);
boolean ack = httpStatus != null && httpStatus >= 200 && httpStatus < 300
&& StrUtil.equalsIgnoreCase(StrUtil.trim(body), "SUCCESS");
result.setSuccess(ack);
if (!ack) {
result.setErrorMsg(StrUtil.blankToDefault(StrUtil.sub(body, 0, 300), "httpStatus=" + httpStatus));
}
return result;
}
}

View File

@@ -0,0 +1,44 @@
package cn.daxpay.open.plugin.easypay.result.api.v1;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data;
import lombok.experimental.Accessors;
import java.io.Serializable;
/// # 易支付 V1 回调报文
///
@Data
@Accessors(chain = true)
public class EasyPayCallbackV1Result implements Serializable {
@JsonProperty("pid")
private Integer pid;
@JsonProperty("trade_no")
private String tradeNo;
@JsonProperty("out_trade_no")
private String outTradeNo;
@JsonProperty("type")
private String type;
@JsonProperty("name")
private String name;
@JsonProperty("money")
private String money;
@JsonProperty("trade_status")
private String tradeStatus;
@JsonProperty("param")
private String param;
@JsonProperty("sign")
private String sign;
@JsonProperty("sign_type")
private String signType;
}

View File

@@ -0,0 +1,61 @@
package cn.daxpay.open.plugin.easypay.result.api.v2;
import com.fasterxml.jackson.annotation.JsonProperty;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.experimental.Accessors;
import java.io.Serializable;
/// # 易支付 V2 回调报文
///
@Data
@Accessors(chain = true)
@Schema(title = "易支付V2回调报文")
public class EasyPayCallbackV2Result implements Serializable {
@JsonProperty("pid")
private Integer pid;
@JsonProperty("trade_no")
private String tradeNo;
@JsonProperty("out_trade_no")
private String outTradeNo;
@JsonProperty("api_trade_no")
private String apiTradeNo;
@JsonProperty("type")
private String type;
@JsonProperty("trade_status")
private String tradeStatus;
@JsonProperty("addtime")
private String addTime;
@JsonProperty("endtime")
private String endTime;
@JsonProperty("name")
private String name;
@JsonProperty("money")
private String money;
@JsonProperty("param")
private String param;
@JsonProperty("buyer")
private String buyer;
@JsonProperty("timestamp")
private String timestamp;
@JsonProperty("sign")
private String sign;
@JsonProperty("sign_type")
private String signType;
}

View File

@@ -176,7 +176,7 @@ public class EasyPayPayV2Service {
payParam.setOpenId(openId);
payParam.setAuthCode(authCode);
payParam.setClientIp(order.getClientIp());
payParam.setNotifyUrl(order.getNotifyUrl());
// 易支付 notifyUrl 仅落在协议单,不写内核订单,避免与 system 协议双发
payParam.setReturnUrl(order.getReturnUrl());
payParam.setAttach(order.getParam());
payParam.setSource(easyPayAssistService.sourceCode());

View File

@@ -1,14 +1,23 @@
package cn.daxpay.open.plugin.easypay.strategy;
import cn.daxpay.open.payment.strategy.plugin.AbsPayPluginStrategy;
import cn.daxpay.open.payment.trade.notice.command.NoticeDispatchCommand;
import cn.daxpay.open.payment.trade.notice.service.NoticeDispatcher;
import cn.daxpay.open.payment.trade.order.entity.RefundOrder;
import cn.daxpay.open.payment.trade.order.entity.PayTrade;
import cn.daxpay.open.platform.common.json.util.JacksonUtil;
import cn.daxpay.open.platform.core.enums.pay.notice.NoticeContentModeEnum;
import cn.daxpay.open.platform.core.enums.pay.notice.NoticeEventEnum;
import cn.daxpay.open.platform.core.enums.pay.notice.NoticeProtocolEnum;
import cn.daxpay.open.platform.core.enums.pay.trade.TradeSourceEnum;
import cn.daxpay.open.plugin.easypay.entity.EasyPayOrder;
import cn.daxpay.open.plugin.easypay.service.order.EasyPayOrderService;
import cn.hutool.core.util.StrUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.Map;
import java.util.Objects;
/// # 易支付插件生命周期策略
@@ -20,14 +29,16 @@ import java.util.Objects;
public class EasyPayPluginStrategy implements AbsPayPluginStrategy {
private final EasyPayOrderService easyPayOrderService;
private final NoticeDispatcher noticeDispatcher;
/// 支付成功:回写协议单状态
/// 支付成功:回写协议单状态并注册易支付出站通知
@Override
public void paySuccess(PayTrade trade) {
if (!Objects.equals(TradeSourceEnum.EASY_PAY.getCode(), trade.getSource())) {
return;
}
easyPayOrderService.paySuccess(trade);
EasyPayOrder easyPayOrder = easyPayOrderService.paySuccess(trade);
registerEasyPayNotice(easyPayOrder);
}
/// 关单:一期仅日志
@@ -48,4 +59,28 @@ public class EasyPayPluginStrategy implements AbsPayPluginStrategy {
long amount = refundOrder.getAmount() == null ? 0L : refundOrder.getAmount();
easyPayOrderService.refundSuccess(trade, amount);
}
/// 注册易支付协议出站content 仅存 id 指针)
private void registerEasyPayNotice(EasyPayOrder easyPayOrder) {
if (easyPayOrder == null || StrUtil.isBlank(easyPayOrder.getNotifyUrl())) {
log.info("易支付订单无需回调, outTradeNo={}",
easyPayOrder == null ? null : easyPayOrder.getOutTradeNo());
return;
}
String content = JacksonUtil.toJson(Map.of(
"id", easyPayOrder.getId(),
"pid", easyPayOrder.getPid() == null ? 0 : easyPayOrder.getPid(),
"remark", "ref-only; payload assembled at send time"
));
noticeDispatcher.dispatch(new NoticeDispatchCommand()
.setMchNo(easyPayOrder.getMchNo())
.setAppId(easyPayOrder.getAppId())
.setEvent(NoticeEventEnum.PAY_SUCCESS.getCode())
.setBizId(easyPayOrder.getId())
.setBizNo(easyPayOrder.getTradeNo() != null ? easyPayOrder.getTradeNo() : easyPayOrder.getOutTradeNo())
.setProtocol(NoticeProtocolEnum.EASY_PAY)
.setContentMode(NoticeContentModeEnum.REF)
.setContentOrRef(content)
.setProtocolNotifyUrl(easyPayOrder.getNotifyUrl()));
}
}