Selaa lähdekoodia

fix(统一支付): 创建未迁移时补排首次轮询并钉住前态集合

- C1:orderState=0 / state 缺失或未知时 applyOrderState 不写 next_query_time,
  订单永远进不了 findPendingForPoll;创建末尾对仍为 PENDING 且未排期的单兜底
  调度一次(不增加 query_count),金额不符分支同样兜底
- I1:补 R29 第三分支(重查为空再抛 DuplicateKeyException)测试
- I2:从 wrapper 的 paramNameValuePairs 断言 next=1/2/5 的前态集合 {0}/{0,1}/{2}
- I3:列表日期边界固定 Asia/Shanghai,不跟随 JVM 默认时区
alphaH 2 viikkoa sitten
vanhempi
sitoutus
c605ee0853

+ 27 - 1
java/src/main/java/com/payment/platform/module/payment/unified/service/UnifiedOrderService.java

@@ -85,6 +85,9 @@ public class UnifiedOrderService {
 
     private static final String CURRENCY_CNY = "cny";
 
+    /** 平台统一时区:日期边界不跟随部署 JVM 的默认时区(同 AutoFillMetaObjectHandler)。 */
+    private static final ZoneId SHANGHAI = ZoneId.of("Asia/Shanghai");
+
     private final UnifiedOrderMapper orderMapper;
     private final UnifiedRefundMapper refundMapper;
     private final UnifiedClient client;
@@ -163,15 +166,38 @@ public class UnifiedOrderService {
                     || UnifiedOrderStatus.REFUNDED.equals(state)) {
                 if (!amountMatches(entity, data)) {
                     markAmountMismatch(entity, upstreamAmountDetail(entity, data));
+                    // 金额不符的单也不许留在「永远不会被扫描」的 NULL 状态
+                    ensurePollScheduled(entity);
                     throw new BusinessException(500, "AMOUNT_MISMATCH");
                 }
             }
             applyOrderState(entity, state, upstream, null, null);
         }
 
+        // C1:orderState=0 / state 缺失或未知时没有合法迁移,applyOrderState 不会写
+        // next_query_time —— 这里兜底排第一次,否则该单永远进不了 findPendingForPoll。
+        ensurePollScheduled(entity);
+
         return reload(entity);
     }
 
+    /**
+     * 仍为非终态且尚未排期的订单,按当前 {@code queryCount} 兜底安排下一次查询。
+     *
+     * <p>不增加 {@code queryCount}:创建响应本身已经由 {@link #recordQueryAttempt} 计过一次,
+     * 兜底只负责写 {@code next_query_time}。
+     */
+    private void ensurePollScheduled(UnifiedOrderEntity order) {
+        if (order == null
+                || !UnifiedOrderStatus.PENDING.contains(order.getTradeStatus())
+                || order.getNextQueryTime() != null) {
+            return;
+        }
+        int queryCount = order.getQueryCount() == null ? 0 : order.getQueryCount();
+        scheduleNextQuery(order, OffsetDateTime.now()
+                .plusSeconds(UnifiedPollPolicy.nextDelaySeconds(queryCount)));
+    }
+
     private static void validateCreate(UnifiedOrderCreateCommand cmd, UnifiedSource source) {
         if (cmd == null) {
             throw new BusinessException(400, "请求不能为空");
@@ -517,7 +543,7 @@ public class UnifiedOrderService {
 
     private static OffsetDateTime startOfDay(String value) {
         try {
-            return LocalDate.parse(value.trim()).atStartOfDay(ZoneId.systemDefault())
+            return LocalDate.parse(value.trim()).atStartOfDay(SHANGHAI)
                     .toOffsetDateTime();
         } catch (DateTimeParseException e) {
             throw new BusinessException(400, "日期格式应为 yyyy-MM-dd: " + value);

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

@@ -37,6 +37,11 @@ import org.springframework.dao.DuplicateKeyException;
 import java.time.OffsetDateTime;
 import java.util.List;
 import java.util.Map;
+import java.util.Set;
+import java.util.TimeZone;
+import java.util.TreeSet;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertFalse;
@@ -165,6 +170,25 @@ class UnifiedOrderServiceTest {
         return ArgumentCaptor.forClass(LambdaUpdateWrapper.class);
     }
 
+    /**
+     * 只取 {@code trade_status IN (...)} 谓词**引用到**的绑定值,不倒
+     * {@code paramNameValuePairs} 全集 —— 后者会把 SET 子句绑的值一并算进来。
+     * (同款口径见 {@code LsySchedulerTest.expireStale_alreadySettled_doesNotOverwriteOrNotify}。)
+     */
+    private static Set<String> inValues(LambdaUpdateWrapper<UnifiedOrderEntity> w) {
+        Matcher inPredicate = Pattern.compile("trade_status IN \\(([^)]*)\\)")
+                .matcher(w.getSqlSegment());
+        assertTrue(inPredicate.find(), "WHERE 里应有 trade_status IN (...) 谓词,实测: "
+                + w.getSqlSegment());
+        Matcher placeholder = Pattern.compile("paramNameValuePairs\\.([A-Za-z0-9_]+)")
+                .matcher(inPredicate.group(1));
+        Set<String> values = new TreeSet<>();
+        while (placeholder.find()) {
+            values.add(String.valueOf(w.getParamNameValuePairs().get(placeholder.group(1))));
+        }
+        return values;
+    }
+
     // ────────────────────────────── 创建前置闸门 ──────────────────────────────
 
     @Test
@@ -280,6 +304,20 @@ class UnifiedOrderServiceTest {
         verifyNoInteractions(client);
     }
 
+    @Test
+    void duplicateInsertThenNotFound_rethrowsOriginalException() {
+        // R29 第三支:撞键后按幂等键重查为空 ⇒ 原异常照抛,不得吞掉/返回 null
+        when(orderMapper.selectOne(any())).thenReturn(null);
+        stubMerchant();
+        when(orderMapper.insert(any())).thenThrow(
+                new DuplicateKeyException("uk_unified_order_tenant_out_trade_no"));
+
+        assertThrows(DuplicateKeyException.class,
+                () -> service.create(command(), TENANT, "KEY-1", UnifiedSource.OPENAPI));
+
+        verifyNoInteractions(client);
+    }
+
     // ────────────────────────────── 创建主流程 ──────────────────────────────
 
     @Test
@@ -375,6 +413,40 @@ class UnifiedOrderServiceTest {
 
         assertEquals("0", result.getTradeStatus());
         assertEquals("P1", result.getUpstreamPayOrderId(), "回填仍应发生,只是状态不推进");
+        // C1:未知状态不迁移,但必须进入主动轮询主路径
+        assertNotNull(result.getNextQueryTime(), "未知状态也必须安排首次轮询");
+        assertEquals(1, result.getQueryCount(), "创建响应只计一次查询");
+        verifyNoInteractions(publisher, callbackService);
+    }
+
+    @Test
+    void orderStateZero_schedulesFirstPoll() {
+        stubMerchant();
+        when(client.unifiedOrder(any(), any())).thenReturn(
+                json("{\"payOrderId\":\"P1\",\"orderState\":0}"));
+        when(orderMapper.update(isNull(), any())).thenReturn(1);
+
+        UnifiedOrderEntity result = service.create(command(), TENANT, "KEY-1", UnifiedSource.OPENAPI);
+
+        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());
+        verifyNoInteractions(publisher, callbackService);
+    }
+
+    @Test
+    void missingState_schedulesFirstPoll() {
+        stubMerchant();
+        when(client.unifiedOrder(any(), any())).thenReturn(json("{\"payOrderId\":\"P1\"}"));
+        when(orderMapper.update(isNull(), any())).thenReturn(1);
+
+        UnifiedOrderEntity result = service.create(command(), TENANT, "KEY-1", UnifiedSource.OPENAPI);
+
+        assertEquals("0", result.getTradeStatus());
+        assertEquals(1, result.getQueryCount(), "创建响应只计一次查询");
+        assertNotNull(result.getNextQueryTime(), "state 缺失也必须安排首次轮询");
         verifyNoInteractions(publisher, callbackService);
     }
 
@@ -402,6 +474,8 @@ class UnifiedOrderServiceTest {
             assertEquals("0", order.getTradeStatus(), "金额不符时状态不能推进");
             assertEquals("AMOUNT_MISMATCH", order.getErrCode());
             assertNotNull(order.getErrMsg());
+            assertEquals(1, order.getQueryCount(), "金额不符也只计一次查询");
+            assertNotNull(order.getNextQueryTime(), "金额不符分支同样要进入轮询主路径");
         }
         verifyNoInteractions(publisher, callbackService);
     }
@@ -524,6 +598,22 @@ class UnifiedOrderServiceTest {
         verifyNoInteractions(publisher, callbackService);
     }
 
+    @Test
+    void applyOrderState_previousStateSetsArePinned() {
+        when(orderMapper.update(isNull(), any())).thenReturn(1);
+
+        assertTrue(service.applyOrderState(existing("0"), "1", null, null, null));
+        assertTrue(service.applyOrderState(existing("0"), "2", null, null, null));
+        assertTrue(service.applyOrderState(existing("2"), "5", null, null, null));
+
+        ArgumentCaptor<LambdaUpdateWrapper<UnifiedOrderEntity>> w = updateCaptor();
+        verify(orderMapper, times(3)).update(isNull(), w.capture());
+        List<LambdaUpdateWrapper<UnifiedOrderEntity>> wrappers = w.getAllValues();
+        assertEquals(Set.of("0"), inValues(wrappers.get(0)), "next=1 的前态只能是 0");
+        assertEquals(Set.of("0", "1"), inValues(wrappers.get(1)), "next=2 的前态是 0/1");
+        assertEquals(Set.of("2"), inValues(wrappers.get(2)), "next=5 的前态只能是 2");
+    }
+
     // ────────────────────────────── 查询与列表 ──────────────────────────────
 
     @Test
@@ -579,6 +669,40 @@ class UnifiedOrderServiceTest {
         }
     }
 
+    @Test
+    void list_dateBoundary_isShanghaiRegardlessOfJvmZone() {
+        Page<UnifiedOrderEntity> page = new Page<>(1, 20);
+        page.setRecords(List.of());
+        page.setTotal(0);
+        doReturn(page).when(orderMapper).selectPage(any(), any());
+
+        UnifiedOrderQueryDTO q = new UnifiedOrderQueryDTO();
+        q.setStartDate("2026-10-01");
+        q.setEndDate("2026-10-20");
+
+        // 部署 JVM 若是 UTC,日期边界也必须按上海口径算(否则当天 00:00–08:00 的单会漏掉)
+        TimeZone original = TimeZone.getDefault();
+        try {
+            TimeZone.setDefault(TimeZone.getTimeZone("UTC"));
+            service.list(q);
+        } finally {
+            TimeZone.setDefault(original);
+        }
+
+        ArgumentCaptor<LambdaQueryWrapper<UnifiedOrderEntity>> w = queryCaptor();
+        verify(orderMapper).selectPage(any(), w.capture());
+        assertTrue(w.getValue().getSqlSegment().contains("created_time"),
+                "日期条件应落在 created_time,实测: " + w.getValue().getSqlSegment());
+        List<OffsetDateTime> times = w.getValue().getParamNameValuePairs().values().stream()
+                .filter(OffsetDateTime.class::isInstance)
+                .map(OffsetDateTime.class::cast)
+                .toList();
+        assertTrue(times.contains(OffsetDateTime.parse("2026-10-01T00:00+08:00")),
+                "startDate 必须按 Asia/Shanghai 起算,实测: " + times);
+        assertTrue(times.contains(OffsetDateTime.parse("2026-10-21T00:00+08:00")),
+                "endDate 含当天(+1 天 0 点,Asia/Shanghai),实测: " + times);
+    }
+
     @Test
     void findPendingForPoll_filtersAndClampsLimit() {
         when(orderMapper.selectList(any())).thenReturn(List.of(existing("0")));