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:
daxpay
2026-08-03 19:49:44 +08:00
parent 7b40ead19d
commit 01a5eb89b6
61 changed files with 686 additions and 295 deletions

View File

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

View File

@@ -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;
/// 协议适配层自带 URLprotocol 非 SYSTEM 时使用)
/// 协议适配层自带 URLformat 非 SYSTEM 时使用,如易支付
private String protocolNotifyUrl;
}

View File

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

View File

@@ -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;
/// 是否发送成功

View File

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

View File

@@ -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来源")

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 = "是否发送成功")

View File

@@ -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.*

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,4 @@
{
"http": "HTTP Callback",
"mq": "MQ Push"
}

View File

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

View File

@@ -0,0 +1,4 @@
{
"http": "Callback HTTP",
"mq": "Push MQ"
}

View File

@@ -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": "リスク検知"
}

View File

@@ -0,0 +1,4 @@
{
"system": "システムプロトコル",
"easy_pay": "EasyPayプロトコル"
}

View File

@@ -0,0 +1,4 @@
{
"http": "HTTPコールバック",
"mq": "MQプッシュ"
}

View File

@@ -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": "리스크 적중"
}

View File

@@ -0,0 +1,4 @@
{
"system": "시스템 프로토콜",
"easy_pay": "EasyPay 프로토콜"
}

View File

@@ -0,0 +1,4 @@
{
"http": "HTTP 콜백",
"mq": "MQ 푸시"
}

View File

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

View File

@@ -0,0 +1,4 @@
{
"http": "HTTP Callback",
"mq": "MQ Push"
}

View File

@@ -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": "ตรวจพบความเสี่ยง"
}

View File

@@ -1,4 +0,0 @@
{
"system": "System",
"easy_pay": "EasyPay"
}

View File

@@ -0,0 +1,4 @@
{
"http": "HTTP Callback",
"mq": "MQ Push"
}

View File

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

View File

@@ -1,4 +0,0 @@
{
"system": "System",
"easy_pay": "EasyPay"
}

View File

@@ -0,0 +1,4 @@
{
"http": "Callback HTTP",
"mq": "Đẩy MQ"
}

View File

@@ -3,5 +3,9 @@
"pay.fail": "支付失败",
"pay.close": "支付关闭",
"refund.success": "退款成功",
"refund.close": "退款关闭"
"refund.close": "退款关闭",
"pay.timeout": "支付超时关闭",
"pay.cancel": "支付撤销",
"refund.fail": "退款失败",
"risk.hit": "风控命中"
}

View File

@@ -0,0 +1,4 @@
{
"http": "HTTP回调",
"mq": "MQ推送"
}

View File

@@ -3,5 +3,9 @@
"pay.fail": "支付失敗",
"pay.close": "支付關閉",
"refund.success": "退款成功",
"refund.close": "退款關閉"
"refund.close": "退款關閉",
"pay.timeout": "支付逾時關閉",
"pay.cancel": "支付撤銷",
"refund.fail": "退款失敗",
"risk.hit": "風控命中"
}

View File

@@ -0,0 +1,4 @@
{
"system": "系統協議",
"easy_pay": "易支付協議"
}

View File

@@ -1,4 +0,0 @@
{
"system": "系统协议",
"easy_pay": "易支付协议"
}

View File

@@ -0,0 +1,4 @@
{
"http": "HTTP回調",
"mq": "MQ推送"
}

View File

@@ -3,5 +3,9 @@
"pay.fail": "支付失敗",
"pay.close": "支付關閉",
"refund.success": "退款成功",
"refund.close": "退款關閉"
"refund.close": "退款關閉",
"pay.timeout": "支付逾時關閉",
"pay.cancel": "支付撤銷",
"refund.fail": "退款失敗",
"risk.hit": "風控命中"
}

View File

@@ -0,0 +1,4 @@
{
"system": "系統協議",
"easy_pay": "易支付協議"
}

View File

@@ -1,4 +0,0 @@
{
"system": "系统协议",
"easy_pay": "易支付协议"
}

View File

@@ -0,0 +1,4 @@
{
"http": "HTTP回呼",
"mq": "MQ推送"
}

View File

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

View File

@@ -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"),
;
/// 编码

View File

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

View File

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

View File

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

View File

@@ -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=refcontent EasyPayOrder.id发送时实时组装 V1/V2 GET 回调
/// AckHTTP 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;
}
}

View File

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