|
@@ -37,6 +37,11 @@ import org.springframework.dao.DuplicateKeyException;
|
|
|
import java.time.OffsetDateTime;
|
|
import java.time.OffsetDateTime;
|
|
|
import java.util.List;
|
|
import java.util.List;
|
|
|
import java.util.Map;
|
|
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.assertEquals;
|
|
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
|
@@ -165,6 +170,25 @@ class UnifiedOrderServiceTest {
|
|
|
return ArgumentCaptor.forClass(LambdaUpdateWrapper.class);
|
|
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
|
|
@Test
|
|
@@ -280,6 +304,20 @@ class UnifiedOrderServiceTest {
|
|
|
verifyNoInteractions(client);
|
|
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
|
|
@Test
|
|
@@ -375,6 +413,40 @@ class UnifiedOrderServiceTest {
|
|
|
|
|
|
|
|
assertEquals("0", result.getTradeStatus());
|
|
assertEquals("0", result.getTradeStatus());
|
|
|
assertEquals("P1", result.getUpstreamPayOrderId(), "回填仍应发生,只是状态不推进");
|
|
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);
|
|
verifyNoInteractions(publisher, callbackService);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -402,6 +474,8 @@ class UnifiedOrderServiceTest {
|
|
|
assertEquals("0", order.getTradeStatus(), "金额不符时状态不能推进");
|
|
assertEquals("0", order.getTradeStatus(), "金额不符时状态不能推进");
|
|
|
assertEquals("AMOUNT_MISMATCH", order.getErrCode());
|
|
assertEquals("AMOUNT_MISMATCH", order.getErrCode());
|
|
|
assertNotNull(order.getErrMsg());
|
|
assertNotNull(order.getErrMsg());
|
|
|
|
|
+ assertEquals(1, order.getQueryCount(), "金额不符也只计一次查询");
|
|
|
|
|
+ assertNotNull(order.getNextQueryTime(), "金额不符分支同样要进入轮询主路径");
|
|
|
}
|
|
}
|
|
|
verifyNoInteractions(publisher, callbackService);
|
|
verifyNoInteractions(publisher, callbackService);
|
|
|
}
|
|
}
|
|
@@ -524,6 +598,22 @@ class UnifiedOrderServiceTest {
|
|
|
verifyNoInteractions(publisher, callbackService);
|
|
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
|
|
@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
|
|
@Test
|
|
|
void findPendingForPoll_filtersAndClampsLimit() {
|
|
void findPendingForPoll_filtersAndClampsLimit() {
|
|
|
when(orderMapper.selectList(any())).thenReturn(List.of(existing("0")));
|
|
when(orderMapper.selectList(any())).thenReturn(List.of(existing("0")));
|