package com.ruoyi.app.unifiedorder; import com.baomidou.mybatisplus.core.MybatisConfiguration; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.ruoyi.app.unifiedorder.dto.UnifiedOrderListItem; import com.ruoyi.app.unifiedorder.dto.UnifiedOrderPageView; import com.ruoyi.system.domain.PosOrder; import com.ruoyi.system.domain.PosStore; import com.ruoyi.system.domain.flash.FlashDeliveryOrder; import com.ruoyi.system.domain.flash.FlashDeliveryStatus; import com.ruoyi.system.mapper.PosOrderMapper; import com.ruoyi.system.mapper.PosStoreMapper; import com.ruoyi.system.mapper.flash.FlashDeliveryOrderMapper; import org.apache.ibatis.builder.MapperBuilderAssistant; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; import java.util.ArrayList; import java.util.Date; import java.util.List; import java.util.Set; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; 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.anyCollection; import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; /** 统一订单列表服务:两路查询、归并排序、翻页完整性、hasMore、tab 跳过闪送路。 */ class UnifiedOrderQueryServiceTest { private static final Long USER_ID = 9L; private final PosOrderMapper posOrderMapper = mock(PosOrderMapper.class); private final FlashDeliveryOrderMapper flashOrderMapper = mock(FlashDeliveryOrderMapper.class); private final PosStoreMapper posStoreMapper = mock(PosStoreMapper.class); private final UnifiedOrderQueryService service = new UnifiedOrderQueryService(posOrderMapper, flashOrderMapper, posStoreMapper); @BeforeAll static void initializeTableMetadata() { TableInfoHelper.initTableInfo( new MapperBuilderAssistant(new MybatisConfiguration(), ""), PosOrder.class); TableInfoHelper.initTableInfo( new MapperBuilderAssistant(new MybatisConfiguration(), ""), FlashDeliveryOrder.class); } @Test void twoPathsMergeInDisplayOrderAndReturnExactlySize() { doReturn(pageOf(List.of( takeaway(21L, 3000L), takeaway(22L, 2000L), takeaway(23L, 1000L)))) .when(posOrderMapper).selectPage(any(), any()); doReturn(pageOf(List.of( flash(31L, 3000L), flash(32L, 2500L)))) .when(flashOrderMapper).selectPage(any(), any()); UnifiedOrderPageView view = service.query(USER_ID, UnifiedOrderTab.ALL, null, null, 3); assertEquals(3, view.getList().size()); // 同秒并列:flash 排在 takeaway 前;整体时间非递增 assertEquals(List.of("flash", "takeaway", "flash"), view.getList().stream().map(UnifiedOrderListItem::getSourceType).toList()); assertEquals(List.of(31L, 21L, 32L), view.getList().stream().map(UnifiedOrderListItem::getOrderId).toList()); long previous = Long.MAX_VALUE; for (UnifiedOrderListItem item : view.getList()) { assertTrue(item.getCreateTime() <= previous, "createTime 非递增"); previous = item.getCreateTime(); assertEquals("completed", item.getUnifiedStatus()); } assertTrue(view.isHasMore()); assertEquals("2500000_flash_32", view.getNextCursor()); } @Test @SuppressWarnings({"rawtypes", "unchecked"}) void eachPathQueriesSizePlusOneOnFirstPageWithoutCount() { doReturn(pageOf(List.of())).when(posOrderMapper).selectPage(any(), any()); doReturn(pageOf(List.of())).when(flashOrderMapper).selectPage(any(), any()); service.query(USER_ID, UnifiedOrderTab.ALL, null, null, 10); ArgumentCaptor posPage = ArgumentCaptor.forClass(Page.class); verify(posOrderMapper).selectPage(posPage.capture(), any()); assertEquals(1L, posPage.getValue().getCurrent()); assertEquals(11L, posPage.getValue().getSize()); assertFalse(posPage.getValue().searchCount()); ArgumentCaptor flashPage = ArgumentCaptor.forClass(Page.class); verify(flashOrderMapper).selectPage(flashPage.capture(), any()); assertEquals(11L, flashPage.getValue().getSize()); assertFalse(flashPage.getValue().searchCount()); } @Test void scrollingThroughEntireSetYieldsEveryOrderExactlyOnce() { // 样本全集(含同秒跨源并列、同源同秒 id 决胜) List flashAll = List.of( flash(31L, 3000L), flash(32L, 2000L), flash(34L, 1000L), flash(33L, 1000L)); List takeawayAll = List.of( takeaway(21L, 3000L), takeaway(22L, 2000L), takeaway(23L, 1000L)); int size = 3; List collected = new ArrayList<>(); UnifiedCursor cursor = null; for (int round = 0; round < 10; round++) { doReturn(pageOf(afterCursor(flashAll, OrderSource.FLASH, cursor, size + 1))) .when(flashOrderMapper).selectPage(any(), any()); doReturn(pageOf(afterCursor(takeawayAll, OrderSource.TAKEAWAY, cursor, size + 1))) .when(posOrderMapper).selectPage(any(), any()); UnifiedOrderPageView view = service.query(USER_ID, UnifiedOrderTab.ALL, null, cursor, size); view.getList().forEach(item -> collected.add(item.getSourceType() + ":" + item.getOrderId())); if (!view.isHasMore()) { assertNull(view.getNextCursor()); break; } cursor = UnifiedCursor.parse(view.getNextCursor()); } // 展示全序:3000F31 3000T21 2000F32 2000T22 1000F34 1000F33 1000T23 assertEquals(List.of("flash:31", "takeaway:21", "flash:32", "takeaway:22", "flash:34", "flash:33", "takeaway:23"), collected); } @Test @SuppressWarnings({"rawtypes", "unchecked"}) void sizeIsClampedBetweenOneAndFifty() { doReturn(pageOf(List.of())).when(posOrderMapper).selectPage(any(), any()); doReturn(pageOf(List.of())).when(flashOrderMapper).selectPage(any(), any()); service.query(USER_ID, UnifiedOrderTab.ALL, null, null, 0); service.query(USER_ID, UnifiedOrderTab.ALL, null, null, 999); service.query(USER_ID, UnifiedOrderTab.ALL, null, null, 10); ArgumentCaptor pages = ArgumentCaptor.forClass(Page.class); verify(posOrderMapper, times(3)).selectPage(pages.capture(), any()); assertEquals(List.of(2L, 51L, 11L), pages.getAllValues().stream().map(Page::getSize).toList()); } @Test void singleSourceFilterQueriesOnlyThatMapper() { doReturn(pageOf(List.of(flash(31L, 3000L)))).when(flashOrderMapper).selectPage(any(), any()); service.query(USER_ID, UnifiedOrderTab.ALL, OrderSource.FLASH, null, 10); verify(posOrderMapper, never()).selectPage(any(), any()); verify(flashOrderMapper, times(1)).selectPage(any(), any()); doReturn(pageOf(List.of(takeaway(21L, 3000L)))).when(posOrderMapper).selectPage(any(), any()); service.query(USER_ID, UnifiedOrderTab.ALL, OrderSource.TAKEAWAY, null, 10); verify(flashOrderMapper, times(1)).selectPage(any(), any()); verify(posOrderMapper, times(1)).selectPage(any(), any()); } @Test @SuppressWarnings({"rawtypes", "unchecked"}) void sourceFilterAddsPosOrderTypePredicate() { doReturn(pageOf(List.of())).when(posOrderMapper).selectPage(any(), any()); doReturn(pageOf(List.of())).when(flashOrderMapper).selectPage(any(), any()); service.query(USER_ID, UnifiedOrderTab.ALL, OrderSource.TAKEAWAY, null, 10); service.query(USER_ID, UnifiedOrderTab.ALL, OrderSource.DINEIN, null, 10); service.query(USER_ID, UnifiedOrderTab.ALL, OrderSource.PICKUP, null, 10); service.query(USER_ID, UnifiedOrderTab.ALL, null, null, 10); ArgumentCaptor captor = ArgumentCaptor.forClass(com.baomidou.mybatisplus.core.conditions.Wrapper.class); verify(posOrderMapper, times(4)).selectPage(any(), captor.capture()); List> wrappers = captor.getAllValues().stream() .map(wrapper -> (LambdaQueryWrapper) wrapper) .toList(); // takeaway 仅外送 type=0;dinein=堂食 type=2;pickup=自取 type=1 assertTrue(wrappers.get(0).getSqlSegment().contains("type ="), wrappers.get(0).getSqlSegment()); assertTrue(wrappers.get(0).getParamNameValuePairs().containsValue(0L), wrappers.get(0).getParamNameValuePairs().toString()); assertTrue(wrappers.get(1).getSqlSegment().contains("type ="), wrappers.get(1).getSqlSegment()); assertTrue(wrappers.get(1).getParamNameValuePairs().containsValue(2L), wrappers.get(1).getParamNameValuePairs().toString()); assertTrue(wrappers.get(2).getSqlSegment().contains("type ="), wrappers.get(2).getSqlSegment()); assertTrue(wrappers.get(2).getParamNameValuePairs().containsValue(1L), wrappers.get(2).getParamNameValuePairs().toString()); // source=all 不限 type,堂食/自取单照常混排 assertFalse(wrappers.get(3).getSqlSegment().contains("type ="), wrappers.get(3).getSqlSegment()); } @Test void dineinAndPickupQueryOnlyPosOrderPathAndTagCursorWithOwnSource() { doReturn(pageOf(List.of(takeaway(21L, 3000L), takeaway(22L, 2000L)))) .when(posOrderMapper).selectPage(any(), any()); UnifiedOrderPageView view = service.query(USER_ID, UnifiedOrderTab.ALL, OrderSource.DINEIN, null, 1); assertEquals(1, view.getList().size()); // 卡片仍是外卖样式,堂食/自取由 type 字段渲染 assertEquals("takeaway", view.getList().get(0).getSourceType()); assertTrue(view.isHasMore()); assertEquals("3000000_dinein_21", view.getNextCursor()); verify(flashOrderMapper, never()).selectPage(any(), any()); service.query(USER_ID, UnifiedOrderTab.ALL, OrderSource.PICKUP, null, 10); verify(posOrderMapper, times(2)).selectPage(any(), any()); verify(flashOrderMapper, never()).selectPage(any(), any()); } @Test @SuppressWarnings({"rawtypes", "unchecked"}) void dineinCursorDrivesSameSecondIdDecisivePredicateOnNextScroll() { doReturn(pageOf(List.of())).when(posOrderMapper).selectPage(any(), any()); service.query(USER_ID, UnifiedOrderTab.ALL, OrderSource.DINEIN, UnifiedCursor.parse("3000000_dinein_21"), 10); ArgumentCaptor captor = ArgumentCaptor.forClass(com.baomidou.mybatisplus.core.conditions.Wrapper.class); verify(posOrderMapper).selectPage(any(), captor.capture()); LambdaQueryWrapper wrapper = (LambdaQueryWrapper) captor.getValue(); // 同源游标:同秒 id 决胜分支存在,保证翻页不丢同秒行 String sql = wrapper.getSqlSegment(); assertTrue(sql.contains(" OR "), sql); assertTrue(sql.contains("type ="), sql); } @Test void unpaidAndRefundTabsNeverQueryFlashPath() { doReturn(pageOf(List.of())).when(posOrderMapper).selectPage(any(), any()); service.query(USER_ID, UnifiedOrderTab.UNPAID, null, null, 10); service.query(USER_ID, UnifiedOrderTab.REFUND, null, null, 10); verify(flashOrderMapper, never()).selectPage(any(), any()); verify(posOrderMapper, times(2)).selectPage(any(), any()); } @Test void emptyResultReportsNoMoreAndNoCursor() { doReturn(pageOf(List.of())).when(posOrderMapper).selectPage(any(), any()); doReturn(pageOf(List.of())).when(flashOrderMapper).selectPage(any(), any()); UnifiedOrderPageView view = service.query(USER_ID, UnifiedOrderTab.ALL, null, null, 10); assertTrue(view.getList().isEmpty()); assertFalse(view.isHasMore()); assertNull(view.getNextCursor()); } @Test void takeawayItemsCarryStoreNameAndAvatarFromSingleBatchLookup() { PosOrder first = takeaway(21L, 3000L); first.setMdId(55L); PosOrder second = takeaway(22L, 2000L); second.setMdId(56L); PosOrder third = takeaway(23L, 1000L); third.setMdId(55L); doReturn(pageOf(List.of(first, second, third))).when(posOrderMapper).selectPage(any(), any()); doReturn(pageOf(List.of())).when(flashOrderMapper).selectPage(any(), any()); PosStore shopA = new PosStore(); shopA.setId(55); shopA.setPosName("店铺A"); shopA.setLogo("http://img/a.png"); PosStore shopB = new PosStore(); shopB.setId(56); shopB.setPosName("店铺B"); shopB.setLogo("http://img/b.png"); when(posStoreMapper.selectBatchIds(anyCollection())).thenReturn(List.of(shopA, shopB)); UnifiedOrderPageView view = service.query(USER_ID, UnifiedOrderTab.ALL, OrderSource.TAKEAWAY, null, 10); assertEquals(3, view.getList().size()); assertEquals("店铺A", view.getList().get(0).getStoreName()); assertEquals("店铺B", view.getList().get(1).getStoreName()); assertEquals("店铺A", view.getList().get(2).getStoreName()); assertEquals("http://img/a.png", view.getList().get(0).getStoreAvatar()); assertEquals("http://img/b.png", view.getList().get(1).getStoreAvatar()); verify(posStoreMapper, times(1)).selectBatchIds(anyCollection()); } @Test void takeawayFallsBackToOrderSnapshotWhenStoreMissing() { PosOrder order = takeaway(21L, 3000L); order.setMdId(404L); order.setPosName("快照店铺"); order.setLogo("http://img/snap.png"); doReturn(pageOf(List.of(order))).when(posOrderMapper).selectPage(any(), any()); doReturn(pageOf(List.of())).when(flashOrderMapper).selectPage(any(), any()); when(posStoreMapper.selectBatchIds(anyCollection())).thenReturn(List.of()); UnifiedOrderPageView view = service.query(USER_ID, UnifiedOrderTab.ALL, OrderSource.TAKEAWAY, null, 10); assertEquals("快照店铺", view.getList().get(0).getStoreName()); assertEquals("http://img/snap.png", view.getList().get(0).getStoreAvatar()); } @Test void takeawayGoodsCountSumsFoodNumberFields() { PosOrder order = takeaway(21L, 3000L); order.setFood("[{\"name\":\"餐A\",\"number\":2},{\"name\":\"餐B\",\"number\":3}]"); PosOrder emptyFood = takeaway(22L, 2000L); PosOrder brokenFood = takeaway(23L, 1000L); brokenFood.setFood("not-json"); doReturn(pageOf(List.of(order, emptyFood, brokenFood))) .when(posOrderMapper).selectPage(any(), any()); doReturn(pageOf(List.of())).when(flashOrderMapper).selectPage(any(), any()); when(posStoreMapper.selectBatchIds(anyCollection())).thenReturn(List.of()); UnifiedOrderPageView view = service.query(USER_ID, UnifiedOrderTab.ALL, OrderSource.TAKEAWAY, null, 10); assertEquals(5, view.getList().get(0).getGoodsCount()); assertNull(view.getList().get(1).getGoodsCount()); assertNull(view.getList().get(2).getGoodsCount()); } @Test void refundWithFlashSourceQueriesNothingAtAll() { UnifiedOrderPageView view = service.query(USER_ID, UnifiedOrderTab.REFUND, OrderSource.FLASH, null, 10); assertTrue(view.getList().isEmpty()); assertFalse(view.isHasMore()); assertNull(view.getNextCursor()); verify(posOrderMapper, never()).selectPage(any(), any()); verify(flashOrderMapper, never()).selectPage(any(), any()); } @Test void activeOrUnpaidWithTakeawaySourceQueriesOnlyTakeawayPath() { doReturn(pageOf(List.of())).when(posOrderMapper).selectPage(any(), any()); service.query(USER_ID, UnifiedOrderTab.ACTIVE, OrderSource.TAKEAWAY, null, 10); service.query(USER_ID, UnifiedOrderTab.UNPAID, OrderSource.TAKEAWAY, null, 10); verify(posOrderMapper, times(2)).selectPage(any(), any()); verify(flashOrderMapper, never()).selectPage(any(), any()); } @Test @SuppressWarnings({"rawtypes", "unchecked"}) void flashPathQueriesBothSenderAndReceiverParticipation() { doReturn(pageOf(List.of())).when(flashOrderMapper).selectPage(any(), any()); service.query(USER_ID, UnifiedOrderTab.ALL, OrderSource.FLASH, null, 10); ArgumentCaptor captor = ArgumentCaptor.forClass(com.baomidou.mybatisplus.core.conditions.Wrapper.class); verify(flashOrderMapper).selectPage(any(), captor.capture()); LambdaQueryWrapper wrapper = (LambdaQueryWrapper) captor.getValue(); String sql = wrapper.getSqlSegment(); assertTrue(sql.contains("user_id ="), sql); assertTrue(sql.contains("receiver_user_id ="), sql); assertTrue(sql.contains(" OR "), sql); assertEquals(2, java.util.Collections.frequency(wrapper.getParamNameValuePairs().values(), USER_ID), wrapper.getParamNameValuePairs().toString()); } @Test void receivedFlashOrderCarriesRolePerViewer() { FlashDeliveryOrder order = flash(31L, 3000L); doReturn(pageOf(List.of(order))).when(flashOrderMapper).selectPage(any(), any()); // 收件人 B=77 视角 UnifiedOrderPageView receiverView = service.query(77L, UnifiedOrderTab.ALL, OrderSource.FLASH, null, 10); assertEquals(1, receiverView.getList().size()); assertEquals("receiver", receiverView.getList().get(0).getRole()); // 寄件人 A=9 视角 UnifiedOrderPageView senderView = service.query(USER_ID, UnifiedOrderTab.ALL, OrderSource.FLASH, null, 10); assertEquals(1, senderView.getList().size()); assertEquals("sender", senderView.getList().get(0).getRole()); } @Test void selfSentFlashOrderKeepsSenderRole() { FlashDeliveryOrder order = flash(31L, 3000L); order.setReceiverUserId(USER_ID); doReturn(pageOf(List.of(order))).when(flashOrderMapper).selectPage(any(), any()); UnifiedOrderPageView view = service.query(USER_ID, UnifiedOrderTab.ALL, OrderSource.FLASH, null, 10); assertEquals(1, view.getList().size()); assertEquals("sender", view.getList().get(0).getRole()); } @Test void flashItemsCarryOrderVersion() { FlashDeliveryOrder order = flash(31L, 3000L); order.setVersion(7); doReturn(pageOf(List.of(order))).when(flashOrderMapper).selectPage(any(), any()); UnifiedOrderPageView view = service.query(USER_ID, UnifiedOrderTab.ALL, OrderSource.FLASH, null, 10); assertEquals(7, view.getList().get(0).getOrderVersion()); } // ---------- helpers ---------- private static PosOrder takeaway(long id, long timeSec) { PosOrder order = new PosOrder(); order.setId(id); order.setDdId("DD" + id); order.setUserId(USER_ID); order.setCretim(new Date(timeSec * 1000)); order.setState(3L); order.setType(0L); order.setPayStatus(1L); order.setPayType("2"); order.setAfterSaleStatus(0L); order.setAmount(100); return order; } private static FlashDeliveryOrder flash(long id, long timeSec) { FlashDeliveryOrder order = new FlashDeliveryOrder(); order.setId(id); order.setOrderNo("FD" + id); order.setUserId(USER_ID); order.setCreateTime(new Date(timeSec * 1000)); order.setStatus(FlashDeliveryStatus.COMPLETED); order.setAmount(500L); order.setCurrency("VND"); return order; } private static Page pageOf(List records) { Page page = new Page<>(1, Math.max(records.size(), 1), false); page.setRecords(new ArrayList<>(records)); return page; } /** 模拟 keyset:返回该源中展示序严格晚于游标的前 limit 行。 */ private static List afterCursor(List sourceRows, OrderSource source, UnifiedCursor cursor, int limit) { List result = new ArrayList<>(); for (T row : sourceRows) { UnifiedCursor key; if (source == OrderSource.TAKEAWAY) { PosOrder order = (PosOrder) row; key = UnifiedCursor.of(order.getCretim().getTime(), source, order.getId()); } else { FlashDeliveryOrder order = (FlashDeliveryOrder) row; key = UnifiedCursor.of(order.getCreateTime().getTime(), source, order.getId()); } if (cursor == null || UnifiedCursor.DISPLAY_ORDER.compare(key, cursor) > 0) { result.add(row); if (result.size() >= limit) { break; } } } return result; } }