mirror of
https://gitee.com/dromara/dax-pay
synced 2026-08-10 23:01:58 +08:00
refactor(notice): 通知系统重构, protocol 拆分为 transport/format/payload
- 删除 notice/protocol 与 NoticeProtocolEnum, 拆为 transport(HTTP/MQ 发送通道) 与 format(报文格式) 两维度 - 新增 notice/payload(信封与载荷构造) 与 notice/job(NoticeRetryJob 重试调度) - NoticeDispatcher/SendEngine/RetryPolicy 适配新枚举, MchNoticeTask 补 transport/format 字段 - NoticeEventEnum 扩展 pay.timeout/pay.cancel/refund.fail/risk.hit, notice_event i18n 全语种同步 - EasyPay 插件通知由 NoticeSender 改为 PayloadBuilder - i18n: 删除 notice_protocol.json, 新增 notice_format/notice_transport.json(10 语种) - MchAppNotifyConfigService 补充 NoticeDispatcher 读取说明
This commit is contained in:
@@ -14,8 +14,10 @@ import org.springframework.stereotype.Service;
|
||||
|
||||
/// # 商户应用事件通知配置服务
|
||||
///
|
||||
/// 应用级通用事件通知配置, 与支付订单级回调并行, 当前版本仅维护配置数据,
|
||||
/// 发送链路(任务/重试/记录)后续阶段实现
|
||||
/// 应用级通用事件通知配置, 与支付订单级回调并行。
|
||||
/// 配置由 [cn.daxpay.open.payment.trade.notice.service.NoticeDispatcher] 读取:
|
||||
/// notifyWay=http 走 HTTP 回调(notifyUrl), notifyWay=mq 走 MQ 推送(发布到 daxpay.notice.<appId> Topic),
|
||||
/// 按订阅事件(subscribedEvents)前缀匹配触发, 发送/重试/记录由 NoticeSendEngine 统一负责
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
package cn.daxpay.open.payment.trade.notice.command;
|
||||
|
||||
import cn.daxpay.open.platform.core.enums.pay.notice.NoticeContentModeEnum;
|
||||
import cn.daxpay.open.platform.core.enums.pay.notice.NoticeProtocolEnum;
|
||||
import cn.daxpay.open.platform.core.enums.pay.notice.NoticeFormatEnum;
|
||||
import cn.daxpay.open.platform.core.enums.pay.notice.NoticeTransportEnum;
|
||||
import lombok.Data;
|
||||
import lombok.experimental.Accessors;
|
||||
|
||||
@@ -30,8 +31,11 @@ public class NoticeDispatchCommand {
|
||||
/// 订单级 notifyUrl(可空)
|
||||
private String orderNotifyUrl;
|
||||
|
||||
/// 通知协议,默认 SYSTEM
|
||||
private NoticeProtocolEnum protocol = NoticeProtocolEnum.SYSTEM;
|
||||
/// 传输通道,默认 HTTP
|
||||
private NoticeTransportEnum transport = NoticeTransportEnum.HTTP;
|
||||
|
||||
/// 报文格式,默认 SYSTEM
|
||||
private NoticeFormatEnum format = NoticeFormatEnum.SYSTEM;
|
||||
|
||||
/// 内容策略
|
||||
private NoticeContentModeEnum contentMode = NoticeContentModeEnum.SNAPSHOT;
|
||||
@@ -39,6 +43,6 @@ public class NoticeDispatchCommand {
|
||||
/// 快照 JSON 或引用指针 JSON
|
||||
private String contentOrRef;
|
||||
|
||||
/// 协议适配层自带 URL(protocol 非 SYSTEM 时使用)
|
||||
/// 协议适配层自带 URL(format 非 SYSTEM 时使用,如易支付)
|
||||
private String protocolNotifyUrl;
|
||||
}
|
||||
|
||||
@@ -5,11 +5,15 @@ import cn.daxpay.open.payment.trade.notice.param.MchNoticeTaskQuery;
|
||||
import cn.daxpay.open.platform.common.mybatisplus.impl.BaseManager;
|
||||
import cn.daxpay.open.platform.common.mybatisplus.query.generator.QueryGenerator;
|
||||
import cn.daxpay.open.platform.common.mybatisplus.util.MpUtil;
|
||||
import cn.daxpay.open.platform.core.annotation.IgnoreTenant;
|
||||
import cn.daxpay.open.platform.core.rest.param.PageParam;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/// # 商户出站通知任务管理
|
||||
@@ -27,14 +31,29 @@ public class MchNoticeTaskManager extends BaseManager<MchNoticeTaskMapper, MchNo
|
||||
|
||||
/// 按幂等键查询已存在任务
|
||||
public Optional<MchNoticeTask> findByIdempotentKey(String mchNo, String appId, String event,
|
||||
String bizNo, String protocol, String source) {
|
||||
String bizNo, String transport, String format, String source) {
|
||||
return lambdaQuery()
|
||||
.eq(MchNoticeTask::getMchNo, mchNo)
|
||||
.eq(MchNoticeTask::getAppId, appId)
|
||||
.eq(MchNoticeTask::getEvent, event)
|
||||
.eq(MchNoticeTask::getBizNo, bizNo)
|
||||
.eq(MchNoticeTask::getProtocol, protocol)
|
||||
.eq(MchNoticeTask::getTransport, transport)
|
||||
.eq(MchNoticeTask::getFormat, format)
|
||||
.eq(MchNoticeTask::getSource, source)
|
||||
.oneOpt();
|
||||
}
|
||||
|
||||
/// 扫描未成功且(nextTime 为空 或 nextTime <= now)的孤儿任务(MQ 投递失败兜底)
|
||||
///
|
||||
/// 覆盖 [cn.daxpay.open.payment.trade.notice.service.NoticeTaskScheduleService#scheduleImmediateAfterCommit]
|
||||
/// 投递 MQ 失败导致任务卡在 success=false、nextTime=null 的场景(全仓无其他扫描入口)。
|
||||
/// 跨租户扫描(定时任务无 HTTP 上下文), 单次上限 limit 防积压爆量。
|
||||
@IgnoreTenant
|
||||
public List<MchNoticeTask> findStaleUnsent(int limit) {
|
||||
return listLimit(limit, q -> q
|
||||
.eq(MchNoticeTask::isSuccess, false)
|
||||
.and(w -> w.isNull(MchNoticeTask::getNextTime)
|
||||
.or().le(MchNoticeTask::getNextTime, OffsetDateTime.now(ZoneOffset.UTC)))
|
||||
.orderByAsc(MchNoticeTask::getCreateTime));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,8 +6,9 @@ import cn.daxpay.open.payment.trade.notice.result.MchNoticeTaskResult;
|
||||
import cn.daxpay.open.platform.common.mybatisplus.function.ToResult;
|
||||
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.notice.NoticeFormatEnum;
|
||||
import cn.daxpay.open.platform.core.enums.pay.notice.NoticeSourceEnum;
|
||||
import cn.daxpay.open.platform.core.enums.pay.notice.NoticeTransportEnum;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import com.baomidou.mybatisplus.annotation.FieldFill;
|
||||
import com.baomidou.mybatisplus.annotation.FieldStrategy;
|
||||
@@ -41,9 +42,13 @@ public class MchNoticeTask extends MchBaseEntity implements ToResult<MchNoticeTa
|
||||
/// @see NoticeEventEnum
|
||||
private String event;
|
||||
|
||||
/// 通知协议
|
||||
/// @see NoticeProtocolEnum
|
||||
private String protocol;
|
||||
/// 传输通道 (http/mq)
|
||||
/// @see NoticeTransportEnum
|
||||
private String transport;
|
||||
|
||||
/// 报文格式 (system/easy_pay)
|
||||
/// @see NoticeFormatEnum
|
||||
private String format;
|
||||
|
||||
/// URL 来源
|
||||
/// @see NoticeSourceEnum
|
||||
@@ -56,7 +61,7 @@ public class MchNoticeTask extends MchBaseEntity implements ToResult<MchNoticeTa
|
||||
/// 通知内容(快照或引用指针)
|
||||
private String content;
|
||||
|
||||
/// 商户接收地址
|
||||
/// 目标地址 (HTTP 时为回调 URL, MQ 时为 Topic 名)
|
||||
private String url;
|
||||
|
||||
/// 是否发送成功
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
package cn.daxpay.open.payment.trade.notice.job;
|
||||
|
||||
import cn.daxpay.open.payment.trade.notice.dao.MchNoticeTaskManager;
|
||||
import cn.daxpay.open.payment.trade.notice.entity.MchNoticeTask;
|
||||
import cn.daxpay.open.payment.trade.notice.service.NoticeTaskScheduleService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import net.javacrumbs.shedlock.spring.annotation.SchedulerLock;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/// # 商户出站通知兜底重投任务
|
||||
///
|
||||
/// 扫描未成功且未排程下次重试的孤儿通知任务, 重新投递 MQ。
|
||||
/// 覆盖 [cn.daxpay.open.payment.trade.notice.service.NoticeTaskScheduleService#scheduleImmediateAfterCommit]
|
||||
/// 投递 MQ 失败(Artemis 故障)导致任务永久卡在 success=false、nextTime=null 的场景;
|
||||
/// 全仓此前无任何扫描 success=false 任务的定时入口, 是通知链路的可用性缺口。
|
||||
///
|
||||
/// 全局开关: `daxpay.platform.config.notice-retry-enabled`(默认 true)。
|
||||
/// 与 [cn.daxpay.open.payment.trade.runtime.job.TradeSyncJob] 一样使用 ShedLock 防多节点重复执行。
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
@ConditionalOnProperty(prefix = "daxpay.platform.config",
|
||||
name = "notice-retry-enabled", havingValue = "true", matchIfMissing = true)
|
||||
public class NoticeRetryJob {
|
||||
|
||||
private final MchNoticeTaskManager mchNoticeTaskManager;
|
||||
private final NoticeTaskScheduleService noticeTaskScheduleService;
|
||||
|
||||
/// 每 2 分钟扫描孤儿通知任务(未成功且 nextTime 为空或已到), 重投 MQ
|
||||
///
|
||||
/// 单笔重投失败不阻断整批(MQ 仍不可用时下轮再试); 已成功任务由 NoticeSendEngine 内部幂等控制不重复发送。
|
||||
@Scheduled(cron = "0 */2 * * * ?")
|
||||
@SchedulerLock(name = "lock:noticeRetry", lockAtMostFor = "110s", lockAtLeastFor = "30s")
|
||||
public void retryStaleTasks() {
|
||||
List<MchNoticeTask> stale = mchNoticeTaskManager.findStaleUnsent(500);
|
||||
if (stale.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
log.info("通知兜底扫描命中 {} 笔孤儿任务, 重投 MQ", stale.size());
|
||||
for (MchNoticeTask task : stale) {
|
||||
try {
|
||||
noticeTaskScheduleService.sendNow(task.getId());
|
||||
} catch (Exception e) {
|
||||
// 单笔重投失败不阻断整批(MQ 仍不可用时下轮再试)
|
||||
log.warn("通知兜底重投失败 taskId={}", task.getId(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -29,8 +29,12 @@ public class MchNoticeTaskQuery {
|
||||
private String event;
|
||||
|
||||
@QueryParam(type = QueryParam.CompareTypeEnum.EQ)
|
||||
@Schema(description = "通知协议")
|
||||
private String protocol;
|
||||
@Schema(description = "传输通道 (http/mq)")
|
||||
private String transport;
|
||||
|
||||
@QueryParam(type = QueryParam.CompareTypeEnum.EQ)
|
||||
@Schema(description = "报文格式 (system/easy_pay)")
|
||||
private String format;
|
||||
|
||||
@QueryParam(type = QueryParam.CompareTypeEnum.EQ)
|
||||
@Schema(description = "URL来源")
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package cn.daxpay.open.payment.trade.notice.payload;
|
||||
|
||||
import lombok.Data;
|
||||
import lombok.experimental.Accessors;
|
||||
|
||||
/// # 商户出站通知投递信封
|
||||
///
|
||||
/// 由 [NoticePayloadBuilder] 按 format 组装, 描述一次投递的请求形态。
|
||||
/// 传输通道 [cn.daxpay.open.payment.trade.notice.transport.NoticeTransportSender] 据此投递:
|
||||
/// - HTTP: 按 method 发请求 (POST 用 body, GET 用 url 含 query)
|
||||
/// - MQ: 忽略 method, 将 body 发布到 task.url(Topic)
|
||||
@Data
|
||||
@Accessors(chain = true)
|
||||
public class NoticeEnvelope {
|
||||
|
||||
/// HTTP 方法 (POST / GET), 仅 HTTP 传输使用; MQ 传输忽略
|
||||
private String method;
|
||||
|
||||
/// HTTP 完整请求 URL (GET 时含 query); MQ 时通常为 null
|
||||
private String url;
|
||||
|
||||
/// 请求体 (POST JSON 或 MQ 推送的消息体); GET 时为 null
|
||||
private String body;
|
||||
|
||||
/// 请求摘要(截断), 便于排查
|
||||
private String requestDigest;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package cn.daxpay.open.payment.trade.notice.payload;
|
||||
|
||||
import cn.daxpay.open.payment.trade.notice.entity.MchNoticeTask;
|
||||
|
||||
/// # 商户出站通知报文构建器
|
||||
///
|
||||
/// 按 [cn.daxpay.open.platform.core.enums.pay.notice.NoticeFormatEnum] 路由,
|
||||
/// 仅负责组装报文内容 ([NoticeEnvelope]), 与传输通道正交
|
||||
public interface NoticePayloadBuilder {
|
||||
|
||||
/// 报文格式编码(与 NoticeFormatEnum.code 对齐: system / easy_pay)
|
||||
String format();
|
||||
|
||||
/// 组装投递信封
|
||||
NoticeEnvelope build(MchNoticeTask task);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package cn.daxpay.open.payment.trade.notice.payload;
|
||||
|
||||
import cn.daxpay.open.payment.common.result.DaxNoticeResult;
|
||||
import cn.daxpay.open.payment.common.util.JsonSignStrUtil;
|
||||
import cn.daxpay.open.payment.common.util.PaySignUtil;
|
||||
import cn.daxpay.open.payment.trade.notice.entity.MchNoticeTask;
|
||||
import cn.daxpay.open.platform.common.config.properties.PlatformConfigProperties;
|
||||
import cn.daxpay.open.platform.common.json.util.JacksonUtil;
|
||||
import cn.daxpay.open.platform.core.code.CommonCode;
|
||||
import cn.daxpay.open.platform.core.enums.pay.notice.NoticeFormatEnum;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.slf4j.MDC;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
|
||||
/// # 标准 DaxPay 签名 JSON 报文构建器
|
||||
///
|
||||
/// 组装 [DaxNoticeResult] JSON + 平台私钥 RSA 签名, 产 POST 信封。
|
||||
/// 对外报文 protocol 字段取自 task.format(值为 system), 保持商户侧契约不变
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class SystemPayloadBuilder implements NoticePayloadBuilder {
|
||||
|
||||
private final PlatformConfigProperties platformConfigProperties;
|
||||
|
||||
@Override
|
||||
public String format() {
|
||||
return NoticeFormatEnum.SYSTEM.getCode();
|
||||
}
|
||||
|
||||
@Override
|
||||
public NoticeEnvelope build(MchNoticeTask task) {
|
||||
var data = JsonSignStrUtil.buildSortedMap(task.getContent());
|
||||
var notice = new DaxNoticeResult<>(CommonCode.SUCCESS_CODE, data, CommonCode.SUCCESS_MSG)
|
||||
.setEvent(task.getEvent())
|
||||
.setProtocol(task.getFormat())
|
||||
.setMchNo(task.getMchNo())
|
||||
.setAppId(task.getAppId());
|
||||
notice.setResTime(OffsetDateTime.now(ZoneOffset.UTC));
|
||||
notice.setReqId(MDC.get(CommonCode.TRACE_ID));
|
||||
String privateKey = platformConfigProperties.getKeyConfig().getPrivateKey();
|
||||
notice.setSign(PaySignUtil.sign(notice, privateKey));
|
||||
String requestJson = JacksonUtil.toJson(notice);
|
||||
return new NoticeEnvelope()
|
||||
.setMethod("POST")
|
||||
.setUrl(task.getUrl())
|
||||
.setBody(requestJson)
|
||||
.setRequestDigest(StrUtil.sub(requestJson, 0, 500));
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
package cn.daxpay.open.payment.trade.notice.protocol;
|
||||
|
||||
import cn.daxpay.open.payment.trade.notice.entity.MchNoticeTask;
|
||||
import lombok.Data;
|
||||
import lombok.experimental.Accessors;
|
||||
|
||||
/// # 商户出站通知协议发送器
|
||||
///
|
||||
/// 按 protocol 精确路由;禁止 fan-out 遍历全部插件
|
||||
public interface NoticeProtocolSender {
|
||||
|
||||
/// 协议编码(与 NoticeProtocolEnum.code 对齐)
|
||||
String protocol();
|
||||
|
||||
/// 执行一次 HTTP/协议发送
|
||||
NoticeSendResult send(MchNoticeTask task);
|
||||
|
||||
/// 单次发送结果
|
||||
@Data
|
||||
@Accessors(chain = true)
|
||||
class NoticeSendResult {
|
||||
/// 是否业务 Ack 成功
|
||||
private boolean success;
|
||||
/// HTTP 状态码(可空)
|
||||
private Integer httpStatus;
|
||||
/// 错误或非 SUCCESS 响应摘要
|
||||
private String errorMsg;
|
||||
/// 请求摘要(便于排查)
|
||||
private String requestDigest;
|
||||
}
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
package cn.daxpay.open.payment.trade.notice.protocol;
|
||||
|
||||
import cn.daxpay.open.payment.common.result.DaxNoticeResult;
|
||||
import cn.daxpay.open.payment.common.util.JsonSignStrUtil;
|
||||
import cn.daxpay.open.payment.common.util.PaySignUtil;
|
||||
import cn.daxpay.open.payment.trade.notice.entity.MchNoticeTask;
|
||||
import cn.daxpay.open.platform.common.config.properties.PlatformConfigProperties;
|
||||
import cn.daxpay.open.platform.common.json.util.JacksonUtil;
|
||||
import cn.daxpay.open.platform.core.code.CommonCode;
|
||||
import cn.daxpay.open.platform.core.enums.pay.notice.NoticeProtocolEnum;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import cn.hutool.http.ContentType;
|
||||
import cn.hutool.http.HttpResponse;
|
||||
import cn.hutool.http.HttpUtil;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.slf4j.MDC;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
|
||||
/// # 标准 DaxPay 签名 JSON 出站发送器
|
||||
///
|
||||
/// Ack 规则:HTTP 2xx 且 body trim 后忽略大小写等于 SUCCESS
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class SystemHttpSignedSender implements NoticeProtocolSender {
|
||||
|
||||
private final PlatformConfigProperties platformConfigProperties;
|
||||
|
||||
@Override
|
||||
public String protocol() {
|
||||
return NoticeProtocolEnum.SYSTEM.getCode();
|
||||
}
|
||||
|
||||
@Override
|
||||
public NoticeSendResult send(MchNoticeTask task) {
|
||||
NoticeSendResult result = new NoticeSendResult();
|
||||
String body = null;
|
||||
Integer httpStatus = null;
|
||||
try {
|
||||
var data = JsonSignStrUtil.buildSortedMap(task.getContent());
|
||||
var notice = new DaxNoticeResult<>(CommonCode.SUCCESS_CODE, data, CommonCode.SUCCESS_MSG)
|
||||
.setEvent(task.getEvent())
|
||||
.setProtocol(task.getProtocol())
|
||||
.setMchNo(task.getMchNo())
|
||||
.setAppId(task.getAppId());
|
||||
notice.setResTime(OffsetDateTime.now(ZoneOffset.UTC));
|
||||
notice.setReqId(MDC.get(CommonCode.TRACE_ID));
|
||||
String privateKey = platformConfigProperties.getKeyConfig().getPrivateKey();
|
||||
notice.setSign(PaySignUtil.sign(notice, privateKey));
|
||||
String requestJson = JacksonUtil.toJson(notice);
|
||||
result.setRequestDigest(StrUtil.sub(requestJson, 0, 500));
|
||||
HttpResponse response = HttpUtil.createPost(task.getUrl())
|
||||
.body(requestJson, ContentType.JSON.getValue())
|
||||
.timeout(15000)
|
||||
.execute();
|
||||
httpStatus = response.getStatus();
|
||||
body = response.body();
|
||||
} catch (Exception e) {
|
||||
log.error("系统协议通知发送失败, taskId={}, bizNo={}", task.getId(), task.getBizNo(), e);
|
||||
result.setSuccess(false)
|
||||
.setHttpStatus(httpStatus)
|
||||
.setErrorMsg(e.getMessage());
|
||||
return result;
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -18,14 +18,6 @@ import java.time.OffsetDateTime;
|
||||
@Schema(title = "商户出站通知任务")
|
||||
public class MchNoticeTaskResult extends MchBaseResult {
|
||||
|
||||
/// 商户名称(由 mchNo 翻译)
|
||||
@Trans(
|
||||
entity = MerchantInfo.class,
|
||||
source = MchBaseResult.Fields.mchNo,
|
||||
result = MerchantInfo.Fields.mchName)
|
||||
@Schema(description = "商户名称")
|
||||
private String mchName;
|
||||
|
||||
@Schema(description = "应用号")
|
||||
private String appId;
|
||||
|
||||
@@ -38,8 +30,11 @@ public class MchNoticeTaskResult extends MchBaseResult {
|
||||
@Schema(description = "通知事件码")
|
||||
private String event;
|
||||
|
||||
@Schema(description = "通知协议")
|
||||
private String protocol;
|
||||
@Schema(description = "传输通道 (http/mq)")
|
||||
private String transport;
|
||||
|
||||
@Schema(description = "报文格式 (system/easy_pay)")
|
||||
private String format;
|
||||
|
||||
@Schema(description = "URL来源")
|
||||
private String source;
|
||||
@@ -50,7 +45,7 @@ public class MchNoticeTaskResult extends MchBaseResult {
|
||||
@Schema(description = "通知内容")
|
||||
private String content;
|
||||
|
||||
@Schema(description = "商户接收地址")
|
||||
@Schema(description = "目标地址 (HTTP回调URL或MQ Topic名)")
|
||||
private String url;
|
||||
|
||||
@Schema(description = "是否发送成功")
|
||||
|
||||
@@ -5,9 +5,11 @@ import cn.daxpay.open.payment.merchant.entity.config.MchAppNotifyConfig;
|
||||
import cn.daxpay.open.payment.trade.notice.command.NoticeDispatchCommand;
|
||||
import cn.daxpay.open.payment.trade.notice.dao.MchNoticeTaskManager;
|
||||
import cn.daxpay.open.payment.trade.notice.entity.MchNoticeTask;
|
||||
import cn.daxpay.open.payment.trade.runtime.mq.PayArtemisConstants;
|
||||
import cn.daxpay.open.platform.core.enums.pay.notice.NoticeContentModeEnum;
|
||||
import cn.daxpay.open.platform.core.enums.pay.notice.NoticeProtocolEnum;
|
||||
import cn.daxpay.open.platform.core.enums.pay.notice.NoticeFormatEnum;
|
||||
import cn.daxpay.open.platform.core.enums.pay.notice.NoticeSourceEnum;
|
||||
import cn.daxpay.open.platform.core.enums.pay.notice.NoticeTransportEnum;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
@@ -18,7 +20,10 @@ import java.util.Objects;
|
||||
|
||||
/// # 商户出站通知派发器
|
||||
///
|
||||
/// SYSTEM:尝试创建 order + app 两条任务;其它协议:仅 protocol 任务
|
||||
/// 决定一次事件生成几条任务、各任务用何种传输通道 [NoticeTransportEnum] + 报文格式 [NoticeFormatEnum]:
|
||||
/// - 订单级 (ORDER): 走订单传入的 notifyUrl, 恒 HTTP + SYSTEM
|
||||
/// - 应用级 (APP): 走 [MchAppNotifyConfig], 按 notifyWay(http/mq) 决定 transport
|
||||
/// - 协议级 (PROTOCOL): 协议适配层自带 URL (如易支付), HTTP + 对应 format
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@@ -38,42 +43,58 @@ public class NoticeDispatcher {
|
||||
log.warn("出站通知命令缺少必要字段, skip");
|
||||
return;
|
||||
}
|
||||
NoticeProtocolEnum protocol = command.getProtocol() == null
|
||||
? NoticeProtocolEnum.SYSTEM : command.getProtocol();
|
||||
NoticeTransportEnum transport = command.getTransport() == null
|
||||
? NoticeTransportEnum.HTTP : command.getTransport();
|
||||
NoticeFormatEnum format = command.getFormat() == null
|
||||
? NoticeFormatEnum.SYSTEM : command.getFormat();
|
||||
NoticeContentModeEnum contentMode = command.getContentMode() == null
|
||||
? NoticeContentModeEnum.SNAPSHOT : command.getContentMode();
|
||||
|
||||
if (protocol == NoticeProtocolEnum.SYSTEM) {
|
||||
tryCreate(command, NoticeSourceEnum.ORDER, protocol, contentMode, command.getOrderNotifyUrl());
|
||||
// SYSTEM 格式: 订单级 + 应用级 双轨并行 (订单级恒 HTTP)
|
||||
if (format == NoticeFormatEnum.SYSTEM) {
|
||||
tryCreate(command, NoticeSourceEnum.ORDER, NoticeTransportEnum.HTTP, format, contentMode, command.getOrderNotifyUrl());
|
||||
tryCreateApp(command, contentMode);
|
||||
return;
|
||||
}
|
||||
tryCreate(command, NoticeSourceEnum.PROTOCOL, protocol, contentMode, command.getProtocolNotifyUrl());
|
||||
// 其它格式 (如 easy_pay): 协议适配层自带 URL, 单条 PROTOCOL 任务
|
||||
tryCreate(command, NoticeSourceEnum.PROTOCOL, transport, format, contentMode, command.getProtocolNotifyUrl());
|
||||
}
|
||||
|
||||
/// 应用级订阅
|
||||
/// 应用级订阅: 按 [MchAppNotifyConfig].notifyWay 决定传输通道
|
||||
private void tryCreateApp(NoticeDispatchCommand command, NoticeContentModeEnum contentMode) {
|
||||
MchAppNotifyConfig config = notifyConfigManager.findByAppId(command.getAppId()).orElse(null);
|
||||
if (config == null || !Boolean.TRUE.equals(config.getStatus()) || StrUtil.isBlank(config.getNotifyUrl())) {
|
||||
if (config == null || !Boolean.TRUE.equals(config.getStatus())) {
|
||||
return;
|
||||
}
|
||||
if (!matchSubscribed(config.getSubscribedEvents(), command.getEvent())) {
|
||||
return;
|
||||
}
|
||||
tryCreate(command, NoticeSourceEnum.APP, NoticeProtocolEnum.SYSTEM, contentMode, config.getNotifyUrl());
|
||||
// 按配置的通知方式决定传输通道与目标地址
|
||||
if (NoticeTransportEnum.MQ.getCode().equals(config.getNotifyWay())) {
|
||||
// MQ 方式: 目标地址为按应用隔离的 Topic
|
||||
String topic = PayArtemisConstants.MCH_NOTICE_TOPIC_PREFIX + "." + command.getAppId();
|
||||
tryCreate(command, NoticeSourceEnum.APP, NoticeTransportEnum.MQ, NoticeFormatEnum.SYSTEM, contentMode, topic);
|
||||
} else {
|
||||
// HTTP 方式(默认): 目标地址为配置的回调 URL
|
||||
if (StrUtil.isBlank(config.getNotifyUrl())) {
|
||||
return;
|
||||
}
|
||||
tryCreate(command, NoticeSourceEnum.APP, NoticeTransportEnum.HTTP, NoticeFormatEnum.SYSTEM, contentMode, config.getNotifyUrl());
|
||||
}
|
||||
}
|
||||
|
||||
/// 创建任务(幂等)并投递
|
||||
private void tryCreate(NoticeDispatchCommand command, NoticeSourceEnum source,
|
||||
NoticeProtocolEnum protocol, NoticeContentModeEnum contentMode, String url) {
|
||||
NoticeTransportEnum transport, NoticeFormatEnum format,
|
||||
NoticeContentModeEnum contentMode, String url) {
|
||||
if (StrUtil.isBlank(url)) {
|
||||
log.debug("出站通知跳过(无URL): event={}, bizNo={}, source={}",
|
||||
log.debug("出站通知跳过(无目标地址): event={}, bizNo={}, source={}",
|
||||
command.getEvent(), command.getBizNo(), source.getCode());
|
||||
return;
|
||||
}
|
||||
var existing = taskManager.findByIdempotentKey(
|
||||
command.getMchNo(), command.getAppId(), command.getEvent(),
|
||||
command.getBizNo(), protocol.getCode(), source.getCode());
|
||||
command.getBizNo(), transport.getCode(), format.getCode(), source.getCode());
|
||||
if (existing.isPresent()) {
|
||||
log.info("出站通知任务已存在, 跳过创建: event={}, bizNo={}, source={}",
|
||||
command.getEvent(), command.getBizNo(), source.getCode());
|
||||
@@ -86,7 +107,8 @@ public class NoticeDispatcher {
|
||||
.setBizId(command.getBizId())
|
||||
.setBizNo(command.getBizNo())
|
||||
.setEvent(command.getEvent())
|
||||
.setProtocol(protocol.getCode())
|
||||
.setTransport(transport.getCode())
|
||||
.setFormat(format.getCode())
|
||||
.setSource(source.getCode())
|
||||
.setContentMode(contentMode.getCode())
|
||||
.setContent(command.getContentOrRef())
|
||||
@@ -96,8 +118,8 @@ public class NoticeDispatcher {
|
||||
.setDelayCount(0);
|
||||
taskManager.save(task);
|
||||
scheduleService.scheduleImmediateAfterCommit(task.getId());
|
||||
log.info("注册出站通知: event={}, bizNo={}, protocol={}, source={}",
|
||||
command.getEvent(), command.getBizNo(), protocol.getCode(), source.getCode());
|
||||
log.info("注册出站通知: event={}, bizNo={}, transport={}, format={}, source={}",
|
||||
command.getEvent(), command.getBizNo(), transport.getCode(), format.getCode(), source.getCode());
|
||||
}
|
||||
|
||||
/// 订阅匹配:精确事件码,或前缀(pay 匹配 pay.*)
|
||||
|
||||
@@ -1,21 +1,26 @@
|
||||
package cn.daxpay.open.payment.trade.notice.service;
|
||||
|
||||
import cn.daxpay.open.platform.core.enums.pay.notice.NoticeTransportEnum;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/// # 商户出站通知重试策略
|
||||
///
|
||||
/// 仿微信通知节奏,约 16 次延时重试,合计约 24h+
|
||||
/// 间隔:15s/15s/30s/3m/10m/20m/30m×3/60m/3h×3/6h×…
|
||||
/// 按传输通道 [NoticeTransportEnum] 区分:
|
||||
/// - HTTP: 仿微信通知节奏, 约 16 次延时重试, 合计约 24h+ (业务 ACK 失败兜底)
|
||||
/// - MQ: 仅 3 次短间隔重试 (publish 失败兜底; 消费侧失败由商户/MQ 自身负责)
|
||||
@Component
|
||||
public class NoticeRetryPolicy {
|
||||
|
||||
/// 最大延时重试次数
|
||||
public static final int MAX_DELAY_COUNT = 16;
|
||||
/// HTTP 最大延时重试次数
|
||||
public static final int MAX_DELAY_COUNT_HTTP = 16;
|
||||
|
||||
/// key: 延时次数(1起), value: 下次间隔秒数
|
||||
private static final Map<Integer, Integer> DELAY_SECONDS = Map.ofEntries(
|
||||
/// MQ 最大延时重试次数
|
||||
public static final int MAX_DELAY_COUNT_MQ = 3;
|
||||
|
||||
/// HTTP 间隔 (仿微信): 15s/15s/30s/3m/10m/20m/30m×3/60m/3h×3/6h×…
|
||||
private static final Map<Integer, Integer> DELAY_SECONDS_HTTP = Map.ofEntries(
|
||||
Map.entry(1, 15),
|
||||
Map.entry(2, 15),
|
||||
Map.entry(3, 30),
|
||||
@@ -34,15 +39,30 @@ public class NoticeRetryPolicy {
|
||||
Map.entry(16, 6 * 60 * 60)
|
||||
);
|
||||
|
||||
/// MQ 间隔: 10s/30s/60s
|
||||
private static final Map<Integer, Integer> DELAY_SECONDS_MQ = Map.of(
|
||||
1, 10,
|
||||
2, 30,
|
||||
3, 60
|
||||
);
|
||||
|
||||
/// 各传输通道最大延时重试次数
|
||||
public int maxDelayCount(String transport) {
|
||||
return NoticeTransportEnum.MQ.getCode().equals(transport) ? MAX_DELAY_COUNT_MQ : MAX_DELAY_COUNT_HTTP;
|
||||
}
|
||||
|
||||
/// 是否还可继续延时重试(delayCount 为已完成的延时次数)
|
||||
public boolean canRetry(int delayCount) {
|
||||
return delayCount < MAX_DELAY_COUNT;
|
||||
public boolean canRetry(String transport, int delayCount) {
|
||||
return delayCount < maxDelayCount(transport);
|
||||
}
|
||||
|
||||
/// 获取下一次延时间隔秒数
|
||||
///
|
||||
/// @param nextDelayCount 即将执行的延时序号(1..16)
|
||||
public int nextDelaySeconds(int nextDelayCount) {
|
||||
return DELAY_SECONDS.getOrDefault(nextDelayCount, 6 * 60 * 60);
|
||||
/// @param nextDelayCount 即将执行的延时序号 (1..max)
|
||||
public int nextDelaySeconds(String transport, int nextDelayCount) {
|
||||
if (NoticeTransportEnum.MQ.getCode().equals(transport)) {
|
||||
return DELAY_SECONDS_MQ.getOrDefault(nextDelayCount, 60);
|
||||
}
|
||||
return DELAY_SECONDS_HTTP.getOrDefault(nextDelayCount, 6 * 60 * 60);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,10 @@ import cn.daxpay.open.payment.trade.notice.dao.MchNoticeRecordManager;
|
||||
import cn.daxpay.open.payment.trade.notice.dao.MchNoticeTaskManager;
|
||||
import cn.daxpay.open.payment.trade.notice.entity.MchNoticeRecord;
|
||||
import cn.daxpay.open.payment.trade.notice.entity.MchNoticeTask;
|
||||
import cn.daxpay.open.payment.trade.notice.protocol.NoticeProtocolSender;
|
||||
import cn.daxpay.open.payment.trade.notice.payload.NoticeEnvelope;
|
||||
import cn.daxpay.open.payment.trade.notice.payload.NoticePayloadBuilder;
|
||||
import cn.daxpay.open.payment.trade.notice.transport.NoticeSendResult;
|
||||
import cn.daxpay.open.payment.trade.notice.transport.NoticeTransportSender;
|
||||
import cn.daxpay.open.platform.core.enums.pay.notice.NoticeSendTypeEnum;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
@@ -20,7 +23,7 @@ import java.util.stream.Collectors;
|
||||
|
||||
/// # 商户出站通知发送引擎
|
||||
///
|
||||
/// 唯一负责:选 Sender、写流水、更新任务、排期重试
|
||||
/// 唯一负责: 选 PayloadBuilder(按 format) 组装信封 → 选 TransportSender(按 transport) 投递 → 写流水 → 更新任务 → 排期重试
|
||||
@Slf4j
|
||||
@Service
|
||||
public class NoticeSendEngine {
|
||||
@@ -30,21 +33,31 @@ public class NoticeSendEngine {
|
||||
private final NoticeRetryPolicy retryPolicy;
|
||||
private final NoticeTaskScheduleService scheduleService;
|
||||
private final PaymentContext paymentContext;
|
||||
private final Map<String, NoticeProtocolSender> senderMap;
|
||||
private final Map<String, NoticePayloadBuilder> payloadBuilderMap;
|
||||
private final Map<String, NoticeTransportSender> transportSenderMap;
|
||||
|
||||
public NoticeSendEngine(MchNoticeTaskManager taskManager,
|
||||
MchNoticeRecordManager recordManager,
|
||||
NoticeRetryPolicy retryPolicy,
|
||||
NoticeTaskScheduleService scheduleService,
|
||||
PaymentContext paymentContext,
|
||||
List<NoticeProtocolSender> senders) {
|
||||
List<NoticePayloadBuilder> payloadBuilders,
|
||||
List<NoticeTransportSender> transportSenders) {
|
||||
this.taskManager = taskManager;
|
||||
this.recordManager = recordManager;
|
||||
this.retryPolicy = retryPolicy;
|
||||
this.scheduleService = scheduleService;
|
||||
this.paymentContext = paymentContext;
|
||||
this.senderMap = senders.stream()
|
||||
.collect(Collectors.toMap(NoticeProtocolSender::protocol, Function.identity(), (a, b) -> a));
|
||||
this.payloadBuilderMap = payloadBuilders.stream()
|
||||
.collect(Collectors.toMap(NoticePayloadBuilder::format, Function.identity(), (a, b) -> {
|
||||
log.warn("NoticePayloadBuilder format 冲突, 保留前者: {}", a.format());
|
||||
return a;
|
||||
}));
|
||||
this.transportSenderMap = transportSenders.stream()
|
||||
.collect(Collectors.toMap(NoticeTransportSender::transport, Function.identity(), (a, b) -> {
|
||||
log.warn("NoticeTransportSender transport 冲突, 保留前者: {}", a.transport());
|
||||
return a;
|
||||
}));
|
||||
}
|
||||
|
||||
/// 自动发送(消费端入口)
|
||||
@@ -89,7 +102,6 @@ public class NoticeSendEngine {
|
||||
}
|
||||
log.info("手动重发已成功任务: taskId={}", taskId);
|
||||
}
|
||||
NoticeProtocolSender sender = senderMap.get(task.getProtocol());
|
||||
OffsetDateTime sendTime = OffsetDateTime.now(ZoneOffset.UTC);
|
||||
int reqCount = (task.getSendCount() == null ? 0 : task.getSendCount()) + 1;
|
||||
MchNoticeRecord record = new MchNoticeRecord();
|
||||
@@ -99,18 +111,37 @@ public class NoticeSendEngine {
|
||||
.setReqCount(reqCount)
|
||||
.setSendType(autoSend ? NoticeSendTypeEnum.AUTO.getCode() : NoticeSendTypeEnum.MANUAL.getCode());
|
||||
|
||||
if (sender == null) {
|
||||
log.error("未找到通知协议 Sender: protocol={}, taskId={}", task.getProtocol(), taskId);
|
||||
record.setSuccess(false).setErrorMsg("protocol sender not found: " + task.getProtocol());
|
||||
NoticePayloadBuilder payloadBuilder = payloadBuilderMap.get(task.getFormat());
|
||||
if (payloadBuilder == null) {
|
||||
log.error("未找到通知报文构建器: format={}, taskId={}", task.getFormat(), taskId);
|
||||
record.setSuccess(false).setErrorMsg("payload builder not found: " + task.getFormat());
|
||||
failUpdate(task, sendTime, autoSend, record);
|
||||
return;
|
||||
}
|
||||
NoticeTransportSender transportSender = transportSenderMap.get(task.getTransport());
|
||||
if (transportSender == null) {
|
||||
log.error("未找到通知传输发送器: transport={}, taskId={}", task.getTransport(), taskId);
|
||||
record.setSuccess(false).setErrorMsg("transport sender not found: " + task.getTransport());
|
||||
failUpdate(task, sendTime, autoSend, record);
|
||||
return;
|
||||
}
|
||||
|
||||
NoticeProtocolSender.NoticeSendResult sendResult;
|
||||
// 先组装信封, 再投递 (format 与 transport 正交)
|
||||
NoticeEnvelope envelope;
|
||||
try {
|
||||
sendResult = sender.send(task);
|
||||
envelope = payloadBuilder.build(task);
|
||||
} catch (Exception e) {
|
||||
log.error("出站通知 Sender 异常: taskId={}", taskId, e);
|
||||
log.error("出站通知报文组装异常: taskId={}", taskId, e);
|
||||
record.setSuccess(false).setErrorMsg(e.getMessage());
|
||||
failUpdate(task, sendTime, autoSend, record);
|
||||
return;
|
||||
}
|
||||
|
||||
NoticeSendResult sendResult;
|
||||
try {
|
||||
sendResult = transportSender.send(task, envelope);
|
||||
} catch (Exception e) {
|
||||
log.error("出站通知投递异常: taskId={}", taskId, e);
|
||||
record.setSuccess(false).setErrorMsg(e.getMessage());
|
||||
failUpdate(task, sendTime, autoSend, record);
|
||||
return;
|
||||
@@ -134,7 +165,7 @@ public class NoticeSendEngine {
|
||||
failUpdate(task, sendTime, autoSend, record);
|
||||
}
|
||||
|
||||
/// 失败:更新任务并按需排期重试
|
||||
/// 失败:更新任务并按需排期重试 (重试节奏按 transport 区分)
|
||||
private void failUpdate(MchNoticeTask task, OffsetDateTime sendTime, boolean autoSend, MchNoticeRecord record) {
|
||||
int reqCount = record.getReqCount() == null ? 1 : record.getReqCount();
|
||||
task.setSendCount(reqCount).setLatestTime(sendTime);
|
||||
@@ -145,12 +176,13 @@ public class NoticeSendEngine {
|
||||
if (!autoSend) {
|
||||
task.setSuccess(false);
|
||||
}
|
||||
String transport = task.getTransport();
|
||||
if (autoSend && !task.isSuccess()) {
|
||||
int delayCount = task.getDelayCount() == null ? 0 : task.getDelayCount();
|
||||
if (retryPolicy.canRetry(delayCount)) {
|
||||
if (retryPolicy.canRetry(transport, delayCount)) {
|
||||
int next = delayCount + 1;
|
||||
task.setDelayCount(next);
|
||||
int delaySeconds = retryPolicy.nextDelaySeconds(next);
|
||||
int delaySeconds = retryPolicy.nextDelaySeconds(transport, next);
|
||||
task.setNextTime(sendTime.plusSeconds(delaySeconds));
|
||||
taskManager.updateById(task);
|
||||
recordManager.save(record);
|
||||
|
||||
@@ -14,7 +14,8 @@ import cn.daxpay.open.payment.trade.enums.PayTradeTypeEnum;
|
||||
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.notice.NoticeFormatEnum;
|
||||
import cn.daxpay.open.platform.core.enums.pay.notice.NoticeTransportEnum;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
@@ -52,7 +53,7 @@ public class TradeNoticeBridge {
|
||||
.setBizId(order.getId())
|
||||
.setBizNo(order.getOrderNo())
|
||||
.setOrderNotifyUrl(order.getNotifyUrl())
|
||||
.setProtocol(NoticeProtocolEnum.SYSTEM)
|
||||
.setTransport(NoticeTransportEnum.HTTP).setFormat(NoticeFormatEnum.SYSTEM)
|
||||
.setContentMode(NoticeContentModeEnum.SNAPSHOT)
|
||||
.setContentOrRef(content));
|
||||
return;
|
||||
@@ -70,7 +71,7 @@ public class TradeNoticeBridge {
|
||||
.setBizId(order.getId())
|
||||
.setBizNo(order.getOrderNo())
|
||||
.setOrderNotifyUrl(order.getNotifyUrl())
|
||||
.setProtocol(NoticeProtocolEnum.SYSTEM)
|
||||
.setTransport(NoticeTransportEnum.HTTP).setFormat(NoticeFormatEnum.SYSTEM)
|
||||
.setContentMode(NoticeContentModeEnum.SNAPSHOT)
|
||||
.setContentOrRef(content));
|
||||
}
|
||||
@@ -88,7 +89,7 @@ public class TradeNoticeBridge {
|
||||
.setBizId(refundOrder.getId())
|
||||
.setBizNo(refundOrder.getRefundNo())
|
||||
.setOrderNotifyUrl(refundOrder.getNotifyUrl())
|
||||
.setProtocol(NoticeProtocolEnum.SYSTEM)
|
||||
.setTransport(NoticeTransportEnum.HTTP).setFormat(NoticeFormatEnum.SYSTEM)
|
||||
.setContentMode(NoticeContentModeEnum.SNAPSHOT)
|
||||
.setContentOrRef(content));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
package cn.daxpay.open.payment.trade.notice.transport;
|
||||
|
||||
import cn.daxpay.open.payment.trade.notice.entity.MchNoticeTask;
|
||||
import cn.daxpay.open.payment.trade.notice.payload.NoticeEnvelope;
|
||||
import cn.daxpay.open.platform.core.enums.pay.notice.NoticeTransportEnum;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import cn.hutool.http.ContentType;
|
||||
import cn.hutool.http.HttpResponse;
|
||||
import cn.hutool.http.HttpUtil;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/// # HTTP 传输发送器
|
||||
///
|
||||
/// 按 [NoticeEnvelope].method 投递 (POST JSON / GET), ACK 规则: HTTP 2xx 且 body trim 后忽略大小写等于 SUCCESS。
|
||||
/// system 与 easy_pay 两种报文格式共用本发送器
|
||||
@Slf4j
|
||||
@Component
|
||||
public class HttpTransportSender implements NoticeTransportSender {
|
||||
|
||||
@Override
|
||||
public String transport() {
|
||||
return NoticeTransportEnum.HTTP.getCode();
|
||||
}
|
||||
|
||||
@Override
|
||||
public NoticeSendResult send(MchNoticeTask task, NoticeEnvelope envelope) {
|
||||
NoticeSendResult result = new NoticeSendResult();
|
||||
String body = null;
|
||||
Integer httpStatus = null;
|
||||
try {
|
||||
HttpResponse response;
|
||||
if ("GET".equalsIgnoreCase(envelope.getMethod())) {
|
||||
response = HttpUtil.createGet(envelope.getUrl()).timeout(15000).execute();
|
||||
} else {
|
||||
response = HttpUtil.createPost(envelope.getUrl())
|
||||
.body(envelope.getBody(), ContentType.JSON.getValue())
|
||||
.timeout(15000)
|
||||
.execute();
|
||||
}
|
||||
httpStatus = response.getStatus();
|
||||
body = response.body();
|
||||
} catch (Exception e) {
|
||||
log.error("HTTP 通知发送失败, taskId={}, bizNo={}, url={}",
|
||||
task.getId(), task.getBizNo(), task.getUrl(), e);
|
||||
result.setRequestDigest(envelope.getRequestDigest());
|
||||
return result.setSuccess(false)
|
||||
.setHttpStatus(httpStatus)
|
||||
.setErrorMsg(e.getMessage());
|
||||
}
|
||||
result.setRequestDigest(envelope.getRequestDigest());
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package cn.daxpay.open.payment.trade.notice.transport;
|
||||
|
||||
import cn.daxpay.open.payment.trade.notice.entity.MchNoticeTask;
|
||||
import cn.daxpay.open.payment.trade.notice.payload.NoticeEnvelope;
|
||||
import cn.daxpay.open.platform.common.artemis.service.ArtemisTemplateService;
|
||||
import cn.daxpay.open.platform.core.enums.pay.notice.NoticeTransportEnum;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/// # MQ 传输发送器
|
||||
///
|
||||
/// 将 [NoticeEnvelope].body 发布到 task.url(Artemis Topic, 如 daxpay.notice.<appId>),
|
||||
/// publish 成功即视为投递成功(ACK 语义对齐 Stripe EventBridge: 推到事件总线即完成, 消费侧失败由商户/MQ 自身负责)。
|
||||
/// 商户侧用 JMS 持久订阅消费, 离线不丢消息
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class MqTransportSender implements NoticeTransportSender {
|
||||
|
||||
private final ArtemisTemplateService artemisTemplateService;
|
||||
|
||||
@Override
|
||||
public String transport() {
|
||||
return NoticeTransportEnum.MQ.getCode();
|
||||
}
|
||||
|
||||
@Override
|
||||
public NoticeSendResult send(MchNoticeTask task, NoticeEnvelope envelope) {
|
||||
NoticeSendResult result = new NoticeSendResult();
|
||||
result.setRequestDigest(envelope.getRequestDigest());
|
||||
try {
|
||||
// 投递到商户通知 Topic (task.url 在 MQ 方式下存 Topic 名)
|
||||
artemisTemplateService.sendTopic(task.getUrl(), envelope.getBody());
|
||||
result.setSuccess(true);
|
||||
} catch (Exception e) {
|
||||
log.error("MQ 通知投递失败, taskId={}, bizNo={}, topic={}",
|
||||
task.getId(), task.getBizNo(), task.getUrl(), e);
|
||||
result.setSuccess(false).setErrorMsg(e.getMessage());
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package cn.daxpay.open.payment.trade.notice.transport;
|
||||
|
||||
import lombok.Data;
|
||||
import lombok.experimental.Accessors;
|
||||
|
||||
/// # 商户出站通知单次发送结果
|
||||
///
|
||||
/// 由 [NoticeTransportSender] 返回, 引擎据此落流水与排期重试
|
||||
@Data
|
||||
@Accessors(chain = true)
|
||||
public class NoticeSendResult {
|
||||
|
||||
/// 是否业务 Ack 成功 (HTTP: 2xx + body=SUCCESS; MQ: publish 成功)
|
||||
private boolean success;
|
||||
|
||||
/// HTTP 状态码(MQ 投递时可空)
|
||||
private Integer httpStatus;
|
||||
|
||||
/// 错误或非 SUCCESS 响应摘要
|
||||
private String errorMsg;
|
||||
|
||||
/// 请求摘要(便于排查)
|
||||
private String requestDigest;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package cn.daxpay.open.payment.trade.notice.transport;
|
||||
|
||||
import cn.daxpay.open.payment.trade.notice.entity.MchNoticeTask;
|
||||
import cn.daxpay.open.payment.trade.notice.payload.NoticeEnvelope;
|
||||
|
||||
/// # 商户出站通知传输发送器
|
||||
///
|
||||
/// 按 [cn.daxpay.open.platform.core.enums.pay.notice.NoticeTransportEnum] 路由,
|
||||
/// 负责把已组装好的 [NoticeEnvelope] 投递出去 (HTTP 回调 / MQ 推送), 与报文格式正交
|
||||
public interface NoticeTransportSender {
|
||||
|
||||
/// 传输通道编码(与 NoticeTransportEnum.code 对齐: http / mq)
|
||||
String transport();
|
||||
|
||||
/// 执行一次投递
|
||||
NoticeSendResult send(MchNoticeTask task, NoticeEnvelope envelope);
|
||||
}
|
||||
@@ -3,5 +3,9 @@
|
||||
"pay.fail": "Pay Fail",
|
||||
"pay.close": "Pay Close",
|
||||
"refund.success": "Refund Success",
|
||||
"refund.close": "Refund Close"
|
||||
"refund.close": "Refund Close",
|
||||
"pay.timeout": "Pay Timeout Close",
|
||||
"pay.cancel": "Pay Cancel",
|
||||
"refund.fail": "Refund Fail",
|
||||
"risk.hit": "Risk Hit"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"http": "HTTP Callback",
|
||||
"mq": "MQ Push"
|
||||
}
|
||||
@@ -3,5 +3,9 @@
|
||||
"pay.fail": "Pay Fail",
|
||||
"pay.close": "Pay Close",
|
||||
"refund.success": "Refund Success",
|
||||
"refund.close": "Refund Close"
|
||||
"refund.close": "Refund Close",
|
||||
"pay.timeout": "Pembayaran Tutup Timeout",
|
||||
"pay.cancel": "Pembayaran Dibatalkan",
|
||||
"refund.fail": "Pengembalian Gagal",
|
||||
"risk.hit": "Risiko Terdeteksi"
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"system": "System",
|
||||
"system": "Sistem",
|
||||
"easy_pay": "EasyPay"
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"http": "Callback HTTP",
|
||||
"mq": "Push MQ"
|
||||
}
|
||||
@@ -3,5 +3,9 @@
|
||||
"pay.fail": "Pay Fail",
|
||||
"pay.close": "Pay Close",
|
||||
"refund.success": "Refund Success",
|
||||
"refund.close": "Refund Close"
|
||||
"refund.close": "Refund Close",
|
||||
"pay.timeout": "支払タイムアウト終了",
|
||||
"pay.cancel": "支払取消",
|
||||
"refund.fail": "返金失敗",
|
||||
"risk.hit": "リスク検知"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"system": "システムプロトコル",
|
||||
"easy_pay": "EasyPayプロトコル"
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"http": "HTTPコールバック",
|
||||
"mq": "MQプッシュ"
|
||||
}
|
||||
@@ -3,5 +3,9 @@
|
||||
"pay.fail": "Pay Fail",
|
||||
"pay.close": "Pay Close",
|
||||
"refund.success": "Refund Success",
|
||||
"refund.close": "Refund Close"
|
||||
"refund.close": "Refund Close",
|
||||
"pay.timeout": "결제 시간 초과 종료",
|
||||
"pay.cancel": "결제 취소",
|
||||
"refund.fail": "환불 실패",
|
||||
"risk.hit": "리스크 적중"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"system": "시스템 프로토콜",
|
||||
"easy_pay": "EasyPay 프로토콜"
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"http": "HTTP 콜백",
|
||||
"mq": "MQ 푸시"
|
||||
}
|
||||
@@ -3,5 +3,9 @@
|
||||
"pay.fail": "Pay Fail",
|
||||
"pay.close": "Pay Close",
|
||||
"refund.success": "Refund Success",
|
||||
"refund.close": "Refund Close"
|
||||
"refund.close": "Refund Close",
|
||||
"pay.timeout": "Tutup Pembayaran Tamat Masa",
|
||||
"pay.cancel": "Pembayaran Dibatalkan",
|
||||
"refund.fail": "Bayaran Balik Gagal",
|
||||
"risk.hit": "Risiko Dikesan"
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"system": "System",
|
||||
"system": "Sistem",
|
||||
"easy_pay": "EasyPay"
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"http": "HTTP Callback",
|
||||
"mq": "MQ Push"
|
||||
}
|
||||
@@ -3,5 +3,9 @@
|
||||
"pay.fail": "Pay Fail",
|
||||
"pay.close": "Pay Close",
|
||||
"refund.success": "Refund Success",
|
||||
"refund.close": "Refund Close"
|
||||
"refund.close": "Refund Close",
|
||||
"pay.timeout": "ปิดการชำระหมดเวลา",
|
||||
"pay.cancel": "ยกเลิกการชำระ",
|
||||
"refund.fail": "คืนเงินล้มเหลว",
|
||||
"risk.hit": "ตรวจพบความเสี่ยง"
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"system": "System",
|
||||
"system": "ระบบ",
|
||||
"easy_pay": "EasyPay"
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"system": "System",
|
||||
"easy_pay": "EasyPay"
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"http": "HTTP Callback",
|
||||
"mq": "MQ Push"
|
||||
}
|
||||
@@ -3,5 +3,9 @@
|
||||
"pay.fail": "Pay Fail",
|
||||
"pay.close": "Pay Close",
|
||||
"refund.success": "Refund Success",
|
||||
"refund.close": "Refund Close"
|
||||
"refund.close": "Refund Close",
|
||||
"pay.timeout": "Đóng thanh toán hết hạn",
|
||||
"pay.cancel": "Hủy thanh toán",
|
||||
"refund.fail": "Hoàn tiền thất bại",
|
||||
"risk.hit": "Rủi ro phát hiện"
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"system": "System",
|
||||
"system": "Hệ thống",
|
||||
"easy_pay": "EasyPay"
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"system": "System",
|
||||
"easy_pay": "EasyPay"
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"http": "Callback HTTP",
|
||||
"mq": "Đẩy MQ"
|
||||
}
|
||||
@@ -3,5 +3,9 @@
|
||||
"pay.fail": "支付失败",
|
||||
"pay.close": "支付关闭",
|
||||
"refund.success": "退款成功",
|
||||
"refund.close": "退款关闭"
|
||||
"refund.close": "退款关闭",
|
||||
"pay.timeout": "支付超时关闭",
|
||||
"pay.cancel": "支付撤销",
|
||||
"refund.fail": "退款失败",
|
||||
"risk.hit": "风控命中"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"http": "HTTP回调",
|
||||
"mq": "MQ推送"
|
||||
}
|
||||
@@ -3,5 +3,9 @@
|
||||
"pay.fail": "支付失敗",
|
||||
"pay.close": "支付關閉",
|
||||
"refund.success": "退款成功",
|
||||
"refund.close": "退款關閉"
|
||||
"refund.close": "退款關閉",
|
||||
"pay.timeout": "支付逾時關閉",
|
||||
"pay.cancel": "支付撤銷",
|
||||
"refund.fail": "退款失敗",
|
||||
"risk.hit": "風控命中"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"system": "系統協議",
|
||||
"easy_pay": "易支付協議"
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"system": "系统协议",
|
||||
"easy_pay": "易支付协议"
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"http": "HTTP回調",
|
||||
"mq": "MQ推送"
|
||||
}
|
||||
@@ -3,5 +3,9 @@
|
||||
"pay.fail": "支付失敗",
|
||||
"pay.close": "支付關閉",
|
||||
"refund.success": "退款成功",
|
||||
"refund.close": "退款關閉"
|
||||
"refund.close": "退款關閉",
|
||||
"pay.timeout": "支付逾時關閉",
|
||||
"pay.cancel": "支付撤銷",
|
||||
"refund.fail": "退款失敗",
|
||||
"risk.hit": "風控命中"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"system": "系統協議",
|
||||
"easy_pay": "易支付協議"
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"system": "系统协议",
|
||||
"easy_pay": "易支付协议"
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"http": "HTTP回呼",
|
||||
"mq": "MQ推送"
|
||||
}
|
||||
@@ -4,9 +4,9 @@ import cn.daxpay.open.platform.core.i18n.I18nSupport;
|
||||
import lombok.Getter;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
/// # 回调通知类型(已演进为 [NoticeProtocolEnum])
|
||||
/// # 回调通知类型(已演进为 [NoticeFormatEnum])
|
||||
///
|
||||
/// @deprecated 使用 [NoticeProtocolEnum]
|
||||
/// @deprecated 使用 [NoticeFormatEnum]
|
||||
@Deprecated
|
||||
@Getter
|
||||
@RequiredArgsConstructor
|
||||
|
||||
@@ -16,14 +16,20 @@ public enum NoticeEventEnum implements I18nSupport {
|
||||
PAY_SUCCESS("pay.success"),
|
||||
/// 支付失败
|
||||
PAY_FAIL("pay.fail"),
|
||||
/// 支付关闭
|
||||
/// 支付关闭(主动关单, 业务单容器态 CLOSED)
|
||||
PAY_CLOSE("pay.close"),
|
||||
/// 支付超时关闭(业务单容器态 EXPIRED, 区别于主动关单)
|
||||
PAY_TIMEOUT("pay.timeout"),
|
||||
/// 支付撤销(资金态 CANCEL 终态)
|
||||
PAY_CANCEL("pay.cancel"),
|
||||
/// 退款成功
|
||||
REFUND_SUCCESS("refund.success"),
|
||||
/// 退款失败
|
||||
REFUND_FAIL("refund.fail"),
|
||||
/// 退款关闭
|
||||
REFUND_CLOSE("refund.close"),
|
||||
/// 风控命中(黑名单/海外 IP 等规则触发)
|
||||
RISK_HIT("risk.hit"),
|
||||
;
|
||||
|
||||
/// 编码
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
package cn.daxpay.open.platform.core.enums.pay.notice;
|
||||
|
||||
import cn.daxpay.open.platform.core.i18n.I18nSupport;
|
||||
import lombok.Getter;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
/// # 商户出站通知报文格式
|
||||
///
|
||||
/// 字典: notice_format
|
||||
/// 仅描述报文如何组装(JSON / GET query 等)与签名方式, 与传输通道 [NoticeTransportEnum] 正交
|
||||
/// 演进自原 [NoticeProtocolEnum], 拆分后 protocol 不再一维承担「报文格式 + 传输通道 + 路由」三重职责
|
||||
@Getter
|
||||
@RequiredArgsConstructor
|
||||
public enum NoticeFormatEnum implements I18nSupport {
|
||||
|
||||
/// 标准 DaxPay 签名 JSON 报文
|
||||
SYSTEM("system"),
|
||||
/// 易支付兼容协议 GET query 报文
|
||||
EASY_PAY("easy_pay"),
|
||||
;
|
||||
|
||||
/// 编码
|
||||
private final String code;
|
||||
|
||||
/// 翻译 key 前缀
|
||||
@Override
|
||||
public String getI18nPrefix() {
|
||||
return "enum.notice_format";
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
package cn.daxpay.open.platform.core.enums.pay.notice;
|
||||
|
||||
import cn.daxpay.open.platform.core.i18n.I18nSupport;
|
||||
import lombok.Getter;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
/// # 商户出站通知协议
|
||||
///
|
||||
/// 字典: notice_protocol
|
||||
/// 演进自原 CallbackNoticeTypeEnum,表示报文协议而非「回调/订阅」双轨
|
||||
@Getter
|
||||
@RequiredArgsConstructor
|
||||
public enum NoticeProtocolEnum implements I18nSupport {
|
||||
|
||||
/// 标准 DaxPay 签名 JSON 回调
|
||||
SYSTEM("system"),
|
||||
/// 易支付兼容协议 GET 回调
|
||||
EASY_PAY("easy_pay"),
|
||||
;
|
||||
|
||||
/// 编码
|
||||
private final String code;
|
||||
|
||||
/// 翻译 key 前缀
|
||||
@Override
|
||||
public String getI18nPrefix() {
|
||||
return "enum.notice_protocol";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package cn.daxpay.open.platform.core.enums.pay.notice;
|
||||
|
||||
import cn.daxpay.open.platform.core.i18n.I18nSupport;
|
||||
import lombok.Getter;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
/// # 商户出站通知传输通道
|
||||
///
|
||||
/// 字典: notice_transport
|
||||
/// 与报文格式 [NoticeFormatEnum] 正交: 决定通知如何投递(HTTP 回调 / MQ 推送),
|
||||
/// 报文内容由 format 决定, 二者组合如 `http+system` / `mq+system`
|
||||
@Getter
|
||||
@RequiredArgsConstructor
|
||||
public enum NoticeTransportEnum implements I18nSupport {
|
||||
|
||||
/// HTTP 异步回调(POST JSON 或 GET query, 由 format 决定)
|
||||
HTTP("http"),
|
||||
/// MQ 推送(发布到 Artemis Topic, 商户自行订阅消费)
|
||||
MQ("mq"),
|
||||
;
|
||||
|
||||
/// 编码
|
||||
private final String code;
|
||||
|
||||
/// 翻译 key 前缀
|
||||
@Override
|
||||
public String getI18nPrefix() {
|
||||
return "enum.notice_transport";
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
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.payment.trade.notice.payload.NoticeEnvelope;
|
||||
import cn.daxpay.open.payment.trade.notice.payload.NoticePayloadBuilder;
|
||||
import cn.daxpay.open.platform.common.json.util.JacksonUtil;
|
||||
import cn.daxpay.open.platform.core.enums.pay.notice.NoticeProtocolEnum;
|
||||
import cn.daxpay.open.platform.core.enums.pay.notice.NoticeFormatEnum;
|
||||
import cn.daxpay.open.plugin.easypay.dao.EasyPayOrderManager;
|
||||
import cn.daxpay.open.plugin.easypay.entity.EasyPayOrder;
|
||||
import cn.daxpay.open.plugin.easypay.enums.EasyPayApiVersionEnum;
|
||||
@@ -13,8 +14,6 @@ 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;
|
||||
@@ -27,14 +26,14 @@ import java.time.format.DateTimeFormatter;
|
||||
import java.util.Objects;
|
||||
import java.util.TreeMap;
|
||||
|
||||
/// # 易支付协议出站发送器
|
||||
/// # 易支付协议报文构建器
|
||||
///
|
||||
/// content_mode=ref:content 存 EasyPayOrder.id;发送时实时组装 V1/V2 GET 回调
|
||||
/// Ack:HTTP 2xx 且 body=SUCCESS(忽略大小写)
|
||||
/// content_mode=ref: content 存 EasyPayOrder.id; 构建时实时组装 V1/V2 GET 回调信封 (含签名)。
|
||||
/// HTTP 投递与 ACK 判定由 [cn.daxpay.open.payment.trade.notice.transport.HttpTransportSender] 统一处理
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class EasyPayNoticeSender implements NoticeProtocolSender {
|
||||
public class EasyPayPayloadBuilder implements NoticePayloadBuilder {
|
||||
|
||||
private static final DateTimeFormatter NORM =
|
||||
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss").withZone(ZoneId.of("Asia/Shanghai"));
|
||||
@@ -43,28 +42,35 @@ public class EasyPayNoticeSender implements NoticeProtocolSender {
|
||||
private final EasyPayCredentialService easyPayCredentialService;
|
||||
|
||||
@Override
|
||||
public String protocol() {
|
||||
return NoticeProtocolEnum.EASY_PAY.getCode();
|
||||
public String format() {
|
||||
return NoticeFormatEnum.EASY_PAY.getCode();
|
||||
}
|
||||
|
||||
@Override
|
||||
public NoticeSendResult send(MchNoticeTask task) {
|
||||
NoticeSendResult result = new NoticeSendResult();
|
||||
public NoticeEnvelope build(MchNoticeTask task) {
|
||||
Long easyPayOrderId = JSONUtil.parseObj(task.getContent()).getLong("id");
|
||||
if (easyPayOrderId == null) {
|
||||
return result.setSuccess(false).setErrorMsg("easy pay ref missing id");
|
||||
throw new IllegalStateException("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);
|
||||
throw new IllegalStateException("easy pay order not found: " + easyPayOrderId);
|
||||
}
|
||||
if (Objects.equals(order.getApiVersion(), EasyPayApiVersionEnum.V1.getCode())) {
|
||||
return sendV1(task, order, result);
|
||||
}
|
||||
return sendV2(task, order, result);
|
||||
Object callback = Objects.equals(order.getApiVersion(), EasyPayApiVersionEnum.V1.getCode())
|
||||
? buildV1(order) : buildV2(order);
|
||||
// 转 TreeMap 拼接 query (按 key 排序)
|
||||
TreeMap<String, String> map = JacksonUtil.toBean(JacksonUtil.toJson(callback),
|
||||
new TypeReference<TreeMap<String, String>>() {});
|
||||
String query = URLUtil.buildQuery(map, StandardCharsets.UTF_8);
|
||||
String baseUrl = task.getUrl();
|
||||
String fullUrl = baseUrl.contains("?") ? baseUrl + "&" + query : baseUrl + "?" + query;
|
||||
return new NoticeEnvelope()
|
||||
.setMethod("GET")
|
||||
.setUrl(fullUrl)
|
||||
.setRequestDigest(StrUtil.sub(fullUrl, 0, 500));
|
||||
}
|
||||
|
||||
private NoticeSendResult sendV1(MchNoticeTask task, EasyPayOrder order, NoticeSendResult result) {
|
||||
private EasyPayCallbackV1Result buildV1(EasyPayOrder order) {
|
||||
var credential = easyPayCredentialService.getAndCheck(order.getPid());
|
||||
var callback = new EasyPayCallbackV1Result()
|
||||
.setPid(order.getPid())
|
||||
@@ -78,10 +84,10 @@ public class EasyPayNoticeSender implements NoticeProtocolSender {
|
||||
.setSignType("MD5");
|
||||
// 仅一次 MD5 签名(修复商业版重复 setSign)
|
||||
callback.setSign(EasyPayUtil.signByMd5(callback, credential.getMd5Key()));
|
||||
return doGet(task.getUrl(), callback, result);
|
||||
return callback;
|
||||
}
|
||||
|
||||
private NoticeSendResult sendV2(MchNoticeTask task, EasyPayOrder order, NoticeSendResult result) {
|
||||
private EasyPayCallbackV2Result buildV2(EasyPayOrder order) {
|
||||
var credential = easyPayCredentialService.getAndCheck(order.getPid());
|
||||
var callback = new EasyPayCallbackV2Result()
|
||||
.setPid(order.getPid())
|
||||
@@ -99,32 +105,6 @@ public class EasyPayNoticeSender implements NoticeProtocolSender {
|
||||
.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;
|
||||
return callback;
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,8 @@ 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.notice.NoticeFormatEnum;
|
||||
import cn.daxpay.open.platform.core.enums.pay.notice.NoticeTransportEnum;
|
||||
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;
|
||||
@@ -82,7 +83,8 @@ public class EasyPayPluginStrategy implements AbsPayPluginStrategy {
|
||||
.setEvent(NoticeEventEnum.PAY_SUCCESS.getCode())
|
||||
.setBizId(easyPayOrder.getId())
|
||||
.setBizNo(easyPayOrder.getTradeNo() != null ? easyPayOrder.getTradeNo() : easyPayOrder.getOutTradeNo())
|
||||
.setProtocol(NoticeProtocolEnum.EASY_PAY)
|
||||
.setTransport(NoticeTransportEnum.HTTP)
|
||||
.setFormat(NoticeFormatEnum.EASY_PAY)
|
||||
.setContentMode(NoticeContentModeEnum.REF)
|
||||
.setContentOrRef(content)
|
||||
.setProtocolNotifyUrl(easyPayOrder.getNotifyUrl()));
|
||||
|
||||
Reference in New Issue
Block a user