From 36a17448dc5471ef8d68c439bbf805207386ba34 Mon Sep 17 00:00:00 2001
From: dzc <2388224167@qq.com>
Date: Mon, 10 Aug 2026 10:51:46 +0800
Subject: [PATCH] =?UTF-8?q?fix:=E4=BA=8C=E7=BB=B4=E7=A0=81=E6=94=AF?=
=?UTF-8?q?=E4=BB=98=E8=BD=AE=E8=AE=AD=E4=BC=98=E5=8C=96?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../hard/job/HardAggregateOrderPayJob.java | 113 ++++++++++++++++-
.../HardAggregatePayPollingCoordinator.java | 112 ++++++++++++++++
.../hard/service/HardOrderService.java | 10 ++
.../impl/order/HardOrderServiceImpl.java | 99 ++++++++++++++-
.../job/HardAggregateOrderPayJobTest.java | 77 +++++++++++
...ardAggregatePayPollingCoordinatorTest.java | 42 ++++++
.../job/ThirdAggregateOrderPayJob.java | 75 ++++++++++-
.../thirdparty/job/ThirdAppPlusPayJob.java | 58 +++++++++
.../ThirdAggregateOrderPayService.java | 4 +-
.../service/ThirdPayPollingCoordinator.java | 7 +
.../ThirdAggregateOrderPayServiceImpl.java | 69 +++++++++-
.../job/ThirdAggregateOrderPayJobTest.java | 120 ++++++++++++++++++
.../job/ThirdAppPlusPayJobTest.java | 19 +++
...ThirdAggregateOrderPayServiceImplTest.java | 53 ++++++++
14 files changed, 834 insertions(+), 24 deletions(-)
create mode 100644 pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardAggregatePayPollingCoordinator.java
create mode 100644 pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/job/HardAggregateOrderPayJobTest.java
create mode 100644 pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/HardAggregatePayPollingCoordinatorTest.java
create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/job/ThirdAggregateOrderPayJobTest.java
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/job/HardAggregateOrderPayJob.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/job/HardAggregateOrderPayJob.java
index 3af02e996..5d601c533 100644
--- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/job/HardAggregateOrderPayJob.java
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/job/HardAggregateOrderPayJob.java
@@ -6,12 +6,14 @@ import com.bsd.pjcloud.common.data.tenant.TenantBroker;
import com.bsd.pjcloud.hard.api.constant.order.HardOrderStatusEnum;
import com.bsd.pjcloud.hard.api.constant.order.HardPayTypeEnum;
import com.bsd.pjcloud.hard.api.entity.order.HardOrderEntity;
+import com.bsd.pjcloud.hard.service.HardAggregatePayPollingCoordinator;
import com.bsd.pjcloud.hard.service.HardOrderService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
+import java.time.LocalDateTime;
import java.util.List;
/**
@@ -30,25 +32,99 @@ public class HardAggregateOrderPayJob {
*/
private static final int BATCH_SIZE = 100;
+ /**
+ * 近期订单高频补偿窗口,单位分钟;覆盖聚合码有效期及 Thirdparty 关单补偿时间。
+ */
+ private static final long ACTIVE_ORDER_WINDOW_MINUTES = 15L;
+
+ /**
+ * 超出近期窗口但尚未达到自动取消时间的订单低频核对间隔,单位毫秒。
+ */
+ private static final long STALE_RECONCILIATION_INTERVAL_MILLIS = 300000L;
+
+ /**
+ * 服务启动后首次核对遗留订单的等待时间,单位毫秒,避免与启动恢复扫描同时争用资源。
+ */
+ private static final long STALE_INITIAL_DELAY_MILLIS = 60000L;
+
/**
* Hardware 订单服务,复用带订单锁和事务的聚合支付状态确认及结算流程。
*/
private final HardOrderService hardOrderService;
/**
- * 每五秒补偿仍处于创建状态且已绑定聚合会话的 Hardware 订单。
+ * 近期聚合订单轮询协调器,用于在空闲时退避数据库扫描并响应新订单唤醒。
+ */
+ private final HardAggregatePayPollingCoordinator pollingCoordinator;
+
+ /**
+ * 高频补偿最近十五分钟内仍处于创建状态且已绑定聚合会话的 Hardware 订单。
*
*
调度线程无租户上下文时先跨租户读取候选记录,再按记录持久化的租户 ID 恢复上下文;
- * 具体结算继续由业务服务的分布式订单锁和状态条件保证幂等。
+ * 具体结算继续由业务服务的分布式订单锁和状态条件保证幂等。方法每五秒轻量触发一次,连续空结果时
+ * 真正的数据库扫描按十秒、二十秒、四十秒退避到最长一分钟。
*/
- @Scheduled(fixedDelay = 5000L, initialDelay = 5000L)
+ @Scheduled(fixedDelay = HardAggregatePayPollingCoordinator.TRIGGER_INTERVAL_MILLIS,
+ initialDelay = HardAggregatePayPollingCoordinator.TRIGGER_INTERVAL_MILLIS)
public void settlePendingAggregateOrders() {
- List orders = hardOrderService.list(new LambdaQueryWrapper()
+ if (!pollingCoordinator.tryStartPolling()) {
+ return;
+ }
+ boolean hasPendingOrders = true;
+ try {
+ LocalDateTime activeSince = LocalDateTime.now().minusMinutes(ACTIVE_ORDER_WINDOW_MINUTES);
+ List orders = listPendingOrders(
+ new LambdaQueryWrapper().ge(HardOrderEntity::getCreateTime, activeSince));
+ hasPendingOrders = ObjectUtil.isNotEmpty(orders);
+ processOrders(orders);
+ } finally {
+ // 查询异常不能被当作空闲,否则数据库或远程服务恢复前会被错误放大重试间隔。
+ pollingCoordinator.finishPolling(hasPendingOrders);
+ }
+ }
+
+ /**
+ * 低频核对十五分钟至一小时内的遗留订单,并取消超过一小时的订单。
+ *
+ * 一小时以内仍保留服务异常恢复能力;超过一小时后不再调用 Thirdparty 查单,而是把仍处于创建状态
+ * 的 Hardware 订单原子更新为未支付取消,避免异常记录被永久轮询。第三方若在此后报告延迟支付,
+ * 由线下渠道核对和协商,不再自动改变 Hardware 库存及订单状态。
+ */
+ @Scheduled(fixedDelay = STALE_RECONCILIATION_INTERVAL_MILLIS, initialDelay = STALE_INITIAL_DELAY_MILLIS)
+ public void settleStaleAggregateOrders() {
+ LocalDateTime now = LocalDateTime.now();
+ LocalDateTime activeSince = now.minusMinutes(ACTIVE_ORDER_WINDOW_MINUTES);
+ LocalDateTime cancelBefore = now.minusMinutes(HardAggregatePayPollingCoordinator.MAX_AUTO_SETTLEMENT_MINUTES);
+ List expiredOrders = listPendingOrders(
+ new LambdaQueryWrapper().lt(HardOrderEntity::getCreateTime, cancelBefore));
+ cancelExpiredOrders(expiredOrders, cancelBefore);
+ List recoverableOrders = listPendingOrders(new LambdaQueryWrapper()
+ .ge(HardOrderEntity::getCreateTime, cancelBefore)
+ .lt(HardOrderEntity::getCreateTime, activeSince));
+ processOrders(recoverableOrders);
+ }
+
+ /**
+ * 按附加时间条件读取仍处于创建状态的聚合支付 Hardware 订单。
+ *
+ * @param timeWrapper 近期或遗留订单的创建时间条件
+ * @return 按创建时间升序排列且最多一百条的待结算订单
+ */
+ private List listPendingOrders(LambdaQueryWrapper timeWrapper) {
+ return hardOrderService.list(timeWrapper
.eq(HardOrderEntity::getPayType, HardPayTypeEnum.AGGREGATE_ORDER_PAY.getType())
.eq(HardOrderEntity::getOrderStatus, HardOrderStatusEnum.CREATE.getType())
.isNotNull(HardOrderEntity::getOrderNo)
.orderByAsc(HardOrderEntity::getCreateTime)
.last("limit " + BATCH_SIZE));
+ }
+
+ /**
+ * 按订单租户恢复上下文并调用已有幂等结算流程。
+ *
+ * @param orders 本轮需要核对的 Hardware 聚合支付订单;为空时不执行远程调用
+ */
+ private void processOrders(List orders) {
if (ObjectUtil.isEmpty(orders)) {
return;
}
@@ -66,4 +142,33 @@ public class HardAggregateOrderPayJob {
}
}
}
+
+ /**
+ * 将超过一小时仍未结算的聚合支付订单原子更新为未支付取消状态。
+ *
+ * 更新同时校验支付方式、创建状态和创建时间,避免与临界时刻的终端查询或支付结算互相覆盖。
+ * 此处使用 {@link HardOrderStatusEnum#UNFOLLOW} 表示未支付自动取消,不能使用代表已核销完成的
+ * {@link HardOrderStatusEnum#CANCEL}。
+ *
+ * @param orders 本轮查询到的超时 Hardware 聚合支付订单
+ * @param cancelBefore 本轮统一的一小时截止时间
+ */
+ private void cancelExpiredOrders(List orders, LocalDateTime cancelBefore) {
+ if (ObjectUtil.isEmpty(orders)) {
+ return;
+ }
+ for (HardOrderEntity order : orders) {
+ if (order.getTenantId() == null) {
+ log.warn("超时聚合支付Hardware订单缺少持久化租户ID,orderId:{}", order.getId());
+ continue;
+ }
+ try {
+ TenantBroker.runAs(order.getTenantId(), tenantId ->
+ hardOrderService.cancelExpiredAggregateOrder(order.getId(), cancelBefore));
+ } catch (Exception exception) {
+ log.warn("取消超时聚合支付Hardware订单失败,orderId:{},tenantId:{},exceptionType:{}",
+ order.getId(), order.getTenantId(), exception.getClass().getSimpleName());
+ }
+ }
+ }
}
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardAggregatePayPollingCoordinator.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardAggregatePayPollingCoordinator.java
new file mode 100644
index 000000000..27a71ed20
--- /dev/null
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardAggregatePayPollingCoordinator.java
@@ -0,0 +1,112 @@
+package com.bsd.pjcloud.hard.service;
+
+import org.springframework.stereotype.Component;
+
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Hardware 聚合订单支付结算轮询协调器。
+ *
+ * 协调器只控制当前服务实例访问数据库的频率,不保存业务订单状态。存在近期待结算订单或查询异常时
+ * 保持五秒补偿;连续空扫描时指数退避,创建订单后主动唤醒,避免无订单时全天执行固定频率 SQL。
+ */
+@Component
+public class HardAggregatePayPollingCoordinator {
+
+ /**
+ * 近期聚合订单结算任务的轻量触发周期,单位毫秒;数据库扫描还会受空闲退避门控制。
+ */
+ public static final long TRIGGER_INTERVAL_MILLIS = 5000L;
+
+ /**
+ * 聚合支付 Hardware 订单允许自动结算的最长时间,单位分钟;状态查询和后台任务必须共用该值。
+ */
+ public static final long MAX_AUTO_SETTLEMENT_MINUTES = 60L;
+
+ /**
+ * 连续空扫描时允许达到的最大数据库扫描间隔,单位毫秒。
+ */
+ private static final long MAX_IDLE_INTERVAL_MILLIS = 60000L;
+
+ /**
+ * 指数退避最大左移位数,防止连续空扫描计数和间隔计算无限增长。
+ */
+ private static final int MAX_BACKOFF_SHIFT = 8;
+
+ /**
+ * 连续空扫描次数;发现订单或收到创建唤醒后清零。
+ */
+ private int consecutiveEmptyScans;
+
+ /**
+ * 使用 {@link System#nanoTime()} 时间基准保存的下次允许扫描时刻。
+ */
+ private long nextScanAtNanos = System.nanoTime();
+
+ /**
+ * 当前是否已有线程取得扫描资格,用于防止调度线程重叠访问数据库。
+ */
+ private boolean scanInProgress;
+
+ /**
+ * 扫描执行期间是否收到新订单唤醒,确保扫描结束时不会覆盖唤醒信号。
+ */
+ private boolean wakeUpRequested;
+
+ /**
+ * 原子判断近期聚合订单是否到达下次数据库扫描时间,并锁定本轮扫描资格。
+ *
+ * @return 到达扫描时间且当前没有同类扫描执行时返回 {@code true}
+ */
+ public synchronized boolean tryStartPolling() {
+ long now = System.nanoTime();
+ if (scanInProgress || now - nextScanAtNanos < 0) {
+ return false;
+ }
+ scanInProgress = true;
+ return true;
+ }
+
+ /**
+ * 释放扫描资格并按查询结果安排下一次数据库扫描。
+ *
+ * @param hasPendingOrders 本轮是否查询到近期待结算订单;查询异常时传 {@code true} 以保持短间隔重试
+ */
+ public synchronized void finishPolling(boolean hasPendingOrders) {
+ scanInProgress = false;
+ if (wakeUpRequested) {
+ wakeUpRequested = false;
+ consecutiveEmptyScans = 0;
+ nextScanAtNanos = System.nanoTime();
+ return;
+ }
+ long nextIntervalMillis = hasPendingOrders ? TRIGGER_INTERVAL_MILLIS : nextIdleIntervalMillis();
+ if (hasPendingOrders) {
+ consecutiveEmptyScans = 0;
+ }
+ nextScanAtNanos = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(nextIntervalMillis);
+ }
+
+ /**
+ * 聚合订单创建或复用成功后唤醒近期扫描,最迟在下一个五秒调度周期访问数据库。
+ */
+ public synchronized void wakePolling() {
+ consecutiveEmptyScans = 0;
+ if (scanInProgress) {
+ wakeUpRequested = true;
+ return;
+ }
+ nextScanAtNanos = System.nanoTime();
+ }
+
+ /**
+ * 按连续空扫描次数计算下一次等待时长,并限制在一分钟以内。
+ *
+ * @return 下一次数据库扫描等待时长,单位毫秒
+ */
+ private long nextIdleIntervalMillis() {
+ consecutiveEmptyScans = Math.min(consecutiveEmptyScans + 1, MAX_BACKOFF_SHIFT);
+ long multiplier = 1L << consecutiveEmptyScans;
+ return Math.min(TRIGGER_INTERVAL_MILLIS * multiplier, MAX_IDLE_INTERVAL_MILLIS);
+ }
+}
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardOrderService.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardOrderService.java
index b7e62933b..473680f2c 100644
--- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardOrderService.java
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardOrderService.java
@@ -40,6 +40,7 @@ import com.bsd.pjcloud.thirdparty.dto.ks.KsRecognitionCallbackDto;
import com.bsd.pjcloud.wallet.api.vo.MchManageVo;
import jakarta.validation.Valid;
+import java.time.LocalDateTime;
import java.util.List;
public interface HardOrderService extends IService {
@@ -139,6 +140,15 @@ public interface HardOrderService extends IService {
*/
HardAggregateOrderCodePayStatusVo getAggregateOrderCodePayStatus(Long orderId, String aggregateNo);
+ /**
+ * 在订单锁内取消超过服务端自动结算时限的聚合支付订单。
+ *
+ * @param orderId Hardware 业务订单 ID
+ * @param cancelBefore 允许自动结算的订单创建时间截止点
+ * @return 当前订单仍为聚合支付创建状态且已超时时返回 {@code true}
+ */
+ boolean cancelExpiredAggregateOrder(Long orderId, LocalDateTime cancelBefore);
+
/**
* 用户确认设备二维码扫一扫订单
*/
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImpl.java
index 6207100e6..8879bb66f 100644
--- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImpl.java
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImpl.java
@@ -206,6 +206,9 @@ public class HardOrderServiceImpl extends ServiceImpl延迟到提交后可避免调度线程提前查询不到刚创建或刚绑定的订单;没有事务同步上下文时立即唤醒,
+ * 保证非代理内部调用也不会丢失服务端补偿信号。
+ */
+ private void wakeAggregatePayPollingAfterCommit() {
+ if (!TransactionSynchronizationManager.isSynchronizationActive()) {
+ aggregatePayPollingCoordinator.wakePolling();
+ return;
+ }
+ TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
+ /**
+ * 事务提交完成后解除当前实例的空闲退避,使新订单最迟在下一个五秒周期被发现。
+ */
+ @Override
+ public void afterCommit() {
+ aggregatePayPollingCoordinator.wakePolling();
+ }
+ });
}
/**
* 查询同一终端本地订单号尚未结束的聚合支付订单,供创建响应丢失后的重试复用。
*
* @param dto 当前收银端创建请求
- * @return 同设备、同商户的待支付或已支付聚合订单;不存在时返回 {@code null}
+ * @return 同设备、同商户的一小时内待支付订单或既有已支付订单;不存在时返回 {@code null}
*/
private HardOrderEntity getReusableAggregateOrder(HardCreateOrderDto dto) {
+ LocalDateTime cancelBefore = LocalDateTime.now()
+ .minusMinutes(HardAggregatePayPollingCoordinator.MAX_AUTO_SETTLEMENT_MINUTES);
return getOne(new LambdaQueryWrapper()
.eq(HardOrderEntity::getAndroidOrderCode, dto.getAndroidOrderCode())
.eq(HardOrderEntity::getHardId, dto.getHardwareId())
.eq(HardOrderEntity::getMerchantId, dto.getMerchantId())
.eq(HardOrderEntity::getPayType, HardPayTypeEnum.AGGREGATE_ORDER_PAY.getType())
- .in(HardOrderEntity::getOrderStatus,
- HardOrderStatusEnum.CREATE.getType(), HardOrderStatusEnum.PAY.getType())
+ .and(statusWrapper -> statusWrapper
+ .eq(HardOrderEntity::getOrderStatus, HardOrderStatusEnum.PAY.getType())
+ .or(waitingWrapper -> waitingWrapper
+ .eq(HardOrderEntity::getOrderStatus, HardOrderStatusEnum.CREATE.getType())
+ .ge(HardOrderEntity::getCreateTime, cancelBefore)))
.orderByDesc(HardOrderEntity::getCreateTime)
.last("limit 1"));
}
@@ -988,6 +1022,32 @@ public class HardOrderServiceImpl extends ServiceImpl statusResult = thirdAppPlusPayService.getAggregateOrderCodePayStatus(aggregateNo);
if (statusResult == null || !statusResult.isOk() || statusResult.getData() == null) {
@@ -1042,6 +1102,35 @@ public class HardOrderServiceImpl extends ServiceImpl锁用于防止超时任务与终端支付成功结算交叉执行;数据库更新继续校验支付方式、创建状态和
+ * 创建时间,因此已支付、已取消或截止时间内的订单不会被覆盖。
+ *
+ * @param orderId Hardware 业务订单 ID
+ * @param cancelBefore 允许自动结算的订单创建时间截止点
+ * @return 成功把订单推进为未支付取消状态时返回 {@code true}
+ */
+ @Override
+ @Lock4j(keys = {"#orderId"})
+ @Transactional(rollbackFor = Exception.class)
+ public boolean cancelExpiredAggregateOrder(Long orderId, LocalDateTime cancelBefore) {
+ if (orderId == null || cancelBefore == null) {
+ return false;
+ }
+ return update(new LambdaUpdateWrapper()
+ .eq(HardOrderEntity::getId, orderId)
+ .eq(HardOrderEntity::getPayType, HardPayTypeEnum.AGGREGATE_ORDER_PAY.getType())
+ .eq(HardOrderEntity::getOrderStatus, HardOrderStatusEnum.CREATE.getType())
+ .and(timeWrapper -> timeWrapper
+ .isNull(HardOrderEntity::getCreateTime)
+ .or()
+ .le(HardOrderEntity::getCreateTime, cancelBefore))
+ .set(HardOrderEntity::getOrderStatus, HardOrderStatusEnum.UNFOLLOW.getType())
+ .set(HardOrderEntity::getUnfollowTime, LocalDateTime.now()));
+ }
+
/**
* 用户确认设备二维码扫一扫订单
*/
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/job/HardAggregateOrderPayJobTest.java b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/job/HardAggregateOrderPayJobTest.java
new file mode 100644
index 000000000..0294a7e8e
--- /dev/null
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/job/HardAggregateOrderPayJobTest.java
@@ -0,0 +1,77 @@
+package com.bsd.pjcloud.hard.job;
+
+import com.baomidou.mybatisplus.core.conditions.Wrapper;
+import com.bsd.pjcloud.common.data.tenant.TenantContextHolder;
+import com.bsd.pjcloud.hard.api.constant.order.HardOrderStatusEnum;
+import com.bsd.pjcloud.hard.api.constant.order.HardPayTypeEnum;
+import com.bsd.pjcloud.hard.api.entity.order.HardOrderEntity;
+import com.bsd.pjcloud.hard.service.HardAggregatePayPollingCoordinator;
+import com.bsd.pjcloud.hard.service.HardOrderService;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * Hardware 聚合订单支付补偿任务测试。
+ */
+@ExtendWith(MockitoExtension.class)
+class HardAggregateOrderPayJobTest {
+
+ /**
+ * Hardware 订单服务 Mock,用于返回历史订单并验证带锁取消入口。
+ */
+ @Mock
+ private HardOrderService hardOrderService;
+
+ /**
+ * 近期轮询协调器 Mock;历史取消路径不依赖客户端或近期扫描门。
+ */
+ @Mock
+ private HardAggregatePayPollingCoordinator pollingCoordinator;
+
+ /**
+ * 被测 Hardware 聚合订单支付补偿任务。
+ */
+ @InjectMocks
+ private HardAggregateOrderPayJob job;
+
+ /**
+ * 清理测试期间由 TenantBroker 设置的线程租户上下文。
+ */
+ @AfterEach
+ void tearDown() {
+ TenantContextHolder.setTenantId(null);
+ }
+
+ /**
+ * 验证超过一小时的订单只进入带锁取消,不再调用 Thirdparty 状态查询流程。
+ */
+ @Test
+ void staleJobShouldCancelOrderOlderThanOneHourWithoutQueryingPaymentStatus() {
+ HardOrderEntity order = new HardOrderEntity();
+ order.setId(101L);
+ order.setTenantId(66L);
+ order.setOrderNo("aggregate-no");
+ order.setPayType(HardPayTypeEnum.AGGREGATE_ORDER_PAY.getType());
+ order.setOrderStatus(HardOrderStatusEnum.CREATE.getType());
+ order.setCreateTime(LocalDateTime.now().minusHours(2));
+ when(hardOrderService.list(any(Wrapper.class))).thenReturn(List.of(order), List.of());
+
+ job.settleStaleAggregateOrders();
+
+ verify(hardOrderService).cancelExpiredAggregateOrder(eq(order.getId()), any(LocalDateTime.class));
+ verify(hardOrderService, never()).getAggregateOrderCodePayStatus(any(), any());
+ }
+}
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/HardAggregatePayPollingCoordinatorTest.java b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/HardAggregatePayPollingCoordinatorTest.java
new file mode 100644
index 000000000..1e3c5cd32
--- /dev/null
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/HardAggregatePayPollingCoordinatorTest.java
@@ -0,0 +1,42 @@
+package com.bsd.pjcloud.hard.service;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Hardware 聚合订单支付自适应轮询协调器测试。
+ */
+class HardAggregatePayPollingCoordinatorTest {
+
+ /**
+ * 验证空扫描会进入退避,而创建订单唤醒能够立即恢复扫描资格。
+ */
+ @Test
+ void emptyScanShouldBackoffUntilOrderCreationWakesPolling() {
+ HardAggregatePayPollingCoordinator coordinator = new HardAggregatePayPollingCoordinator();
+
+ Assertions.assertTrue(coordinator.tryStartPolling());
+ coordinator.finishPolling(false);
+ Assertions.assertFalse(coordinator.tryStartPolling());
+
+ coordinator.wakePolling();
+
+ Assertions.assertTrue(coordinator.tryStartPolling());
+ coordinator.finishPolling(true);
+ }
+
+ /**
+ * 验证扫描执行期间到达的唤醒信号不会被本轮空查询结果覆盖。
+ */
+ @Test
+ void wakeDuringScanShouldRemainEffectiveAfterEmptyResult() {
+ HardAggregatePayPollingCoordinator coordinator = new HardAggregatePayPollingCoordinator();
+
+ Assertions.assertTrue(coordinator.tryStartPolling());
+ coordinator.wakePolling();
+ coordinator.finishPolling(false);
+
+ Assertions.assertTrue(coordinator.tryStartPolling());
+ coordinator.finishPolling(true);
+ }
+}
diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/job/ThirdAggregateOrderPayJob.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/job/ThirdAggregateOrderPayJob.java
index f144293c0..56c82b9ab 100644
--- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/job/ThirdAggregateOrderPayJob.java
+++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/job/ThirdAggregateOrderPayJob.java
@@ -2,11 +2,15 @@ package com.bsd.pjcloud.thirdparty.job;
import cn.hutool.core.util.ObjectUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.bsd.pjcloud.common.data.tenant.TenantBroker;
import com.bsd.pjcloud.thirdparty.constant.pay.ThirdAggregatePaySessionStatusEnum;
+import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPayOrderStatusEnum;
import com.bsd.pjcloud.thirdparty.entity.pay.ThirdAggregatePaySessionEntity;
+import com.bsd.pjcloud.thirdparty.entity.pay.ThirdPayOrderEntity;
import com.bsd.pjcloud.thirdparty.service.ThirdAggregateOrderPayService;
import com.bsd.pjcloud.thirdparty.service.ThirdAggregatePaySessionService;
+import com.bsd.pjcloud.thirdparty.service.ThirdPayOrderService;
import com.bsd.pjcloud.thirdparty.service.ThirdPayPollingCoordinator;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -44,6 +48,11 @@ public class ThirdAggregateOrderPayJob {
*/
private final ThirdAggregateOrderPayService aggregateOrderPayService;
+ /**
+ * 第三方支付子订单服务,用于在一小时硬截止后仅做本地关闭标记。
+ */
+ private final ThirdPayOrderService thirdPayOrderService;
+
/**
* 服务端轮询协调器,用于在无活动会话时退避数据库扫描,并在新会话创建后恢复短间隔。
*/
@@ -54,7 +63,7 @@ public class ThirdAggregateOrderPayJob {
*
* 调度线程初始没有租户上下文,单条处理前使用记录自身 tenantId 恢复上下文;
* Redis 键仍由公共基础设施自动附加租户前缀。方法每五秒轻量触发一次,但连续空结果时协调器会把
- * 真正的数据库扫描逐级退避到最长六十秒。
+ * 真正的数据库扫描逐级退避到最长六十秒;创建超过一小时的记录不再进入高频处理。
*/
@Scheduled(fixedDelay = ThirdPayPollingCoordinator.ACTIVE_TRIGGER_INTERVAL_MILLIS,
initialDelay = ThirdPayPollingCoordinator.ACTIVE_TRIGGER_INTERVAL_MILLIS)
@@ -64,12 +73,15 @@ public class ThirdAggregateOrderPayJob {
}
boolean hasPendingSessions = true;
try {
+ LocalDateTime historyCutoff = LocalDateTime.now()
+ .minusMinutes(ThirdPayPollingCoordinator.MAX_AUTO_PROCESSING_MINUTES);
List sessions = aggregatePaySessionService.list(
new LambdaQueryWrapper()
.in(ThirdAggregatePaySessionEntity::getStatus,
ThirdAggregatePaySessionStatusEnum.WAIT_SCAN.getType(),
ThirdAggregatePaySessionStatusEnum.PLATFORM_CREATING.getType(),
ThirdAggregatePaySessionStatusEnum.PAYING.getType())
+ .ge(ThirdAggregatePaySessionEntity::getCreateTime, historyCutoff)
.orderByAsc(ThirdAggregatePaySessionEntity::getCreateTime)
.last("limit " + BATCH_SIZE));
hasPendingSessions = ObjectUtil.isNotEmpty(sessions);
@@ -93,11 +105,11 @@ public class ThirdAggregateOrderPayJob {
}
/**
- * 补偿处理已到期但子订单尚未确认关闭的聚合会话。
+ * 补偿处理已到期但子订单尚未确认关闭的聚合会话,并关闭超过一小时的历史记录。
*
* 补偿窗口从会话原始失效时间开始计算,最多持续十分钟;因此客户端退出、断网或不再查询状态时,
* 服务端仍会独立完成有限次查单和关单。连续空结果时数据库扫描从三十秒逐级退避到最长五分钟,
- * 同时不会对一天前的未支付订单永久轮询。
+ * 超过一小时后只把会话及未支付子订单标记为关闭,不再请求支付宝或微信。
*/
@Scheduled(fixedDelay = ThirdPayPollingCoordinator.COMPENSATION_TRIGGER_INTERVAL_MILLIS,
initialDelay = ThirdPayPollingCoordinator.COMPENSATION_TRIGGER_INTERVAL_MILLIS)
@@ -108,6 +120,21 @@ public class ThirdAggregateOrderPayJob {
boolean hasExpiredSessions = true;
try {
LocalDateTime now = LocalDateTime.now();
+ List historicalSessions = aggregatePaySessionService.list(
+ new LambdaQueryWrapper()
+ .in(ThirdAggregatePaySessionEntity::getStatus,
+ ThirdAggregatePaySessionStatusEnum.WAIT_SCAN.getType(),
+ ThirdAggregatePaySessionStatusEnum.PLATFORM_CREATING.getType(),
+ ThirdAggregatePaySessionStatusEnum.PAYING.getType(),
+ ThirdAggregatePaySessionStatusEnum.EXPIRED.getType())
+ .and(timeWrapper -> timeWrapper
+ .isNull(ThirdAggregatePaySessionEntity::getCreateTime)
+ .or()
+ .lt(ThirdAggregatePaySessionEntity::getCreateTime,
+ now.minusMinutes(ThirdPayPollingCoordinator.MAX_AUTO_PROCESSING_MINUTES)))
+ .orderByAsc(ThirdAggregatePaySessionEntity::getCreateTime)
+ .last("limit " + BATCH_SIZE));
+ closeHistoricalSessions(historicalSessions);
List sessions = aggregatePaySessionService.list(
new LambdaQueryWrapper()
.eq(ThirdAggregatePaySessionEntity::getStatus, ThirdAggregatePaySessionStatusEnum.EXPIRED.getType())
@@ -117,10 +144,7 @@ public class ThirdAggregateOrderPayJob {
now.minusMinutes(CLOSE_COMPENSATION_MINUTES), now)
.orderByDesc(ThirdAggregatePaySessionEntity::getExpireTime)
.last("limit " + BATCH_SIZE));
- hasExpiredSessions = ObjectUtil.isNotEmpty(sessions);
- if (!hasExpiredSessions) {
- return;
- }
+ hasExpiredSessions = ObjectUtil.isNotEmpty(historicalSessions) || ObjectUtil.isNotEmpty(sessions);
for (ThirdAggregatePaySessionEntity session : sessions) {
if (session.getTenantId() == null) {
log.warn("到期聚合支付会话缺少持久化租户ID,sessionId:{}", session.getId());
@@ -136,4 +160,41 @@ public class ThirdAggregateOrderPayJob {
pollingCoordinator.finishAggregateCompensationPolling(hasExpiredSessions);
}
}
+
+ /**
+ * 将超过一小时的过期聚合会话及其未支付子订单标记为交易关闭。
+ *
+ * 该分支不调用任何支付平台接口,也不再根据子订单恢复会话。会话先进入关闭终态,随后仅把仍为
+ * 未支付的子订单标记为关闭;已经落库的真实支付结果保留给人工对账,但不会重新激活聚合会话。
+ *
+ * @param sessions 超过一小时且仍处于活动或过期状态的聚合会话
+ */
+ private void closeHistoricalSessions(List sessions) {
+ if (ObjectUtil.isEmpty(sessions)) {
+ return;
+ }
+ for (ThirdAggregatePaySessionEntity session : sessions) {
+ if (session.getTenantId() == null) {
+ log.warn("历史聚合支付会话缺少持久化租户ID,sessionId:{}", session.getId());
+ continue;
+ }
+ try {
+ TenantBroker.runAs(session.getTenantId(), tenantId -> {
+ aggregatePaySessionService.markTerminal(
+ session.getId(), ThirdAggregatePaySessionStatusEnum.PAY_CLOSED);
+ ThirdPayOrderEntity payOrder = session.getPayNo() == null
+ ? null : thirdPayOrderService.getByPayNo(session.getPayNo());
+ if (payOrder != null) {
+ thirdPayOrderService.update(new LambdaUpdateWrapper()
+ .eq(ThirdPayOrderEntity::getId, payOrder.getId())
+ .eq(ThirdPayOrderEntity::getStatus, ThirdPayOrderStatusEnum.PAY_UNPAID.getType())
+ .set(ThirdPayOrderEntity::getStatus, ThirdPayOrderStatusEnum.PAY_CLOSED.getType()));
+ }
+ });
+ } catch (Exception exception) {
+ log.warn("关闭历史聚合支付会话失败,sessionId:{},tenantId:{},exceptionType:{}",
+ session.getId(), session.getTenantId(), exception.getClass().getSimpleName());
+ }
+ }
+ }
}
diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/job/ThirdAppPlusPayJob.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/job/ThirdAppPlusPayJob.java
index 887637176..3224b664c 100644
--- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/job/ThirdAppPlusPayJob.java
+++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/job/ThirdAppPlusPayJob.java
@@ -3,6 +3,7 @@ package com.bsd.pjcloud.thirdparty.job;
import cn.hutool.core.util.ObjectUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.bsd.pjcloud.common.core.constant.enums.YesNoEnum;
import com.bsd.pjcloud.common.data.tenant.TenantBroker;
import com.bsd.pjcloud.thirdparty.constant.miniplatform.AppPlusPlatformTypeEnum;
@@ -38,6 +39,16 @@ public class ThirdAppPlusPayJob {
*/
private static final long ORDER_CODE_CLOSE_COMPENSATION_MINUTES = 10L;
+ /**
+ * 超过历史恢复时间的原生订单码本地关闭标记周期,单位毫秒。
+ */
+ private static final long ORDER_CODE_HISTORY_CLOSE_INTERVAL_MILLIS = 300000L;
+
+ /**
+ * 服务启动后首次标记历史订单码的等待时间,单位毫秒。
+ */
+ private static final long ORDER_CODE_HISTORY_CLOSE_INITIAL_DELAY_MILLIS = 60000L;
+
/**
* 支付订单服务,用于分批读取仍未支付的订单码记录。
*/
@@ -128,4 +139,51 @@ public class ThirdAppPlusPayJob {
}
}
+ /**
+ * 将超过一小时仍未支付的原生支付宝、微信订单码标记为交易关闭。
+ *
+ * 该任务只执行带未支付状态条件的数据库更新,不再向支付宝或微信发起查单、撤销或关单请求;
+ * 平台若在硬截止后出现延迟支付,由人工渠道核对处理。
+ */
+ @Scheduled(fixedDelay = ORDER_CODE_HISTORY_CLOSE_INTERVAL_MILLIS,
+ initialDelay = ORDER_CODE_HISTORY_CLOSE_INITIAL_DELAY_MILLIS)
+ public void closeHistoricalOrderCodeOrders() {
+ LocalDateTime cutoff = LocalDateTime.now()
+ .minusMinutes(ThirdPayPollingCoordinator.MAX_AUTO_PROCESSING_MINUTES);
+ List orders = thirdPayOrderService.list(new LambdaQueryWrapper()
+ .in(ThirdPayOrderEntity::getPayPlatformType,
+ AppPlusPlatformTypeEnum.ALI_PAY.getType(), AppPlusPlatformTypeEnum.WECHAT.getType())
+ .eq(ThirdPayOrderEntity::getBizType, ThirdPayOrderBizTypeEnum.YXFJ_PAY.getType())
+ .eq(ThirdPayOrderEntity::getStatus, ThirdPayOrderStatusEnum.PAY_UNPAID.getType())
+ .isNotNull(ThirdPayOrderEntity::getExpireTime)
+ .and(timeWrapper -> timeWrapper
+ .isNull(ThirdPayOrderEntity::getCreateTime)
+ .or()
+ .lt(ThirdPayOrderEntity::getCreateTime, cutoff))
+ .notExists("SELECT 1 FROM third_aggregate_pay_session s WHERE s.pay_no = third_pay_order.pay_no AND s.del_flag = '0'")
+ .orderByAsc(ThirdPayOrderEntity::getCreateTime)
+ .last("limit " + ORDER_CODE_BATCH_SIZE));
+ for (ThirdPayOrderEntity order : orders) {
+ if (order.getTenantId() == null) {
+ log.warn("历史订单码缺少租户ID,orderId:{},platform:{}", order.getId(), order.getPayPlatformType());
+ continue;
+ }
+ try {
+ TenantBroker.runAs(order.getTenantId(), tenantId -> thirdPayOrderService.update(
+ new LambdaUpdateWrapper()
+ .eq(ThirdPayOrderEntity::getId, order.getId())
+ .eq(ThirdPayOrderEntity::getStatus, ThirdPayOrderStatusEnum.PAY_UNPAID.getType())
+ .and(timeWrapper -> timeWrapper
+ .isNull(ThirdPayOrderEntity::getCreateTime)
+ .or()
+ .lt(ThirdPayOrderEntity::getCreateTime, cutoff))
+ .set(ThirdPayOrderEntity::getStatus, ThirdPayOrderStatusEnum.PAY_CLOSED.getType())));
+ } catch (Exception exception) {
+ log.warn("关闭历史订单码失败,orderId:{},tenantId:{},platform:{},exceptionType:{}",
+ order.getId(), order.getTenantId(), order.getPayPlatformType(),
+ exception.getClass().getSimpleName());
+ }
+ }
+ }
+
}
diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdAggregateOrderPayService.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdAggregateOrderPayService.java
index f031b4bd5..92ae37881 100644
--- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdAggregateOrderPayService.java
+++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdAggregateOrderPayService.java
@@ -75,14 +75,14 @@ public interface ThirdAggregateOrderPayService {
ThirdAggregateOrderCodePayStatusVo getStatusByTicket(String tenantCode, String ticket);
/**
- * 查询、关闭并同步一条活动会话或处于有限关单补偿窗口内的过期会话。
+ * 在一小时自动处理期限内查询、关闭并同步活动或过期会话,期限外仅执行本地关闭标记。
*
* @param sessionId 聚合支付会话主键
*/
void processPendingSession(Long sessionId);
/**
- * 判断支付回调订单是否属于聚合会话;属于时同步会话终态并阻止旧业务回调链继续执行。
+ * 判断支付回调订单是否属于聚合会话;期限内同步终态,期限外关闭会话并阻止旧业务回调链执行。
*
* @param payOrder 已通过平台验签和关键字段校验的最新第三方支付订单
* @return 属于聚合 JSAPI 子订单时返回 {@code true}
diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdPayPollingCoordinator.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdPayPollingCoordinator.java
index 767497dd9..9de0f7f07 100644
--- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdPayPollingCoordinator.java
+++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdPayPollingCoordinator.java
@@ -23,6 +23,13 @@ public class ThirdPayPollingCoordinator {
*/
public static final long COMPENSATION_TRIGGER_INTERVAL_MILLIS = 30000L;
+ /**
+ * 订单码允许服务端自动查单、关单或恢复会话状态的最长时间,单位分钟。
+ *
+ * 超过该硬截止后只允许本地标记关闭,客户端查询、定时任务和创建重试均不得再次访问支付平台。
+ */
+ public static final long MAX_AUTO_PROCESSING_MINUTES = 60L;
+
/**
* 原生订单码和聚合活动会话连续为空时的最大数据库扫描间隔,单位毫秒。
*/
diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateOrderPayServiceImpl.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateOrderPayServiceImpl.java
index f028dc4e0..76ecd3a32 100644
--- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateOrderPayServiceImpl.java
+++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateOrderPayServiceImpl.java
@@ -253,7 +253,9 @@ public class ThirdAggregateOrderPayServiceImpl implements ThirdAggregateOrderPay
}
/**
- * 查询当前租户聚合会话,并先同步已到达终态的第三方子订单。
+ * 查询当前租户聚合会话,并在自动处理期限内同步第三方子订单终态。
+ *
+ * 创建已满一小时的会话只执行本地关闭,不因客户端持续查询再次访问支付平台或恢复历史会话。
*
* @param aggregateNo 聚合支付会话编号
* @return 最小聚合支付状态
@@ -267,7 +269,9 @@ public class ThirdAggregateOrderPayServiceImpl implements ThirdAggregateOrderPay
if (session == null) {
throw new RuntimeException("聚合支付会话不存在");
}
- if (isActive(session) && isExpired(session)) {
+ if (isBeyondAutoProcessingWindow(session)) {
+ processPendingSession(session.getId());
+ } else if (isActive(session) && isExpired(session)) {
processPendingSession(session.getId());
} else {
syncSessionFromChildOrder(session);
@@ -414,7 +418,8 @@ public class ThirdAggregateOrderPayServiceImpl implements ThirdAggregateOrderPay
* 处理一条待确认聚合会话或处于有限补偿窗口内的过期会话。
*
* 活动会话到期后即使第三方查询或关单异常,也会在本轮结束时推进为过期状态,退出五秒高频队列;
- * 后续仅由服务端低频补偿任务在限定窗口内重试,不依赖客户端继续查询。
+ * 后续仅由服务端低频补偿任务在限定窗口内重试。创建已满一小时后只执行本地关闭,不再访问平台,
+ * 因此客户端查询和创建重试均不能形成无限历史恢复。
*
* @param sessionId 聚合支付会话主键
*/
@@ -426,6 +431,10 @@ public class ThirdAggregateOrderPayServiceImpl implements ThirdAggregateOrderPay
if (session == null || (!isActive(session) && !expiredCompensation)) {
return;
}
+ if (isBeyondAutoProcessingWindow(session)) {
+ closeHistoricalSessionLocally(session);
+ return;
+ }
if (ObjectUtil.equals(session.getStatus(), ThirdAggregatePaySessionStatusEnum.WAIT_SCAN.getType())) {
if (isExpired(session)) {
aggregatePaySessionService.markTerminal(session.getId(), ThirdAggregatePaySessionStatusEnum.EXPIRED);
@@ -464,7 +473,9 @@ public class ThirdAggregateOrderPayServiceImpl implements ThirdAggregateOrderPay
}
/**
- * 识别共享回调中的聚合 JSAPI 子订单并同步聚合会话,旧支付订单返回 {@code false}。
+ * 识别共享回调中的聚合 JSAPI 子订单并在自动处理期限内同步聚合会话。
+ *
+ * 超过一小时到达的延迟回调仍保留子订单真实支付事实,但聚合会话只标记关闭,不恢复为成功。
*
* @param payOrder 已通过平台验签和关键字段校验的最新第三方支付订单
* @return 找到 pay_no 绑定聚合会话时返回 {@code true}
@@ -478,6 +489,10 @@ public class ThirdAggregateOrderPayServiceImpl implements ThirdAggregateOrderPay
if (session == null) {
return false;
}
+ if (isBeyondAutoProcessingWindow(session)) {
+ closeHistoricalSessionLocally(session);
+ return true;
+ }
aggregatePaySessionService.syncFromPayOrder(payOrder);
return true;
}
@@ -671,6 +686,10 @@ public class ThirdAggregateOrderPayServiceImpl implements ThirdAggregateOrderPay
if (session == null || StrUtil.isBlank(session.getPayNo())) {
return;
}
+ if (isBeyondAutoProcessingWindow(session)) {
+ closeHistoricalSessionLocally(session);
+ return;
+ }
aggregatePaySessionService.syncFromPayOrder(thirdPayOrderService.getByPayNo(session.getPayNo()));
}
@@ -678,7 +697,8 @@ public class ThirdAggregateOrderPayServiceImpl implements ThirdAggregateOrderPay
* 新建支付订单前确认已过期会话绑定的第三方子订单不再处于可支付状态。
*
* 后台补偿超过十分钟后会停止,但创建新订单时仍会同步执行一次查单和关单;只有旧子订单已经成功、
- * 关闭、失败或不存在时才允许继续,避免旧单与新单同时可支付。
+ * 关闭、失败或不存在时才允许继续,避免旧单与新单同时可支付。创建已满一小时的会话只做本地关闭,
+ * 不再通过该入口访问平台或恢复会话状态。
*
* @param session 同业务最新聚合会话,允许为空或非过期状态
*/
@@ -695,7 +715,7 @@ public class ThirdAggregateOrderPayServiceImpl implements ThirdAggregateOrderPay
ThirdAggregatePaySessionEntity latestSession = aggregatePaySessionService.getById(session.getId());
ThirdPayOrderEntity payOrder = StrUtil.isBlank(session.getPayNo())
? null : thirdPayOrderService.getByPayNo(session.getPayNo());
- if (payOrder != null) {
+ if (payOrder != null && !isBeyondAutoProcessingWindow(session)) {
aggregatePaySessionService.syncFromPayOrder(payOrder);
}
if ((latestSession != null && ObjectUtil.equals(latestSession.getStatus(),
@@ -961,6 +981,43 @@ public class ThirdAggregateOrderPayServiceImpl implements ThirdAggregateOrderPay
return session == null || session.getExpireTime() == null || !session.getExpireTime().isAfter(LocalDateTime.now());
}
+ /**
+ * 判断聚合会话是否已经越过服务端自动处理硬截止。
+ *
+ * 创建时间缺失的异常记录同样按越过截止处理,避免任何入口对无法确定年龄的订单持续查单。
+ *
+ * @param session 聚合支付会话
+ * @return 创建时间为空或创建已满一小时时返回 {@code true}
+ */
+ private boolean isBeyondAutoProcessingWindow(ThirdAggregatePaySessionEntity session) {
+ LocalDateTime cutoff = LocalDateTime.now()
+ .minusMinutes(ThirdPayPollingCoordinator.MAX_AUTO_PROCESSING_MINUTES);
+ return session == null || session.getCreateTime() == null || !session.getCreateTime().isAfter(cutoff);
+ }
+
+ /**
+ * 在自动处理硬截止后仅将聚合会话及其未支付子订单标记为关闭。
+ *
+ * 先关闭会话可阻止随后到达的子订单同步重新激活会话;子订单更新带未支付条件,不覆盖已经落库的
+ * 真实成功状态。该方法不调用支付宝或微信接口。
+ *
+ * @param session 已越过自动处理硬截止的活动或过期会话
+ */
+ private void closeHistoricalSessionLocally(ThirdAggregatePaySessionEntity session) {
+ aggregatePaySessionService.markTerminal(session.getId(), ThirdAggregatePaySessionStatusEnum.PAY_CLOSED);
+ if (StrUtil.isBlank(session.getPayNo())) {
+ return;
+ }
+ ThirdPayOrderEntity payOrder = thirdPayOrderService.getByPayNo(session.getPayNo());
+ if (payOrder == null) {
+ return;
+ }
+ thirdPayOrderService.update(new LambdaUpdateWrapper()
+ .eq(ThirdPayOrderEntity::getId, payOrder.getId())
+ .eq(ThirdPayOrderEntity::getStatus, ThirdPayOrderStatusEnum.PAY_UNPAID.getType())
+ .set(ThirdPayOrderEntity::getStatus, ThirdPayOrderStatusEnum.PAY_CLOSED.getType()));
+ }
+
/**
* 阻止同业务存在未归属于聚合会话的旧原生订单码或既有成功订单。
*
diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/job/ThirdAggregateOrderPayJobTest.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/job/ThirdAggregateOrderPayJobTest.java
new file mode 100644
index 000000000..2de19b7c5
--- /dev/null
+++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/job/ThirdAggregateOrderPayJobTest.java
@@ -0,0 +1,120 @@
+package com.bsd.pjcloud.thirdparty.job;
+
+import com.baomidou.mybatisplus.core.conditions.Wrapper;
+import com.bsd.pjcloud.common.data.tenant.TenantContextHolder;
+import com.bsd.pjcloud.thirdparty.constant.pay.ThirdAggregatePaySessionStatusEnum;
+import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPayOrderStatusEnum;
+import com.bsd.pjcloud.thirdparty.entity.pay.ThirdAggregatePaySessionEntity;
+import com.bsd.pjcloud.thirdparty.entity.pay.ThirdPayOrderEntity;
+import com.bsd.pjcloud.thirdparty.service.ThirdAggregateOrderPayService;
+import com.bsd.pjcloud.thirdparty.service.ThirdAggregatePaySessionService;
+import com.bsd.pjcloud.thirdparty.service.ThirdPayOrderService;
+import com.bsd.pjcloud.thirdparty.service.ThirdPayPollingCoordinator;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.when;
+
+/**
+ * Thirdparty 聚合订单码历史终态和补偿窗口测试。
+ */
+@ExtendWith(MockitoExtension.class)
+class ThirdAggregateOrderPayJobTest {
+
+ /**
+ * 聚合会话服务 Mock,用于返回超过一小时的历史会话并验证终态更新。
+ */
+ @Mock
+ private ThirdAggregatePaySessionService aggregatePaySessionService;
+
+ /**
+ * 聚合支付编排服务 Mock,用于确认历史关闭路径不再调用支付平台处理流程。
+ */
+ @Mock
+ private ThirdAggregateOrderPayService aggregateOrderPayService;
+
+ /**
+ * 第三方子订单服务 Mock;无子订单会话关闭时不应访问该服务。
+ */
+ @Mock
+ private ThirdPayOrderService thirdPayOrderService;
+
+ /**
+ * 自适应轮询协调器 Mock,用于允许本轮补偿扫描。
+ */
+ @Mock
+ private ThirdPayPollingCoordinator pollingCoordinator;
+
+ /**
+ * 被测 Thirdparty 聚合订单码任务。
+ */
+ @InjectMocks
+ private ThirdAggregateOrderPayJob job;
+
+ /**
+ * 清理测试期间由 TenantBroker 设置的线程租户上下文。
+ */
+ @AfterEach
+ void tearDown() {
+ TenantContextHolder.setTenantId(null);
+ }
+
+ /**
+ * 验证超过一小时且没有子订单的会话直接标记关闭,不再进入第三方平台查单流程。
+ */
+ @Test
+ void compensationShouldCloseHistoricalSessionWithoutCallingPaymentPlatform() {
+ ThirdAggregatePaySessionEntity session = new ThirdAggregatePaySessionEntity();
+ session.setId(201L);
+ session.setTenantId(66L);
+ session.setStatus(ThirdAggregatePaySessionStatusEnum.EXPIRED.getType());
+ session.setCreateTime(LocalDateTime.now().minusHours(2));
+ when(pollingCoordinator.tryStartAggregateCompensationPolling()).thenReturn(true);
+ when(aggregatePaySessionService.list(any(Wrapper.class))).thenReturn(List.of(session), List.of());
+
+ job.handleExpiredSessionCompensation();
+
+ verify(aggregatePaySessionService).markTerminal(
+ session.getId(), ThirdAggregatePaySessionStatusEnum.PAY_CLOSED);
+ verify(pollingCoordinator).finishAggregateCompensationPolling(true);
+ verifyNoInteractions(aggregateOrderPayService, thirdPayOrderService);
+ }
+
+ /**
+ * 验证历史子订单即使已保存支付成功事实,也只关闭聚合会话而不执行历史状态恢复。
+ */
+ @Test
+ void compensationShouldNotRecoverHistoricalSessionFromSuccessfulChildOrder() {
+ ThirdAggregatePaySessionEntity session = new ThirdAggregatePaySessionEntity();
+ session.setId(202L);
+ session.setTenantId(66L);
+ session.setPayNo("historical-child-pay-no");
+ session.setStatus(ThirdAggregatePaySessionStatusEnum.EXPIRED.getType());
+ session.setCreateTime(LocalDateTime.now().minusHours(2));
+ ThirdPayOrderEntity payOrder = new ThirdPayOrderEntity();
+ payOrder.setId(302L);
+ payOrder.setPayNo(session.getPayNo());
+ payOrder.setStatus(ThirdPayOrderStatusEnum.PAY_SUCCESS.getType());
+ when(pollingCoordinator.tryStartAggregateCompensationPolling()).thenReturn(true);
+ when(aggregatePaySessionService.list(any(Wrapper.class))).thenReturn(List.of(session), List.of());
+ when(thirdPayOrderService.getByPayNo(session.getPayNo())).thenReturn(payOrder);
+
+ job.handleExpiredSessionCompensation();
+
+ verify(aggregatePaySessionService).markTerminal(
+ session.getId(), ThirdAggregatePaySessionStatusEnum.PAY_CLOSED);
+ verify(aggregatePaySessionService, never()).syncFromPayOrder(any());
+ verifyNoInteractions(aggregateOrderPayService);
+ }
+}
diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/job/ThirdAppPlusPayJobTest.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/job/ThirdAppPlusPayJobTest.java
index 741a7355e..d846fc439 100644
--- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/job/ThirdAppPlusPayJobTest.java
+++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/job/ThirdAppPlusPayJobTest.java
@@ -96,4 +96,23 @@ class ThirdAppPlusPayJobTest {
verifyNoInteractions(thirdPayOrderService, thirdAppPlusService);
}
+
+ /**
+ * 验证超过一小时的原生订单码只做本地关闭标记,不再调用支付平台查单流程。
+ */
+ @Test
+ void closeHistoricalOrderCodeOrdersShouldNotQueryPaymentPlatform() {
+ ThirdPayOrderEntity order = new ThirdPayOrderEntity();
+ order.setId(100L);
+ order.setTenantId(66L);
+ order.setStatus(ThirdPayOrderStatusEnum.PAY_UNPAID.getType());
+ order.setCreateTime(LocalDateTime.now().minusHours(2));
+ order.setExpireTime(LocalDateTime.now().minusHours(2));
+ when(thirdPayOrderService.list(any(Wrapper.class))).thenReturn(List.of(order));
+
+ job.closeHistoricalOrderCodeOrders();
+
+ verify(thirdPayOrderService).update(any(Wrapper.class));
+ verifyNoInteractions(thirdAppPlusService);
+ }
}
diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateOrderPayServiceImplTest.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateOrderPayServiceImplTest.java
index 9a3083f5c..28074eaa9 100644
--- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateOrderPayServiceImplTest.java
+++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateOrderPayServiceImplTest.java
@@ -45,6 +45,7 @@ import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
/**
@@ -241,6 +242,7 @@ class ThirdAggregateOrderPayServiceImplTest {
session.setStatus(ThirdAggregatePaySessionStatusEnum.PAYING.getType());
session.setSelectedPlatform(AppPlusPlatformTypeEnum.ALI_PAY.getType());
session.setPayNo("aggregate-child-pay-no");
+ session.setCreateTime(LocalDateTime.now().minusMinutes(1));
session.setExpireTime(LocalDateTime.now().minusSeconds(1));
ThirdPayOrderEntity payOrder = new ThirdPayOrderEntity();
payOrder.setId(101L);
@@ -257,6 +259,57 @@ class ThirdAggregateOrderPayServiceImplTest {
verify(aggregatePaySessionService).markTerminal(session.getId(), ThirdAggregatePaySessionStatusEnum.EXPIRED);
}
+ /**
+ * 验证超过一小时的会话从任何处理入口进入时都只做本地关闭,不访问支付宝或微信。
+ */
+ @Test
+ void historicalSessionShouldCloseLocallyWithoutQueryingPaymentPlatform() {
+ ThirdAggregatePaySessionEntity session = reusableSession();
+ session.setStatus(ThirdAggregatePaySessionStatusEnum.PAYING.getType());
+ session.setSelectedPlatform(AppPlusPlatformTypeEnum.ALI_PAY.getType());
+ session.setPayNo("historical-child-pay-no");
+ session.setCreateTime(LocalDateTime.now().minusHours(2));
+ session.setExpireTime(LocalDateTime.now().minusHours(2));
+ ThirdPayOrderEntity payOrder = new ThirdPayOrderEntity();
+ payOrder.setId(102L);
+ payOrder.setPayNo(session.getPayNo());
+ payOrder.setStatus(ThirdPayOrderStatusEnum.PAY_UNPAID.getType());
+ when(aggregatePaySessionService.getById(session.getId())).thenReturn(session);
+ when(thirdPayOrderService.getByPayNo(session.getPayNo())).thenReturn(payOrder);
+
+ service.processPendingSession(session.getId());
+
+ verify(aggregatePaySessionService).markTerminal(
+ session.getId(), ThirdAggregatePaySessionStatusEnum.PAY_CLOSED);
+ verify(thirdPayOrderService).update(any(Wrapper.class));
+ verifyNoInteractions(aggregateAliPayService, aggregateWechatPayService);
+ }
+
+ /**
+ * 验证一小时后到达的成功回调只保留子订单事实,不把历史聚合会话恢复为支付成功。
+ */
+ @Test
+ void lateSuccessfulCallbackShouldNotRecoverHistoricalSession() {
+ ThirdAggregatePaySessionEntity session = reusableSession();
+ session.setStatus(ThirdAggregatePaySessionStatusEnum.EXPIRED.getType());
+ session.setPayNo("late-success-pay-no");
+ session.setCreateTime(LocalDateTime.now().minusHours(2));
+ session.setExpireTime(LocalDateTime.now().minusHours(2));
+ ThirdPayOrderEntity payOrder = new ThirdPayOrderEntity();
+ payOrder.setId(103L);
+ payOrder.setPayNo(session.getPayNo());
+ payOrder.setStatus(ThirdPayOrderStatusEnum.PAY_SUCCESS.getType());
+ when(aggregatePaySessionService.getByPayNo(payOrder.getPayNo())).thenReturn(session);
+ when(thirdPayOrderService.getByPayNo(payOrder.getPayNo())).thenReturn(payOrder);
+
+ Assertions.assertTrue(service.syncAggregateChildOrder(payOrder));
+
+ verify(aggregatePaySessionService).markTerminal(
+ session.getId(), ThirdAggregatePaySessionStatusEnum.PAY_CLOSED);
+ verify(aggregatePaySessionService, never()).syncFromPayOrder(any());
+ verifyNoInteractions(aggregateAliPayService, aggregateWechatPayService);
+ }
+
/**
* 构建支付宝和微信全局应用配置,公网地址固定使用 HTTPS。
*