Kaynağa Gözat

fix(统一支付): 对齐轮询停止边界并拆分手动同步入口

- 排期时按 createdAt+30min 边界置空 next_query_time,覆盖创建兜底/状态迁移/同步续排三条腿
- 拆出 syncManually:跳过入口停止判断,停止态查完仍保持 next_query_time=null
- 退避索引改为 queryCount-1,使 spec 的 5s 首档可达
alphaH 1 hafta önce
ebeveyn
işleme
5ec148e2c3

+ 61 - 14
java/src/main/java/com/payment/platform/module/payment/unified/service/UnifiedOrderService.java

@@ -185,7 +185,8 @@ public class UnifiedOrderService {
      * 仍为非终态且尚未排期的订单,按当前 {@code queryCount} 兜底安排下一次查询。
      *
      * <p>不增加 {@code queryCount}:创建响应本身已经由 {@link #recordQueryAttempt} 计过一次,
-     * 兜底只负责写 {@code next_query_time}。
+     * 兜底只负责写 {@code next_query_time};排期时间与其它两处共用
+     * {@link #nextQueryTimeWithinWindow}(含 5s 首档与 30 分钟边界)。
      */
     private void ensurePollScheduled(UnifiedOrderEntity order) {
         if (order == null
@@ -194,8 +195,8 @@ public class UnifiedOrderService {
             return;
         }
         int queryCount = order.getQueryCount() == null ? 0 : order.getQueryCount();
-        scheduleNextQuery(order, OffsetDateTime.now()
-                .plusSeconds(UnifiedPollPolicy.nextDelaySeconds(queryCount)));
+        scheduleNextQuery(order,
+                nextQueryTimeWithinWindow(order, OffsetDateTime.now(), queryCount));
     }
 
     private static void validateCreate(UnifiedOrderCreateCommand cmd, UnifiedSource source) {
@@ -342,7 +343,7 @@ public class UnifiedOrderService {
         OffsetDateTime now = OffsetDateTime.now();
         int queryCount = order.getQueryCount() == null ? 0 : order.getQueryCount();
         OffsetDateTime nextQueryTime = UnifiedOrderStatus.PENDING.contains(nextState)
-                ? now.plusSeconds(UnifiedPollPolicy.nextDelaySeconds(queryCount))
+                ? nextQueryTimeWithinWindow(order, now, queryCount)
                 : null;
         OffsetDateTime payTime = UnifiedOrderStatus.SUCCESS.equals(nextState)
                 ? UnifiedResponseParser.toShanghai(
@@ -632,30 +633,52 @@ public class UnifiedOrderService {
     // ────────────────────────────── 主动轮询与手动同步 ──────────────────────────────
 
     /**
-     * 向上游查单并收敛本地状态(10 秒轮询调度器与后台「同步上游」共用)。
+     * 自动轮询入口({@code UnifiedOrderPollScheduler} 消费)。
+     *
+     * <p>先判停止条件:已满 {@link UnifiedPollPolicy#MAX_QUERY_COUNT} 次或创建超过
+     * {@link UnifiedPollPolicy#MAX_AGE_MINUTES} 分钟 → 不打上游,直接把 {@code next_query_time} 置空。
+     * 手动入口见 {@link #syncManually(UnifiedOrderEntity)}。
+     */
+    public void syncFromUpstream(UnifiedOrderEntity order) {
+        sync(order, false);
+    }
+
+    /**
+     * 手动同步入口(后台「同步上游」,Task 16 控制器必须调用本方法)。
+     *
+     * <p>与自动轮询的区别只有一处:<b>入口不判停止条件</b> —— spec §7 承诺「轮询已停止」的
+     * 订单仍可手动同步一次。查单、计数、金额核对、条件 UPDATE、事件/回调全部相同。
+     *
+     * <p>停止态(超 30 分钟/满 60 次)下查完保持 {@code next_query_time = null},
+     * 不重开自动轮询;页面仍显示「轮询已停止」。
+     */
+    public void syncManually(UnifiedOrderEntity order) {
+        sync(order, true);
+    }
+
+    /**
+     * 查单并收敛状态的共用实现。
      *
      * <p>五个刻意保持的顺序/口径(spec §7.2):
      *
      * <ol>
-     *   <li><b>先判停止条件</b>:已满 {@link UnifiedPollPolicy#MAX_QUERY_COUNT} 次或创建
-     *       超过 {@link UnifiedPollPolicy#MAX_AGE_MINUTES} 分钟 → 不打上游,直接把
-     *       {@code next_query_time} 置空。</li>
+     *   <li><b>自动入口先判停止条件</b>(手动入口跳过):到上限不打上游,直接清空排期。</li>
      *   <li><b>配置按 {@code merchant_id} 取、不看启停</b>:{@link UnifiedMerchantService#requireById}
      *       不校验 {@code status} —— 停用商户的历史单还要继续收敛。</li>
      *   <li><b>{@code recordQueryAttempt} 在解析状态之前</b>:上游回了响应就消耗一次查询,
      *       未知状态/金额不符同样计数,否则这类单会无限轮询。</li>
      *   <li><b>金额核对只在 2/5</b>(与通知腿同一规则):不符只记错误码、不推进状态。</li>
      *   <li><b>状态真的迁移时 {@link #applyOrderState} 自己写 {@code next_query_time},
-     *       这里不得覆盖</b>;没有迁移才按当前 {@code queryCount} 退避续排。</li>
+     *       这里不得覆盖</b>;没有迁移才按退避续排。停止态查完一律保持 null。</li>
      * </ol>
      *
      * <p>上游异常直接抛出:调度器逐单兜住,手动同步由控制器转成失败应答。
      */
-    public void syncFromUpstream(UnifiedOrderEntity order) {
+    private void sync(UnifiedOrderEntity order, boolean manual) {
         if (order == null || order.getOrderNo() == null) {
             return;
         }
-        if (shouldStopPolling(order)) {
+        if (!manual && shouldStopPolling(order)) {
             scheduleNextQuery(order, null);
             return;
         }
@@ -676,7 +699,7 @@ public class UnifiedOrderService {
                 markAmountMismatch(order, upstreamAmountDetail(order, data));
             } else if (applyOrderState(order, state, UnifiedUpstreamSnapshot.of(data), null, null)) {
                 // applyOrderState 已按新状态写好 next_query_time(非终态 = 退避,终态 = null)。
-                // 唯一例外:最后一次查询恰好把订单推进到非终态 —— 策略已判定停止,必须置空。
+                // 唯一例外:本次查询已到停止条件(如第 60 次恰好推进到非终态)——必须置空。
                 if (UnifiedOrderStatus.PENDING.contains(order.getTradeStatus())
                         && shouldStopPolling(order)) {
                     scheduleNextQuery(order, null);
@@ -691,8 +714,8 @@ public class UnifiedOrderService {
         }
         if (UnifiedOrderStatus.PENDING.contains(order.getTradeStatus())) {
             int queryCount = order.getQueryCount() == null ? 0 : order.getQueryCount();
-            scheduleNextQuery(order, OffsetDateTime.now()
-                    .plusSeconds(UnifiedPollPolicy.nextDelaySeconds(queryCount)));
+            scheduleNextQuery(order,
+                    nextQueryTimeWithinWindow(order, OffsetDateTime.now(), queryCount));
         }
     }
 
@@ -709,6 +732,30 @@ public class UnifiedOrderService {
         return UnifiedPollPolicy.shouldStop(queryCount, createdAt, OffsetDateTime.now());
     }
 
+    /**
+     * 下一次自动查询时间(spec §7.2 的退避序列 + 30 分钟停止边界)。
+     *
+     * <p>退避索引取 {@code queryCount - 1}:三个调用点都在 {@code recordQueryAttempt}
+     * 之后,计数已含本次查询;spec 的 5s 首档对应「第 1 次查询之后」。
+     * 负索引由 {@link UnifiedPollPolicy#nextDelaySeconds(int)} 自行夹取。
+     *
+     * <p><b>边界</b>:扫描条件只取 {@code created_time > now-30min},因此排到
+     * {@code createdAt + 30min} 或更晚的时间永远不会被消费。必须在写入时就置 null,
+     * 否则该行的 {@code next_query_time} 会永远停在过去时刻,页面无法显示「轮询已停止」。
+     *
+     * <p>{@code createdTime} 缺失(数据损坏/单测构造)时不做边界判断:无法得知窗口起点。
+     */
+    private static OffsetDateTime nextQueryTimeWithinWindow(UnifiedOrderEntity order,
+                                                            OffsetDateTime now, int queryCount) {
+        OffsetDateTime next = now.plusSeconds(UnifiedPollPolicy.nextDelaySeconds(queryCount - 1));
+        OffsetDateTime createdAt = order.getCreatedTime();
+        if (createdAt != null
+                && !next.isBefore(createdAt.plusMinutes(UnifiedPollPolicy.MAX_AGE_MINUTES))) {
+            return null;
+        }
+        return next;
+    }
+
     /**
      * 轮询扫描(Task 11 调度器消费):非终态、到期、未超 30 分钟、未满 60 次,
      * 按到期时间升序,最多 {@code limit} 条。

+ 2 - 2
java/src/test/java/com/payment/platform/module/payment/unified/service/UnifiedOrderServiceTest.java

@@ -431,8 +431,8 @@ class UnifiedOrderServiceTest {
         assertEquals("0", result.getTradeStatus());
         assertEquals(1, result.getQueryCount(), "create 响应算一次查询,兜底调度不得再 +1");
         assertNotNull(result.getNextQueryTime(), "orderState=0 必须安排首次轮询(spec 177 的 WX_H5)");
-        assertTrue(result.getNextQueryTime().isAfter(OffsetDateTime.now().plusSeconds(8)),
-                "queryCount=1 应退避 10s(不是 5s),实测: " + result.getNextQueryTime());
+        assertTrue(result.getNextQueryTime().isAfter(OffsetDateTime.now().plusSeconds(3)),
+                "queryCount=1 应退避首档 5s(不是 10s),实测: " + result.getNextQueryTime());
         verifyNoInteractions(publisher, callbackService);
     }
 

+ 110 - 3
java/src/test/java/com/payment/platform/module/payment/unified/service/UnifiedOrderSyncTest.java

@@ -297,9 +297,9 @@ class UnifiedOrderSyncTest {
 
     @Test
     void eachSyncCountsOneQuery_andBackoffFollowsPolicy() {
-        // 服务在任何解析之前先 recordQueryAttempt:查询结束后的退避按「第 n 次查询之后」取值。
-        // 因此初始 queryCount=0..6 对应 nextDelaySeconds(1..7),即 10/20/30/60/120/300/300。
-        long[] expectedSeconds = {10, 20, 30, 60, 120, 300, 300};
+        // 服务在任何解析之前先 recordQueryAttempt:退避索引取「本次查询是第几次之前」,
+        // 即 nextDelaySeconds(queryCount - 1)。初始 queryCount=0..6 对应 5/10/20/30/60/120/300。
+        long[] expectedSeconds = {5, 10, 20, 30, 60, 120, 300};
         for (int initial = 0; initial <= 6; initial++) {
             when(merchantService.requireById(11L)).thenReturn(merchant());
             when(client.queryOrder(any(), eq("UP5"))).thenReturn(json("{\"state\":0}"));
@@ -374,6 +374,113 @@ class UnifiedOrderSyncTest {
         verifyNoInteractions(client, merchantService);
     }
 
+    // ──────────────────────── 30 分钟排期边界(I1)与手动同步(I2) ────────────────────────
+
+    @Test
+    void nearThirtyMinuteBoundary_withLongDelay_stopsPolling() {
+        when(merchantService.requireById(11L)).thenReturn(merchant());
+        when(client.queryOrder(any(), eq("UP5"))).thenReturn(json("{\"state\":0}"));
+        when(orderMapper.update(isNull(), any())).thenReturn(1);
+
+        UnifiedOrderEntity order = order("0");
+        // 29 分钟时退避已到 300s(本次是第 11 次查询):排期必越过「创建 + 30 分钟」。
+        order.setQueryCount(10);
+        order.setCreatedTime(OffsetDateTime.now().minusMinutes(UnifiedPollPolicy.MAX_AGE_MINUTES - 1));
+        service.syncFromUpstream(order);
+
+        assertEquals("0", order.getTradeStatus());
+        assertNull(order.getNextQueryTime(),
+                "排期越过 createdAt+30min 必须置空,页面才能显示「轮询已停止」");
+    }
+
+    @Test
+    void nearThirtyMinuteBoundary_withShortDelay_stillSchedules() {
+        when(merchantService.requireById(11L)).thenReturn(merchant());
+        when(client.queryOrder(any(), eq("UP5"))).thenReturn(json("{\"state\":0}"));
+        when(orderMapper.update(isNull(), any())).thenReturn(1);
+
+        UnifiedOrderEntity order = order("0");
+        order.setQueryCount(0);
+        order.setCreatedTime(OffsetDateTime.now().minusMinutes(UnifiedPollPolicy.MAX_AGE_MINUTES - 1));
+        service.syncFromUpstream(order);
+
+        assertScheduledIn(order, UnifiedPollPolicy.nextDelaySeconds(0));
+    }
+
+    @Test
+    void transitionToPayingAtTwentyNineFiftyFive_stopsPolling() {
+        when(merchantService.requireById(11L)).thenReturn(merchant());
+        when(client.queryOrder(any(), eq("UP5"))).thenReturn(json("{\"state\":1}"));
+        when(orderMapper.update(isNull(), any())).thenReturn(1);
+
+        UnifiedOrderEntity order = order("0");
+        // 29:55,首档 5s 后正好到达 createdAt+30min(含等号即越界)。
+        order.setCreatedTime(OffsetDateTime.now().minusSeconds(29 * 60 + 55));
+        service.syncFromUpstream(order);
+
+        assertEquals("1", order.getTradeStatus());
+        assertNull(order.getNextQueryTime(),
+                "0→1 恰好发生在 29:55 时,applyOrderState 排的 5s 也必须被 30 分钟边界压成 null");
+    }
+
+    @Test
+    void syncManually_queriesOverAgeOrderAndAppliesState() {
+        when(merchantService.requireById(11L)).thenReturn(merchant());
+        when(client.queryOrder(any(), eq("UP5"))).thenReturn(
+                json("{\"state\":2,\"amount\":100,\"currency\":\"cny\"}"));
+        when(orderMapper.update(isNull(), any())).thenReturn(1);
+
+        UnifiedOrderEntity order = order("0");
+        order.setCreatedTime(OffsetDateTime.now().minusMinutes(UnifiedPollPolicy.MAX_AGE_MINUTES + 1));
+        service.syncManually(order);
+
+        verify(client).queryOrder(any(), eq("UP5"));
+        assertEquals("2", order.getTradeStatus(), "超龄单手动同步仍必须能收敛状态");
+        assertNull(order.getNextQueryTime(), "手动同步不得重开自动轮询");
+        verify(publisher).changed(OrderEventPublisher.CHANNEL_UNIFIED_ORDER, TENANT);
+        verify(callbackService).notifyOrder(same(order), any());
+    }
+
+    @Test
+    void syncManually_atQueryLimitQueriesButDoesNotReopenPolling() {
+        when(merchantService.requireById(11L)).thenReturn(merchant());
+        when(client.queryOrder(any(), eq("UP5"))).thenReturn(json("{\"state\":0}"));
+        when(orderMapper.update(isNull(), any())).thenReturn(1);
+
+        UnifiedOrderEntity order = order("0");
+        order.setQueryCount(UnifiedPollPolicy.MAX_QUERY_COUNT);
+        service.syncManually(order);
+
+        verify(client).queryOrder(any(), eq("UP5"));
+        assertEquals(UnifiedPollPolicy.MAX_QUERY_COUNT + 1, order.getQueryCount(),
+                "手动同步本身也是一次查询");
+        assertNull(order.getNextQueryTime(), "停止态手动同步不得重开自动轮询");
+        verifyNoInteractions(publisher, callbackService);
+    }
+
+    @Test
+    void syncManually_whenNotStopped_schedulesNormally() {
+        when(merchantService.requireById(11L)).thenReturn(merchant());
+        when(client.queryOrder(any(), eq("UP5"))).thenReturn(json("{\"state\":0}"));
+        when(orderMapper.update(isNull(), any())).thenReturn(1);
+
+        UnifiedOrderEntity order = order("0");
+        service.syncManually(order);
+
+        assertScheduledIn(order, UnifiedPollPolicy.nextDelaySeconds(0));
+    }
+
+    @Test
+    void syncFromUpstream_atQueryLimit_skipsUpstream() {
+        UnifiedOrderEntity order = order("0");
+        order.setQueryCount(UnifiedPollPolicy.MAX_QUERY_COUNT);
+
+        service.syncFromUpstream(order);
+
+        verifyNoInteractions(client, merchantService);
+        assertNull(order.getNextQueryTime(), "自动入口到上限只清空排期");
+    }
+
     // ────────────────────────────── 轮询扫描 ──────────────────────────────
 
     @Test