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一小时以内仍保留服务异常恢复能力;超过一小时后不再调用 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更新同时校验支付方式、创建状态和创建时间,避免与临界时刻的终端查询或支付结算互相覆盖。 + * 此处使用 {@link HardOrderStatusEnum#UNFOLLOW} 表示未支付自动取消,不能使用代表已核销完成的 + * {@link HardOrderStatusEnum#CANCEL}。
+ * + * @param orders 本轮查询到的超时 Hardware 聚合支付订单 + * @param cancelBefore 本轮统一的一小时截止时间 + */ + private void cancelExpiredOrders(List协调器只控制当前服务实例访问数据库的频率,不保存业务订单状态。存在近期待结算订单或查询异常时 + * 保持五秒补偿;连续空扫描时指数退避,创建订单后主动唤醒,避免无订单时全天执行固定频率 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调度线程初始没有租户上下文,单条处理前使用记录自身 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补偿窗口从会话原始失效时间开始计算,最多持续十分钟;因此客户端退出、断网或不再查询状态时, * 服务端仍会独立完成有限次查单和关单。连续空结果时数据库扫描从三十秒逐级退避到最长五分钟, - * 同时不会对一天前的未支付订单永久轮询。
+ * 超过一小时后只把会话及未支付子订单标记为关闭,不再请求支付宝或微信。 */ @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该分支不调用任何支付平台接口,也不再根据子订单恢复会话。会话先进入关闭终态,随后仅把仍为 + * 未支付的子订单标记为关闭;已经落库的真实支付结果保留给人工对账,但不会重新激活聚合会话。
+ * + * @param sessions 超过一小时且仍处于活动或过期状态的聚合会话 + */ + private void closeHistoricalSessions(List该任务只执行带未支付状态条件的数据库更新,不再向支付宝或微信发起查单、撤销或关单请求; + * 平台若在硬截止后出现延迟支付,由人工渠道核对处理。
+ */ + @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超过该硬截止后只允许本地标记关闭,客户端查询、定时任务和创建重试均不得再次访问支付平台。
+ */ + 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