|
|
@@ -0,0 +1,418 @@
|
|
|
+package com.payment.platform.module.payment.unified.service;
|
|
|
+
|
|
|
+import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
|
|
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
|
|
+import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
|
|
+import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
|
|
+import com.fasterxml.jackson.core.JsonProcessingException;
|
|
|
+import com.fasterxml.jackson.databind.JsonNode;
|
|
|
+import com.fasterxml.jackson.databind.ObjectMapper;
|
|
|
+import com.payment.platform.module.payment.stream.OrderEventPublisher;
|
|
|
+import com.payment.platform.module.payment.unified.client.UnifiedClient;
|
|
|
+import com.payment.platform.module.payment.unified.config.UnifiedPayProperties;
|
|
|
+import com.payment.platform.module.payment.unified.entity.UnifiedMerchantEntity;
|
|
|
+import com.payment.platform.module.payment.unified.entity.UnifiedOrderEntity;
|
|
|
+import com.payment.platform.module.payment.unified.mapper.UnifiedOrderMapper;
|
|
|
+import com.payment.platform.module.payment.unified.mapper.UnifiedRefundMapper;
|
|
|
+import com.payment.platform.module.payment.unified.util.UnifiedPollPolicy;
|
|
|
+import org.apache.ibatis.builder.MapperBuilderAssistant;
|
|
|
+import org.junit.jupiter.api.BeforeEach;
|
|
|
+import org.junit.jupiter.api.Test;
|
|
|
+import org.junit.jupiter.api.extension.ExtendWith;
|
|
|
+import org.mockito.ArgumentCaptor;
|
|
|
+import org.mockito.Mock;
|
|
|
+import org.mockito.junit.jupiter.MockitoExtension;
|
|
|
+
|
|
|
+import java.time.Duration;
|
|
|
+import java.time.OffsetDateTime;
|
|
|
+import java.util.List;
|
|
|
+import java.util.Set;
|
|
|
+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.assertNotNull;
|
|
|
+import static org.junit.jupiter.api.Assertions.assertNull;
|
|
|
+import static org.junit.jupiter.api.Assertions.assertTrue;
|
|
|
+import static org.mockito.ArgumentMatchers.any;
|
|
|
+import static org.mockito.ArgumentMatchers.eq;
|
|
|
+import static org.mockito.ArgumentMatchers.isNull;
|
|
|
+import static org.mockito.ArgumentMatchers.same;
|
|
|
+import static org.mockito.Mockito.atLeastOnce;
|
|
|
+import static org.mockito.Mockito.never;
|
|
|
+import static org.mockito.Mockito.times;
|
|
|
+import static org.mockito.Mockito.verify;
|
|
|
+import static org.mockito.Mockito.verifyNoInteractions;
|
|
|
+import static org.mockito.Mockito.when;
|
|
|
+
|
|
|
+/**
|
|
|
+ * 查单同步与轮询扫描(spec §7.2)。
|
|
|
+ *
|
|
|
+ * <p>本类盯的是「不会报错但会错钱 / 会让轮询静默停摆」的地方:
|
|
|
+ * <ol>
|
|
|
+ * <li>按 {@code merchant_id} 取配置(不看启停)—— 停用商户的历史单还要继续收敛,
|
|
|
+ * 用 {@code requireByMchNo} 或带启停校验会让存量单永远停在非终态;</li>
|
|
|
+ * <li>未知/缺失状态不得落库(R11)—— 一个 {@code state=99} 就能把订单推到不存在的状态;</li>
|
|
|
+ * <li>上游金额不符仍标成功 —— 少付的单被标成已支付;</li>
|
|
|
+ * <li>状态未迁移时把 {@code next_query_time} 清空或重复计数(R38)——
|
|
|
+ * 清空 = 该单永远不再被 {@code findPendingForPoll} 捞到;</li>
|
|
|
+ * <li>超过 30 分钟 / 60 次仍打上游 —— 违反轮询停止条件。</li>
|
|
|
+ * </ol>
|
|
|
+ */
|
|
|
+@ExtendWith(MockitoExtension.class)
|
|
|
+class UnifiedOrderSyncTest {
|
|
|
+
|
|
|
+ private static final ObjectMapper JSON = new ObjectMapper();
|
|
|
+ private static final Long TENANT = 2L;
|
|
|
+
|
|
|
+ @Mock private UnifiedOrderMapper orderMapper;
|
|
|
+ @Mock private UnifiedRefundMapper refundMapper;
|
|
|
+ @Mock private UnifiedClient client;
|
|
|
+ @Mock private UnifiedMerchantService merchantService;
|
|
|
+ @Mock private UnifiedCallbackService callbackService;
|
|
|
+ @Mock private OrderEventPublisher publisher;
|
|
|
+
|
|
|
+ private UnifiedOrderService service;
|
|
|
+
|
|
|
+ @BeforeEach
|
|
|
+ void setUp() {
|
|
|
+ service = new UnifiedOrderService(orderMapper, refundMapper, client,
|
|
|
+ merchantService, callbackService, publisher, new UnifiedPayProperties());
|
|
|
+ // 初始化 MyBatis-Plus lambda 元数据缓存,使 getSqlSegment()/getSqlSet() 在无 Spring 上下文的单测中工作
|
|
|
+ MybatisConfiguration configuration = new MybatisConfiguration();
|
|
|
+ MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, "");
|
|
|
+ TableInfoHelper.initTableInfo(assistant, UnifiedOrderEntity.class);
|
|
|
+ }
|
|
|
+
|
|
|
+ // ────────────────────────────── 工具 ──────────────────────────────
|
|
|
+
|
|
|
+ private static JsonNode json(String text) {
|
|
|
+ try {
|
|
|
+ return JSON.readTree(text);
|
|
|
+ } catch (JsonProcessingException e) {
|
|
|
+ throw new IllegalStateException("测试 JSON 写错了", e);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private static UnifiedMerchantEntity merchant() {
|
|
|
+ UnifiedMerchantEntity cfg = new UnifiedMerchantEntity();
|
|
|
+ cfg.setId(11L);
|
|
|
+ cfg.setTenantId(TENANT);
|
|
|
+ cfg.setMchNo("M001");
|
|
|
+ cfg.setAppId("app-1");
|
|
|
+ cfg.setAppSecret("SECRET");
|
|
|
+ cfg.setStatus(UnifiedMerchantService.STATUS_ENABLED);
|
|
|
+ return cfg;
|
|
|
+ }
|
|
|
+
|
|
|
+ private static UnifiedOrderEntity order(String tradeStatus) {
|
|
|
+ UnifiedOrderEntity o = new UnifiedOrderEntity();
|
|
|
+ o.setId(5L);
|
|
|
+ o.setTenantId(TENANT);
|
|
|
+ o.setMerchantId(11L);
|
|
|
+ o.setMchNo("M001");
|
|
|
+ o.setAppId("app-1");
|
|
|
+ o.setOrderNo("UP5");
|
|
|
+ o.setOutTradeNo("T001");
|
|
|
+ o.setApiKey("KEY-1");
|
|
|
+ o.setSource("OPENAPI");
|
|
|
+ o.setWayCode("QR_CASHIER");
|
|
|
+ o.setAmount(100L);
|
|
|
+ o.setRefundedAmount(0L);
|
|
|
+ o.setCurrency("cny");
|
|
|
+ o.setSubject("测试商品");
|
|
|
+ o.setTradeStatus(tradeStatus);
|
|
|
+ o.setQueryCount(0);
|
|
|
+ o.setCreatedTime(OffsetDateTime.now());
|
|
|
+ return o;
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 断言安排在下一次查询的时间 ≈ now + {@code expectedSeconds}(服务内部取的 now 略早于此刻)。 */
|
|
|
+ private static void assertScheduledIn(UnifiedOrderEntity order, long expectedSeconds) {
|
|
|
+ assertNotNull(order.getNextQueryTime(), "非终态订单必须安排下一次查询");
|
|
|
+ long actual = Duration.between(OffsetDateTime.now(), order.getNextQueryTime()).toSeconds();
|
|
|
+ assertTrue(actual >= expectedSeconds - 1 && actual <= expectedSeconds + 2,
|
|
|
+ "期望约 " + expectedSeconds + "s 后查询,实测 " + actual + "s");
|
|
|
+ }
|
|
|
+
|
|
|
+ @SuppressWarnings("unchecked")
|
|
|
+ private static ArgumentCaptor<LambdaQueryWrapper<UnifiedOrderEntity>> queryCaptor() {
|
|
|
+ return ArgumentCaptor.forClass(LambdaQueryWrapper.class);
|
|
|
+ }
|
|
|
+
|
|
|
+ @SuppressWarnings("unchecked")
|
|
|
+ private static ArgumentCaptor<LambdaUpdateWrapper<UnifiedOrderEntity>> updateCaptor() {
|
|
|
+ return ArgumentCaptor.forClass(LambdaUpdateWrapper.class);
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 只取 {@code trade_status IN (...)} 谓词引用到的绑定值(同款口径见 UnifiedOrderServiceTest)。 */
|
|
|
+ private static Set<String> inValues(LambdaQueryWrapper<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
|
|
|
+ void sync_usesMerchantIdConfigAndIgnoresStatus() {
|
|
|
+ UnifiedMerchantEntity cfg = merchant();
|
|
|
+ cfg.setStatus("1"); // 停用:历史单还要继续收敛,查单腿不看 status
|
|
|
+ when(merchantService.requireById(11L)).thenReturn(cfg);
|
|
|
+ when(client.queryOrder(same(cfg), eq("UP5"))).thenReturn(json("{\"state\":0}"));
|
|
|
+
|
|
|
+ UnifiedOrderEntity order = order("0");
|
|
|
+ service.syncFromUpstream(order);
|
|
|
+
|
|
|
+ verify(merchantService).requireById(11L);
|
|
|
+ verify(merchantService, never()).requireByMchNo(any());
|
|
|
+ verify(merchantService, never()).resolveForTenant(any(), any());
|
|
|
+ verify(merchantService, never()).requireEnabled(any());
|
|
|
+ verify(client).queryOrder(same(cfg), eq("UP5"));
|
|
|
+ assertEquals("0", order.getTradeStatus());
|
|
|
+ }
|
|
|
+
|
|
|
+ // ────────────────────────────── 状态收敛 ──────────────────────────────
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void stateTwoWithMatchingAmount_appliesPublishesAndNotifies() {
|
|
|
+ when(merchantService.requireById(11L)).thenReturn(merchant());
|
|
|
+ // openid 属于 channelExtra 类敏感字段:只允许进白名单投影,绝不转发下游
|
|
|
+ when(client.queryOrder(any(), eq("UP5"))).thenReturn(json(
|
|
|
+ "{\"payOrderId\":\"P1\",\"state\":2,\"amount\":100,\"currency\":\"cny\","
|
|
|
+ + "\"successTime\":1760000000000,\"openid\":\"SECRET-OPENID\"}"));
|
|
|
+ when(orderMapper.update(isNull(), any())).thenReturn(1);
|
|
|
+
|
|
|
+ UnifiedOrderEntity order = order("0");
|
|
|
+ service.syncFromUpstream(order);
|
|
|
+
|
|
|
+ assertEquals("2", order.getTradeStatus());
|
|
|
+ assertNotNull(order.getPayTime());
|
|
|
+ assertNull(order.getNextQueryTime(), "终态不再轮询");
|
|
|
+ assertEquals(1, order.getQueryCount(), "上游响应算一次查询");
|
|
|
+
|
|
|
+ verify(publisher).changed(OrderEventPublisher.CHANNEL_UNIFIED_ORDER, TENANT);
|
|
|
+ ArgumentCaptor<JsonNode> upstream = ArgumentCaptor.forClass(JsonNode.class);
|
|
|
+ verify(callbackService).notifyOrder(same(order), upstream.capture());
|
|
|
+ assertEquals("P1", upstream.getValue().get("payOrderId").asText());
|
|
|
+ assertNull(upstream.getValue().get("openid"),
|
|
|
+ "敏感字段不得进入下游回调的 upstream 投影");
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void amountMismatch_doesNotAdvance() {
|
|
|
+ JsonNode[] responses = {
|
|
|
+ json("{\"state\":2,\"amount\":99,\"currency\":\"cny\"}"),
|
|
|
+ json("{\"state\":2,\"amount\":0,\"currency\":\"cny\"}"),
|
|
|
+ json("{\"state\":2,\"amount\":-1,\"currency\":\"cny\"}"),
|
|
|
+ json("{\"state\":2,\"currency\":\"cny\"}")
|
|
|
+ };
|
|
|
+ for (JsonNode data : responses) {
|
|
|
+ when(merchantService.requireById(11L)).thenReturn(merchant());
|
|
|
+ when(client.queryOrder(any(), eq("UP5"))).thenReturn(data);
|
|
|
+ when(orderMapper.update(isNull(), any())).thenReturn(1);
|
|
|
+
|
|
|
+ UnifiedOrderEntity order = order("0");
|
|
|
+ service.syncFromUpstream(order);
|
|
|
+
|
|
|
+ assertEquals("0", order.getTradeStatus(), "金额不符不得推进状态: " + data);
|
|
|
+ assertEquals("AMOUNT_MISMATCH", order.getErrCode(), "实测: " + data);
|
|
|
+ assertTrue(order.getErrMsg().contains("本地=100"), "实测: " + order.getErrMsg());
|
|
|
+ assertEquals(1, order.getQueryCount(), "金额不符也只计一次查询");
|
|
|
+ assertNotNull(order.getNextQueryTime(), "金额不符仍留在轮询主路径");
|
|
|
+ verifyNoInteractions(publisher, callbackService);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void unknownState_doesNotAdvance() {
|
|
|
+ when(merchantService.requireById(11L)).thenReturn(merchant());
|
|
|
+ when(client.queryOrder(any(), eq("UP5"))).thenReturn(json("{\"state\":99}"));
|
|
|
+ when(orderMapper.update(isNull(), any())).thenReturn(1);
|
|
|
+
|
|
|
+ UnifiedOrderEntity order = order("0");
|
|
|
+ service.syncFromUpstream(order);
|
|
|
+
|
|
|
+ assertEquals("0", order.getTradeStatus(), "R11:未知状态不得落库");
|
|
|
+ assertEquals(1, order.getQueryCount(), "未知状态也算一次查询");
|
|
|
+ assertNotNull(order.getNextQueryTime(), "未知状态仍要安排下一次查询");
|
|
|
+ verifyNoInteractions(publisher, callbackService);
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void terminalState_isNotOverwrittenAndNotRescheduled() {
|
|
|
+ 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("3");
|
|
|
+ service.syncFromUpstream(order);
|
|
|
+
|
|
|
+ assertEquals("3", order.getTradeStatus(), "终态不得被上游旧状态覆盖");
|
|
|
+ assertNull(order.getNextQueryTime(), "终态不排下一次查询");
|
|
|
+ verifyNoInteractions(publisher, callbackService);
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void conditionalUpdateMiss_doesNotPublish() {
|
|
|
+ when(merchantService.requireById(11L)).thenReturn(merchant());
|
|
|
+ when(client.queryOrder(any(), eq("UP5"))).thenReturn(
|
|
|
+ json("{\"state\":2,\"amount\":100,\"currency\":\"cny\"}"));
|
|
|
+ // 并发下另一条腿(通知/另一次查单)已经写过:条件 UPDATE 落空
|
|
|
+ when(orderMapper.update(isNull(), any())).thenReturn(0);
|
|
|
+
|
|
|
+ UnifiedOrderEntity order = order("0");
|
|
|
+ service.syncFromUpstream(order);
|
|
|
+
|
|
|
+ assertEquals("0", order.getTradeStatus(), "0 行时内存也不能提前改状态");
|
|
|
+ assertNotNull(order.getNextQueryTime(), "未迁移仍要保留轮询主路径");
|
|
|
+ verifyNoInteractions(publisher, callbackService);
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void generatedToPaying_doesNotPublish() {
|
|
|
+ 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");
|
|
|
+ service.syncFromUpstream(order);
|
|
|
+
|
|
|
+ assertEquals("1", order.getTradeStatus());
|
|
|
+ assertNotNull(order.getNextQueryTime(), "非终态继续轮询");
|
|
|
+ verifyNoInteractions(publisher, callbackService);
|
|
|
+ }
|
|
|
+
|
|
|
+ // ────────────────────────────── 计数与退避 ──────────────────────────────
|
|
|
+
|
|
|
+ @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};
|
|
|
+ for (int initial = 0; initial <= 6; initial++) {
|
|
|
+ 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(initial);
|
|
|
+ service.syncFromUpstream(order);
|
|
|
+
|
|
|
+ assertEquals(initial + 1, order.getQueryCount(),
|
|
|
+ "每次同步必须 +1(初始 " + initial + ")");
|
|
|
+ assertScheduledIn(order, expectedSeconds[initial]);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void recordQueryAttempt_usesAtomicSqlIncrement() {
|
|
|
+ when(merchantService.requireById(11L)).thenReturn(merchant());
|
|
|
+ when(client.queryOrder(any(), eq("UP5"))).thenReturn(json("{\"state\":0}"));
|
|
|
+
|
|
|
+ service.syncFromUpstream(order("0"));
|
|
|
+
|
|
|
+ ArgumentCaptor<LambdaUpdateWrapper<UnifiedOrderEntity>> w = updateCaptor();
|
|
|
+ verify(orderMapper, atLeastOnce()).update(isNull(), w.capture());
|
|
|
+ String set = w.getAllValues().stream()
|
|
|
+ .map(LambdaUpdateWrapper::getSqlSet)
|
|
|
+ .filter(s -> s != null && s.replace(" ", "").contains("query_count=query_count+1"))
|
|
|
+ .findFirst()
|
|
|
+ .orElseThrow(() -> new AssertionError("没有找到 SQL 侧自增的 query_count 写: "
|
|
|
+ + w.getAllValues().stream().map(LambdaUpdateWrapper::getSqlSet).toList()));
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void queryCountAtLimit_clearsNextQueryTimeAfterFinalQuery() {
|
|
|
+ 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 - 1);
|
|
|
+ service.syncFromUpstream(order);
|
|
|
+
|
|
|
+ assertEquals(UnifiedPollPolicy.MAX_QUERY_COUNT, order.getQueryCount());
|
|
|
+ assertNull(order.getNextQueryTime(), "达到 60 次上限后置空 next_query_time");
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void finalQueryThatTransitionsToPaying_stillClearsNextQueryTime() {
|
|
|
+ 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");
|
|
|
+ order.setQueryCount(UnifiedPollPolicy.MAX_QUERY_COUNT - 1);
|
|
|
+ service.syncFromUpstream(order);
|
|
|
+
|
|
|
+ assertEquals("1", order.getTradeStatus());
|
|
|
+ assertEquals(UnifiedPollPolicy.MAX_QUERY_COUNT, order.getQueryCount());
|
|
|
+ assertNull(order.getNextQueryTime(),
|
|
|
+ "第 60 次查询即使推进到非终态也必须停止轮询(applyOrderState 的排期要被停止条件覆盖)");
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void createdBeforeThirtyMinutes_skipsUpstreamAndClearsSchedule() {
|
|
|
+ UnifiedOrderEntity order = order("0");
|
|
|
+ order.setCreatedTime(OffsetDateTime.now().minusMinutes(UnifiedPollPolicy.MAX_AGE_MINUTES + 1));
|
|
|
+ order.setNextQueryTime(OffsetDateTime.now().minusSeconds(5));
|
|
|
+
|
|
|
+ service.syncFromUpstream(order);
|
|
|
+
|
|
|
+ assertNull(order.getNextQueryTime(), "超过 30 分钟置空 next_query_time");
|
|
|
+ verifyNoInteractions(client, merchantService);
|
|
|
+ }
|
|
|
+
|
|
|
+ // ────────────────────────────── 轮询扫描 ──────────────────────────────
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void findPendingForPoll_filtersDuePendingRows() {
|
|
|
+ when(orderMapper.selectList(any())).thenReturn(List.of());
|
|
|
+
|
|
|
+ service.findPendingForPoll(50);
|
|
|
+
|
|
|
+ ArgumentCaptor<LambdaQueryWrapper<UnifiedOrderEntity>> w = queryCaptor();
|
|
|
+ verify(orderMapper).selectList(w.capture());
|
|
|
+ String sql = w.getValue().getSqlSegment();
|
|
|
+ for (String fragment : List.of("trade_status IN", "next_query_time <=",
|
|
|
+ "created_time >", "query_count <", "ORDER BY next_query_time ASC", "LIMIT 50")) {
|
|
|
+ assertTrue(sql.contains(fragment), "轮询查询缺片段 " + fragment + ",实测: " + sql);
|
|
|
+ }
|
|
|
+ assertEquals(Set.of("0", "1"), inValues(w.getValue()),
|
|
|
+ "只扫非终态 GENERATED/PAYING,实测: " + sql);
|
|
|
+
|
|
|
+ List<Object> params = List.copyOf(w.getValue().getParamNameValuePairs().values());
|
|
|
+ assertTrue(params.contains(UnifiedPollPolicy.MAX_QUERY_COUNT),
|
|
|
+ "query_count 上限必须是 60,实测参数: " + params);
|
|
|
+ assertTrue(params.stream().anyMatch(v -> v instanceof OffsetDateTime t
|
|
|
+ && Duration.between(t, OffsetDateTime.now().minusMinutes(
|
|
|
+ UnifiedPollPolicy.MAX_AGE_MINUTES)).abs().getSeconds() < 5),
|
|
|
+ "created_time 下界必须是 now-30min,实测参数: " + params);
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void findPendingForPoll_clampsLimit() {
|
|
|
+ when(orderMapper.selectList(any())).thenReturn(List.of());
|
|
|
+
|
|
|
+ service.findPendingForPoll(0);
|
|
|
+ service.findPendingForPoll(9999);
|
|
|
+
|
|
|
+ ArgumentCaptor<LambdaQueryWrapper<UnifiedOrderEntity>> w = queryCaptor();
|
|
|
+ verify(orderMapper, times(2)).selectList(w.capture());
|
|
|
+ String lower = w.getAllValues().get(0).getSqlSegment();
|
|
|
+ String upper = w.getAllValues().get(1).getSqlSegment();
|
|
|
+ assertTrue(lower.contains("LIMIT 1"), "limit=0 必须夹到 1,实测: " + lower);
|
|
|
+ assertTrue(upper.contains("LIMIT 500"), "limit 必须封顶 500,实测: " + upper);
|
|
|
+ }
|
|
|
+}
|