From 8cc63e930ba3e5eca756df0471b98dc31ae3f370 Mon Sep 17 00:00:00 2001 From: daxpay Date: Sun, 2 Aug 2026 10:45:44 +0800 Subject: [PATCH] =?UTF-8?q?feat(payment):=20=E5=87=BA=E7=AB=99=E9=80=9A?= =?UTF-8?q?=E7=9F=A5=20MQ=20=E6=8A=95=E9=80=92=E5=A4=B1=E8=B4=A5=E5=85=9C?= =?UTF-8?q?=E5=BA=95;=20=E6=94=AF=E4=BB=98=E5=AE=9D=20ISV=20=E4=BA=A7?= =?UTF-8?q?=E5=93=81=E7=BB=91=E5=AE=9A=E6=A0=A1=E9=AA=8C=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - NoticeTaskScheduleService.scheduleImmediateAfterCommit 显式捕获投递异常并打日志(afterCommit 回调异常会被 Spring 静默吞掉, task 已落库可手动重发) - 新增 AlipayIsvProductBindingChecker: alipay_isv 产品绑定校验(ISV 应用/应用密钥/应用授权三要素) --- .../isv/AlipayIsvProductBindingChecker.java | 118 ++++++++++++++++++ .../service/NoticeTaskScheduleService.java | 11 +- 2 files changed, 128 insertions(+), 1 deletion(-) create mode 100644 daxpay-channel/daxpay-channel-alipay/src/main/java/cn/daxpay/open/channel/alipay/service/isv/AlipayIsvProductBindingChecker.java diff --git a/daxpay-channel/daxpay-channel-alipay/src/main/java/cn/daxpay/open/channel/alipay/service/isv/AlipayIsvProductBindingChecker.java b/daxpay-channel/daxpay-channel-alipay/src/main/java/cn/daxpay/open/channel/alipay/service/isv/AlipayIsvProductBindingChecker.java new file mode 100644 index 000000000..03654f3f9 --- /dev/null +++ b/daxpay-channel/daxpay-channel-alipay/src/main/java/cn/daxpay/open/channel/alipay/service/isv/AlipayIsvProductBindingChecker.java @@ -0,0 +1,118 @@ +package cn.daxpay.open.channel.alipay.service.isv; + +import cn.daxpay.open.channel.alipay.code.AlipayCode; +import cn.daxpay.open.channel.alipay.dao.isv.AlipayIsvAppAuthConfigManager; +import cn.daxpay.open.channel.alipay.dao.isv.AlipayIsvAppKeyConfigManager; +import cn.daxpay.open.channel.alipay.dao.isv.AlipayIsvAppManager; +import cn.daxpay.open.channel.alipay.entity.isv.AlipayIsvApp; +import cn.daxpay.open.channel.alipay.entity.isv.AlipayIsvAppAuthConfig; +import cn.daxpay.open.channel.alipay.entity.isv.AlipayIsvAppKeyConfig; +import cn.daxpay.open.payment.common.check.checker.ProductBindingChecker; +import cn.daxpay.open.payment.common.check.model.ProductBindingCheckItem; +import cn.daxpay.open.platform.core.enums.pay.channel.ProductEnum; +import cn.hutool.core.util.StrUtil; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.List; + +/// # 支付宝服务商产品绑定检查器 +/// +/// 检查支付宝服务商产品(`alipay_isv`)的3项关键配置完整性: +/// 1. 服务商应用(至少配置一个 [AlipayIsvApp]) +/// 2. 应用密钥配置(至少一个应用密钥完整, 按认证类型区分公钥/证书模式) +/// 3. 应用授权认证(至少一个应用配置了用户标识类型 [AlipayIsvAppAuthConfig]) +/// +/// 与微信服务商的差异: +/// - 微信是单一密钥行(product 维度), 支付宝是多应用结构, 密钥挂在每个 [AlipayIsvApp] 下 +/// - 微信有"产品级默认应用能力"检查, 支付宝 ISV 无此机制(子商户靠 appAuthToken 授权, 不需要) +/// +/// 检查为只读操作, 不产生写入副作用。 +@Slf4j +@Component +@RequiredArgsConstructor +public class AlipayIsvProductBindingChecker implements ProductBindingChecker { + + private final AlipayIsvAppManager alipayIsvAppManager; + private final AlipayIsvAppKeyConfigManager alipayIsvAppKeyConfigManager; + private final AlipayIsvAppAuthConfigManager alipayIsvAppAuthConfigManager; + + @Override + public String getProduct() { + return ProductEnum.ALIPAY_ISV.getCode(); + } + + @Override + public List check() { + List apps = alipayIsvAppManager.listAll(); + + // 服务商应用: 至少配置一个 + boolean appConfigured = !apps.isEmpty(); + + // 应用密钥: 至少一个应用密钥配置完整(按认证类型分支) + boolean appKeyConfigured = apps.stream().anyMatch(app -> + alipayIsvAppKeyConfigManager.findByAlipayIsvAppId(app.getId()) + .map(AlipayIsvProductBindingChecker::isKeyComplete) + .orElse(false)); + + // 应用授权认证: 至少一个应用配置了用户标识类型 + boolean appAuthConfigured = apps.stream().anyMatch(app -> + alipayIsvAppAuthConfigManager.findByAlipayIsvAppId(app.getId()) + .map(AlipayIsvProductBindingChecker::isAuthComplete) + .orElse(false)); + + return List.of( + // 服务商应用 + ProductBindingCheckItem.of( + "alipayIsv.app", + "productBindingCheck.alipayIsv.app.title", + "productBindingCheck.alipayIsv.app.description", + appConfigured, + "openAppManage" + ), + // 应用密钥配置(公钥模式或证书模式) + ProductBindingCheckItem.of( + "alipayIsv.appKey", + "productBindingCheck.alipayIsv.appKey.title", + "productBindingCheck.alipayIsv.appKey.description", + appKeyConfigured, + "openAppManage" + ), + // 应用授权认证 + ProductBindingCheckItem.of( + "alipayIsv.appAuth", + "productBindingCheck.alipayIsv.appAuth.title", + "productBindingCheck.alipayIsv.appAuth.description", + appAuthConfigured, + "openAppManage" + ) + ); + } + + /// 密钥配置完整性判断(按认证类型分支): + /// - 公钥模式([AlipayCode.AuthType#AUTH_TYPE_KEY]): 需支付宝公钥 + 应用私钥 + /// - 证书模式([AlipayCode.AuthType#AUTH_TYPE_CERT]): 需应用私钥 + 应用公钥证书 + 支付宝公钥证书 + 支付宝根证书 + private static boolean isKeyComplete(AlipayIsvAppKeyConfig config) { + // 公钥模式: 支付宝公钥 + 应用私钥 + if (AlipayCode.AuthType.AUTH_TYPE_KEY.equals(config.getAuthType())) { + return StrUtil.isAllNotBlank(config.getAlipayPublicKey(), config.getPrivateKey()); + } + // 证书模式: 应用私钥 + 应用公钥证书 + 支付宝公钥证书 + 支付宝根证书 + if (AlipayCode.AuthType.AUTH_TYPE_CERT.equals(config.getAuthType())) { + return StrUtil.isAllNotBlank( + config.getPrivateKey(), + config.getAppCert(), + config.getAlipayCert(), + config.getAlipayRootCert() + ); + } + // 未知认证类型视为未配置 + return false; + } + + /// 授权认证配置完整性判断: 用户标识类型非空 + private static boolean isAuthComplete(AlipayIsvAppAuthConfig config) { + return StrUtil.isNotBlank(config.getUserIdType()); + } +} diff --git a/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/trade/notice/service/NoticeTaskScheduleService.java b/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/trade/notice/service/NoticeTaskScheduleService.java index d38a73cee..924a2a148 100644 --- a/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/trade/notice/service/NoticeTaskScheduleService.java +++ b/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/trade/notice/service/NoticeTaskScheduleService.java @@ -20,8 +20,17 @@ public class NoticeTaskScheduleService { private final ArtemisTemplateService artemisTemplateService; /// 事务提交后立即投递(delay=0) + // afterCommit 回调在事务提交后执行, 投递失败时事务已无法回滚(task 已落库), + // 若不捕获, 异常会被 Spring 的 TransactionSynchronizationUtils 静默吞掉, 消息永久丢失。 + // 此处显式捕获并打明确日志, task 可由手动重发兜底。 public void scheduleImmediateAfterCommit(Long taskId) { - NoticeTxUtil.afterCommit(() -> sendNow(taskId)); + NoticeTxUtil.afterCommit(() -> { + try { + sendNow(taskId); + } catch (Exception e) { + log.error("出站通知MQ投递失败, 任务不会自动发送, 请手动重发: taskId={}", taskId, e); + } + }); } /// 立即投递