Browse Source

feat: 用户端统一订单列表(030 外卖+闪送合并)

qmj 1 day ago
parent
commit
533e0e136f
28 changed files with 2350 additions and 2 deletions
  1. 1 1
      .specify/feature.json
  2. 1 1
      CLAUDE.md
  3. 35 0
      ruoyi-admin/src/main/java/com/ruoyi/app/unifiedorder/OrderSource.java
  4. 132 0
      ruoyi-admin/src/main/java/com/ruoyi/app/unifiedorder/UnifiedCursor.java
  5. 73 0
      ruoyi-admin/src/main/java/com/ruoyi/app/unifiedorder/UnifiedOrderQueryController.java
  6. 225 0
      ruoyi-admin/src/main/java/com/ruoyi/app/unifiedorder/UnifiedOrderQueryService.java
  7. 194 0
      ruoyi-admin/src/main/java/com/ruoyi/app/unifiedorder/UnifiedOrderTab.java
  8. 83 0
      ruoyi-admin/src/main/java/com/ruoyi/app/unifiedorder/dto/UnifiedOrderListItem.java
  9. 17 0
      ruoyi-admin/src/main/java/com/ruoyi/app/unifiedorder/dto/UnifiedOrderPageView.java
  10. 2 0
      ruoyi-admin/src/main/resources/i18n/messages.properties
  11. 2 0
      ruoyi-admin/src/main/resources/i18n/messages_en_US.properties
  12. 2 0
      ruoyi-admin/src/main/resources/i18n/messages_th_TH.properties
  13. 2 0
      ruoyi-admin/src/main/resources/i18n/messages_vi.properties
  14. 2 0
      ruoyi-admin/src/main/resources/i18n/messages_zh_CN.properties
  15. 2 0
      ruoyi-admin/src/main/resources/i18n/messages_zh_TW.properties
  16. 119 0
      ruoyi-admin/src/test/java/com/ruoyi/app/unifiedorder/UnifiedCursorTest.java
  17. 135 0
      ruoyi-admin/src/test/java/com/ruoyi/app/unifiedorder/UnifiedOrderQueryControllerTest.java
  18. 333 0
      ruoyi-admin/src/test/java/com/ruoyi/app/unifiedorder/UnifiedOrderQueryServiceTest.java
  19. 278 0
      ruoyi-admin/src/test/java/com/ruoyi/app/unifiedorder/UnifiedOrderTabTest.java
  20. 35 0
      specs/030-unified-order-list/checklists/requirements.md
  21. 85 0
      specs/030-unified-order-list/contracts/api-contract.md
  22. 110 0
      specs/030-unified-order-list/data-model.md
  23. 92 0
      specs/030-unified-order-list/plan.md
  24. 54 0
      specs/030-unified-order-list/quickstart.md
  25. 61 0
      specs/030-unified-order-list/research.md
  26. 116 0
      specs/030-unified-order-list/spec.md
  27. 149 0
      specs/030-unified-order-list/tasks.md
  28. 10 0
      updatesql/sql.md

+ 1 - 1
.specify/feature.json

@@ -1,3 +1,3 @@
 {
-  "feature_directory": "specs/029-device-trust-phone-bind"
+  "feature_directory": "specs/030-unified-order-list"
 }

+ 1 - 1
CLAUDE.md

@@ -204,5 +204,5 @@ Strong success criteria let you loop independently. Weak criteria ("make it work
 
 <!-- SPECKIT START -->
 For additional context about technologies to be used, project structure,
-shell commands, and other important information, read the current plan: `specs/029-device-trust-phone-bind/plan.md`
+shell commands, and other important information, read the current plan: `specs/030-unified-order-list/plan.md`
 <!-- SPECKIT END -->

+ 35 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/unifiedorder/OrderSource.java

@@ -0,0 +1,35 @@
+package com.ruoyi.app.unifiedorder;
+
+/** 统一订单列表来源枚举;编码字典序(flash 小于 takeaway)是同秒跨源并列时的确定性决胜依据。 */
+public enum OrderSource {
+    /** 外卖订单(pos_order)。 */
+    TAKEAWAY("takeaway"),
+    /** 闪送订单(flash_delivery_order)。 */
+    FLASH("flash");
+
+    private final String code;
+
+    OrderSource(String code) {
+        this.code = code;
+    }
+
+    /** 前端契约中的来源编码。 */
+    public String code() {
+        return code;
+    }
+
+    /** 按契约编码解析;非法值返回 null,由 Controller 统一转参数错误。 */
+    public static OrderSource fromCode(String code) {
+        for (OrderSource source : values()) {
+            if (source.code.equals(code)) {
+                return source;
+            }
+        }
+        return null;
+    }
+
+    /** 按编码字典序比较:flash 排在 takeaway 之前。 */
+    public int compareByCode(OrderSource other) {
+        return this.code.compareTo(other.code);
+    }
+}

+ 132 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/unifiedorder/UnifiedCursor.java

@@ -0,0 +1,132 @@
+package com.ruoyi.app.unifiedorder;
+
+import java.util.Comparator;
+import java.util.Date;
+
+/**
+ * 统一订单列表游标:格式为 时间毫秒_来源编码_订单ID,服务端生成、前端视为不透明字符串透传。
+ * 排序全序(归并与游标推进共用):时间 DESC → 来源字典序 ASC(flash 小于 takeaway)→ id DESC。
+ */
+public final class UnifiedCursor {
+    /** 列表展示顺序的全序比较器。 */
+    public static final Comparator<UnifiedCursor> DISPLAY_ORDER = Comparator
+            .comparingLong(UnifiedCursor::getTimeMillis).reversed()
+            .thenComparing(cursor -> cursor.getSource().code())
+            .thenComparing(Comparator.comparingLong(UnifiedCursor::getOrderId).reversed());
+
+    private final long timeMillis;
+    private final OrderSource source;
+    private final long orderId;
+
+    private UnifiedCursor(long timeMillis, OrderSource source, long orderId) {
+        this.timeMillis = timeMillis;
+        this.source = source;
+        this.orderId = orderId;
+    }
+
+    /** 按毫秒时间戳构造;非法参数抛 IllegalArgumentException。 */
+    public static UnifiedCursor of(long timeMillis, OrderSource source, long orderId) {
+        if (timeMillis < 0) {
+            throw new IllegalArgumentException("timeMillis must not be negative");
+        }
+        if (source == null) {
+            throw new IllegalArgumentException("source is required");
+        }
+        if (orderId <= 0) {
+            throw new IllegalArgumentException("orderId must be positive");
+        }
+        return new UnifiedCursor(timeMillis, source, orderId);
+    }
+
+    /** 按下单时间构造;时间为空抛 IllegalArgumentException。 */
+    public static UnifiedCursor of(Date time, OrderSource source, long orderId) {
+        if (time == null) {
+            throw new IllegalArgumentException("time is required");
+        }
+        return of(time.getTime(), source, orderId);
+    }
+
+    /** 解析前端透传的游标串;格式非法或数值越界抛 IllegalArgumentException,不静默当首页。 */
+    public static UnifiedCursor parse(String cursor) {
+        if (cursor == null || cursor.isBlank()) {
+            throw new IllegalArgumentException("cursor is blank");
+        }
+        String[] parts = cursor.split("_", -1);
+        if (parts.length != 3) {
+            throw new IllegalArgumentException("cursor must have 3 parts");
+        }
+        long time;
+        long id;
+        try {
+            time = Long.parseLong(parts[0]);
+            id = Long.parseLong(parts[2]);
+        } catch (NumberFormatException e) {
+            throw new IllegalArgumentException("cursor numbers are invalid", e);
+        }
+        OrderSource parsedSource = OrderSource.fromCode(parts[1]);
+        if (parsedSource == null) {
+            throw new IllegalArgumentException("cursor source is invalid");
+        }
+        return of(time, parsedSource, id);
+    }
+
+    /** 生成不透明游标串:时间毫秒_来源编码_订单ID。 */
+    public String encode() {
+        return timeMillis + "_" + source.code() + "_" + orderId;
+    }
+
+    /**
+     * 该源查询在游标同秒处是否包含任意 id 的行。
+     * 仅当查询源字典序大于游标源时成立(展示序在该源之后,同秒整段都排在游标后)。
+     */
+    public boolean includesSameTimeAnyId(OrderSource querySource) {
+        requireQuerySource(querySource);
+        return querySource.compareByCode(source) > 0;
+    }
+
+    /**
+     * 该源查询在游标同秒处是否包含 id 更小的行。
+     * 仅游标本源成立(同源并列时按 id DESC 决胜)。
+     */
+    public boolean includesSameTimeLowerId(OrderSource querySource) {
+        requireQuerySource(querySource);
+        return querySource == source;
+    }
+
+    private static void requireQuerySource(OrderSource querySource) {
+        if (querySource == null) {
+            throw new IllegalArgumentException("querySource is required");
+        }
+    }
+
+    public long getTimeMillis() {
+        return timeMillis;
+    }
+
+    public OrderSource getSource() {
+        return source;
+    }
+
+    public long getOrderId() {
+        return orderId;
+    }
+
+    @Override
+    public boolean equals(Object other) {
+        if (this == other) {
+            return true;
+        }
+        if (!(other instanceof UnifiedCursor cursor)) {
+            return false;
+        }
+        return timeMillis == cursor.timeMillis && orderId == cursor.orderId && source == cursor.source;
+    }
+
+    @Override
+    public int hashCode() {
+        int result = Long.hashCode(timeMillis);
+        result = 31 * result + source.hashCode();
+        result = 31 * result + Long.hashCode(orderId);
+        return result;
+    }
+}

+ 73 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/unifiedorder/UnifiedOrderQueryController.java

@@ -0,0 +1,73 @@
+package com.ruoyi.app.unifiedorder;
+
+import com.ruoyi.common.annotation.Anonymous;
+import com.ruoyi.common.core.controller.BaseController;
+import com.ruoyi.common.core.domain.AjaxResult;
+import com.ruoyi.common.utils.MessageUtils;
+import com.ruoyi.system.utils.Auth;
+import com.ruoyi.system.utils.JwtUtil;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestHeader;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+/** 用户端统一订单列表接口:外卖与闪送合并,tab 归类、来源筛选、游标滚动分页。 */
+@RestController
+@RequestMapping("/system/unifiedOrder")
+public class UnifiedOrderQueryController extends BaseController {
+    private final UnifiedOrderQueryService service;
+
+    public UnifiedOrderQueryController(UnifiedOrderQueryService service) {
+        this.service = service;
+    }
+
+    /**
+     * 统一订单列表。
+     *
+     * @param token 登录令牌
+     * @param tab 归类 tab:all/unpaid/active/completed/cancelled/refund,默认 all
+     * @param source 来源筛选:all/takeaway/flash,默认 all
+     * @param cursor 上一页返回的游标,首页不传
+     * @param size 每页条数,默认 10,服务端钳制 1-50
+     */
+    @GetMapping("/list")
+    @Auth
+    @Anonymous
+    public AjaxResult list(@RequestHeader String token,
+                           @RequestParam(defaultValue = "all") String tab,
+                           @RequestParam(defaultValue = "all") String source,
+                           @RequestParam(required = false) String cursor,
+                           @RequestParam(defaultValue = "10") int size) {
+        UnifiedOrderTab parsedTab = UnifiedOrderTab.fromCode(tab);
+        if (parsedTab == null) {
+            return error(MessageUtils.message("no.unifiedorder.param.invalid"));
+        }
+        OrderSource parsedSource = null;
+        if (source != null && !source.isBlank() && !"all".equals(source)) {
+            parsedSource = OrderSource.fromCode(source);
+            if (parsedSource == null) {
+                return error(MessageUtils.message("no.unifiedorder.param.invalid"));
+            }
+        }
+        UnifiedCursor parsedCursor = null;
+        if (cursor != null && !cursor.isBlank()) {
+            try {
+                parsedCursor = UnifiedCursor.parse(cursor);
+            } catch (IllegalArgumentException e) {
+                return error(MessageUtils.message("no.unifiedorder.cursor.invalid"));
+            }
+        }
+        String userId = new JwtUtil().getusid(token);
+        if (userId == null || userId.isBlank()) {
+            return error(MessageUtils.message("no.unifiedorder.param.invalid"));
+        }
+        Long parsedUserId;
+        try {
+            parsedUserId = Long.valueOf(userId);
+        } catch (NumberFormatException e) {
+            return error(MessageUtils.message("no.unifiedorder.param.invalid"));
+        }
+        return success(service.query(parsedUserId, parsedTab, parsedSource, parsedCursor, size));
+    }
+}

+ 225 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/unifiedorder/UnifiedOrderQueryService.java

@@ -0,0 +1,225 @@
+package com.ruoyi.app.unifiedorder;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+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.InfoUser;
+import com.ruoyi.system.domain.PosOrder;
+import com.ruoyi.system.domain.flash.FlashDeliveryOrder;
+import com.ruoyi.system.mapper.InfoUserMapper;
+import com.ruoyi.system.mapper.PosOrderMapper;
+import com.ruoyi.system.mapper.flash.FlashDeliveryOrderMapper;
+import org.springframework.stereotype.Service;
+
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+
+/**
+ * 统一订单列表查询:外卖与闪送两路各取 size+1 条,Java 按游标全序归并,读时聚合零写路径。
+ * 待付款与退款·售后 tab 闪送侧恒空,直接跳过闪送路;来源单选时跳过另一路。
+ */
+@Service
+public class UnifiedOrderQueryService {
+    /** 每页条数上限。 */
+    private static final int MAX_PAGE_SIZE = 50;
+    /** unifiedStatus 的判定优先级:售后优先于状态,与 tab 互斥性一致。 */
+    private static final List<UnifiedOrderTab> STATUS_RESOLUTION_ORDER = List.of(
+            UnifiedOrderTab.REFUND, UnifiedOrderTab.UNPAID, UnifiedOrderTab.ACTIVE,
+            UnifiedOrderTab.COMPLETED, UnifiedOrderTab.CANCELLED);
+    /** 闪送只可能命中这三个 tab。 */
+    private static final List<UnifiedOrderTab> FLASH_STATUS_RESOLUTION_ORDER = List.of(
+            UnifiedOrderTab.ACTIVE, UnifiedOrderTab.COMPLETED, UnifiedOrderTab.CANCELLED);
+
+    private final PosOrderMapper posOrderMapper;
+    private final FlashDeliveryOrderMapper flashOrderMapper;
+    private final InfoUserMapper infoUserMapper;
+
+    public UnifiedOrderQueryService(PosOrderMapper posOrderMapper,
+                                    FlashDeliveryOrderMapper flashOrderMapper,
+                                    InfoUserMapper infoUserMapper) {
+        this.posOrderMapper = posOrderMapper;
+        this.flashOrderMapper = flashOrderMapper;
+        this.infoUserMapper = infoUserMapper;
+    }
+
+    /** 查询统一列表:size 服务端钳制 1-50;cursor 为空表示首页。 */
+    public UnifiedOrderPageView query(Long userId, UnifiedOrderTab tab, OrderSource source,
+                                      UnifiedCursor cursor, int size) {
+        int limit = Math.max(1, Math.min(MAX_PAGE_SIZE, size));
+        List<Row> rows = new ArrayList<>();
+        if (source == null || source == OrderSource.TAKEAWAY) {
+            Page<PosOrder> page = posOrderMapper.selectPage(
+                    new Page<>(1, limit + 1, false), takeawayWrapper(userId, tab, cursor));
+            for (PosOrder order : page.getRecords()) {
+                rows.add(new Row(UnifiedCursor.of(safeTime(order.getCretim()), OrderSource.TAKEAWAY, order.getId()), order, null));
+            }
+        }
+        if (tab.queriesFlash() && (source == null || source == OrderSource.FLASH)) {
+            Page<FlashDeliveryOrder> page = flashOrderMapper.selectPage(
+                    new Page<>(1, limit + 1, false), flashWrapper(userId, tab, cursor));
+            for (FlashDeliveryOrder order : page.getRecords()) {
+                rows.add(new Row(UnifiedCursor.of(safeTime(order.getCreateTime()), OrderSource.FLASH, order.getId()), null, order));
+            }
+        }
+        rows.sort(java.util.Comparator.comparing(Row::key, UnifiedCursor.DISPLAY_ORDER));
+
+        boolean hasMore = rows.size() > limit;
+        List<Row> displayed = hasMore ? rows.subList(0, limit) : rows;
+
+        UnifiedOrderPageView view = new UnifiedOrderPageView();
+        Map<Long, String> storeNames = storeNamesOf(displayed);
+        List<UnifiedOrderListItem> items = new ArrayList<>(displayed.size());
+        for (Row row : displayed) {
+            items.add(row.takeaway() != null
+                    ? takeawayItem(row.takeaway(), storeNames)
+                    : flashItem(row.flash(), userId));
+        }
+        view.setList(items);
+        view.setHasMore(hasMore);
+        view.setNextCursor(hasMore && !displayed.isEmpty()
+                ? displayed.get(displayed.size() - 1).key().encode() : null);
+        return view;
+    }
+
+    /** 外卖路条件:用户 + tab 谓词 + 游标推进,排序 cretim DESC, id DESC。 */
+    private LambdaQueryWrapper<PosOrder> takeawayWrapper(Long userId, UnifiedOrderTab tab, UnifiedCursor cursor) {
+        LambdaQueryWrapper<PosOrder> wrapper = tab.posOrderWrapper()
+                .eq(PosOrder::getUserId, userId)
+                .orderByDesc(PosOrder::getCretim)
+                .orderByDesc(PosOrder::getId);
+        if (cursor != null) {
+            Date time = new Date(cursor.getTimeMillis());
+            if (cursor.includesSameTimeAnyId(OrderSource.TAKEAWAY)) {
+                wrapper.le(PosOrder::getCretim, time);
+            } else if (cursor.includesSameTimeLowerId(OrderSource.TAKEAWAY)) {
+                wrapper.and(w -> w.lt(PosOrder::getCretim, time)
+                        .or(x -> x.eq(PosOrder::getCretim, time).lt(PosOrder::getId, cursor.getOrderId())));
+            } else {
+                wrapper.lt(PosOrder::getCretim, time);
+            }
+        }
+        return wrapper;
+    }
+
+    /** 闪送路条件:寄件人或收件人 + tab 谓词 + 游标推进,排序 create_time DESC, id DESC。 */
+    private LambdaQueryWrapper<FlashDeliveryOrder> flashWrapper(Long userId, UnifiedOrderTab tab, UnifiedCursor cursor) {
+        LambdaQueryWrapper<FlashDeliveryOrder> wrapper = tab.flashWrapper()
+                .and(w -> w.eq(FlashDeliveryOrder::getUserId, userId)
+                        .or().eq(FlashDeliveryOrder::getReceiverUserId, userId))
+                .orderByDesc(FlashDeliveryOrder::getCreateTime)
+                .orderByDesc(FlashDeliveryOrder::getId);
+        if (cursor != null) {
+            Date time = new Date(cursor.getTimeMillis());
+            if (cursor.includesSameTimeAnyId(OrderSource.FLASH)) {
+                wrapper.le(FlashDeliveryOrder::getCreateTime, time);
+            } else if (cursor.includesSameTimeLowerId(OrderSource.FLASH)) {
+                wrapper.and(w -> w.lt(FlashDeliveryOrder::getCreateTime, time)
+                        .or(x -> x.eq(FlashDeliveryOrder::getCreateTime, time)
+                                .lt(FlashDeliveryOrder::getId, cursor.getOrderId())));
+            } else {
+                wrapper.lt(FlashDeliveryOrder::getCreateTime, time);
+            }
+        }
+        return wrapper;
+    }
+
+    private Map<Long, String> storeNamesOf(List<Row> displayed) {
+        Set<Long> shopIds = new HashSet<>();
+        for (Row row : displayed) {
+            if (row.takeaway() != null && row.takeaway().getShId() != null) {
+                shopIds.add(row.takeaway().getShId());
+            }
+        }
+        Map<Long, String> names = new HashMap<>();
+        if (!shopIds.isEmpty()) {
+            for (InfoUser user : infoUserMapper.selectBatchIds(shopIds)) {
+                if (user != null && user.getUserId() != null) {
+                    names.put(user.getUserId(), user.getNickName());
+                }
+            }
+        }
+        return names;
+    }
+
+    private UnifiedOrderListItem takeawayItem(PosOrder order, Map<Long, String> storeNames) {
+        UnifiedOrderListItem item = new UnifiedOrderListItem();
+        item.setSourceType("takeaway");
+        item.setRole("sender");
+        item.setOrderId(order.getId());
+        item.setOrderNo(order.getDdId());
+        item.setUnifiedStatus(resolveTakeawayStatus(order));
+        item.setCreateTime(order.getCretim() == null ? null : order.getCretim().getTime());
+        item.setAmount(order.getAmount() == null ? null : order.getAmount().longValue());
+        item.setHasAfterSale(order.getAfterSaleStatus() != null && order.getAfterSaleStatus() > 0);
+        item.setType(order.getType());
+        item.setState(order.getState());
+        item.setDeliveryStatus(order.getDeliveryStatus());
+        item.setPayStatus(order.getPayStatus());
+        item.setPayType(order.getPayType());
+        item.setAfterSaleStatus(order.getAfterSaleStatus());
+        item.setStoreName(order.getShId() == null ? null : storeNames.get(order.getShId()));
+        item.setCollectPayment(order.getCollectPayment());
+        return item;
+    }
+
+    private UnifiedOrderListItem flashItem(FlashDeliveryOrder order, Long userId) {
+        UnifiedOrderListItem item = new UnifiedOrderListItem();
+        item.setSourceType("flash");
+        item.setRole(userId != null && userId.equals(order.getUserId()) ? "sender" : "receiver");
+        item.setOrderId(order.getId());
+        item.setOrderNo(order.getOrderNo());
+        item.setUnifiedStatus(resolveFlashStatus(order));
+        item.setCreateTime(order.getCreateTime() == null ? null : order.getCreateTime().getTime());
+        item.setAmount(order.getAmount());
+        item.setCurrency(order.getCurrency());
+        item.setHasAfterSale(false);
+        item.setStatus(order.getStatus());
+        item.setServiceType(order.getServiceType());
+        item.setDeliveryType(order.getDeliveryType());
+        item.setVehicleType(order.getVehicleType());
+        item.setDeliveryMode(order.getDeliveryMode());
+        item.setScheduledPickupStartAt(order.getScheduledPickupStartAt());
+        item.setScheduledPickupEndAt(order.getScheduledPickupEndAt());
+        item.setPickupAddress(order.getPickupAddress());
+        item.setPickupDetailAddress(order.getPickupAddressDetail());
+        item.setDeliveryAddress(order.getDeliveryAddress());
+        item.setDeliveryDetailAddress(order.getDeliveryAddressDetail());
+        item.setPackageType(order.getPackageType());
+        item.setQuantity(order.getQuantity());
+        item.setTipAmount(order.getTipAmount());
+        return item;
+    }
+
+    private String resolveTakeawayStatus(PosOrder order) {
+        for (UnifiedOrderTab tab : STATUS_RESOLUTION_ORDER) {
+            if (tab.matches(order)) {
+                return tab.code();
+            }
+        }
+        return null;
+    }
+
+    private String resolveFlashStatus(FlashDeliveryOrder order) {
+        for (UnifiedOrderTab tab : FLASH_STATUS_RESOLUTION_ORDER) {
+            if (tab.matches(order)) {
+                return tab.code();
+            }
+        }
+        return null;
+    }
+
+    private static long safeTime(Date time) {
+        return time == null ? 0L : time.getTime();
+    }
+
+    /** 归并中间行:游标键 + 单侧实体。 */
+    private record Row(UnifiedCursor key, PosOrder takeaway, FlashDeliveryOrder flash) {
+    }
+}

+ 194 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/unifiedorder/UnifiedOrderTab.java

@@ -0,0 +1,194 @@
+package com.ruoyi.app.unifiedorder;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.ruoyi.system.domain.PosOrder;
+import com.ruoyi.system.domain.flash.FlashDeliveryOrder;
+import com.ruoyi.system.domain.flash.FlashDeliveryStatus;
+
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * 统一订单列表 tab 枚举:SQL 条件构造与内存判定共用同一组常量,是归类语义的唯一事实源。
+ * 外卖条件照抄用户端订单列表现行实现(UserOrderController.orderList);
+ * 空值字段不匹配任何受限 tab(与 SQL NULL 比较语义一致),all 不过滤。
+ */
+public enum UnifiedOrderTab {
+    /** 全部:不过滤。 */
+    ALL("all"),
+    /** 待付款:仅在线支付未付(货到付款创建即视为已付,不在此列)。 */
+    UNPAID("unpaid"),
+    /** 进行中:外卖履约中且无售后;闪送非终态。 */
+    ACTIVE("active"),
+    /** 已完成:外卖 state=3 且无售后;闪送 COMPLETED。 */
+    COMPLETED("completed"),
+    /** 已取消:外卖 state=4 且无售后;闪送 CANCELLED。 */
+    CANCELLED("cancelled"),
+    /** 退款·售后:外卖售后标记大于 0;闪送恒空。 */
+    REFUND("refund");
+
+    /** 在线支付方式(OMG、LINE Pay),待付款 tab 仅认这两种。 */
+    private static final List<String> ONLINE_PAY_TYPES = List.of("2", "3");
+    /** 货到付款支付方式标记。 */
+    private static final String CASH_PAY_TYPE = "1";
+    /** 货到付款可线下收款的订单类型:自取、堂食。 */
+    private static final List<Long> CASH_PAYABLE_TYPES = List.of(1L, 2L);
+    /** 进行中的外卖订单状态:待处理、已接单、已出餐。 */
+    private static final List<Long> ACTIVE_STATES = List.of(0L, 1L, 2L);
+    /** 已完成的外卖订单状态。 */
+    private static final long COMPLETED_STATE = 3L;
+    /** 已取消的外卖订单状态。 */
+    private static final long CANCELLED_STATE = 4L;
+    /** 无售后标记。 */
+    private static final long NO_AFTER_SALE = 0L;
+    /** 已支付的支付状态。 */
+    private static final long PAID = 1L;
+    /** 未支付的支付状态。 */
+    private static final long UNPAID_STATUS = 0L;
+    /** 进行中的闪送状态:非终态四枚举。 */
+    private static final List<String> FLASH_ACTIVE_STATUSES = List.of(
+            FlashDeliveryStatus.WAITING_ACCEPTANCE, FlashDeliveryStatus.ACCEPTED,
+            FlashDeliveryStatus.PICKED_UP, FlashDeliveryStatus.DELIVERED);
+
+    private final String code;
+
+    UnifiedOrderTab(String code) {
+        this.code = code;
+    }
+
+    /** 前端契约中的 tab 编码。 */
+    public String code() {
+        return code;
+    }
+
+    /** 按契约编码解析;空值默认 all,非法值返回 null 由 Controller 统一转参数错误。 */
+    public static UnifiedOrderTab fromCode(String code) {
+        if (code == null || code.isBlank()) {
+            return ALL;
+        }
+        for (UnifiedOrderTab tab : values()) {
+            if (tab.code.equals(code)) {
+                return tab;
+            }
+        }
+        return null;
+    }
+
+    /** 待付款与退款·售后 tab 闪送侧恒空,查询层应直接跳过闪送路。 */
+    public boolean queriesFlash() {
+        return this != UNPAID && this != REFUND;
+    }
+
+    /** 构造外卖 tab 条件(仅 tab 谓词,不含用户、游标与排序,由查询层追加)。 */
+    public LambdaQueryWrapper<PosOrder> posOrderWrapper() {
+        LambdaQueryWrapper<PosOrder> wrapper = new LambdaQueryWrapper<>();
+        switch (this) {
+            case UNPAID:
+                wrapper.eq(PosOrder::getPayStatus, UNPAID_STATUS)
+                        .in(PosOrder::getPayType, ONLINE_PAY_TYPES)
+                        .ne(PosOrder::getState, CANCELLED_STATE)
+                        .eq(PosOrder::getAfterSaleStatus, NO_AFTER_SALE);
+                break;
+            case ACTIVE:
+                wrapper.in(PosOrder::getState, ACTIVE_STATES)
+                        .eq(PosOrder::getAfterSaleStatus, NO_AFTER_SALE)
+                        // 现金自取/堂食可以线下收款;在线订单必须已付款才能履约。
+                        .and(w -> w.eq(PosOrder::getPayStatus, PAID)
+                                .or(cash -> cash.eq(PosOrder::getPayType, CASH_PAY_TYPE)
+                                        .in(PosOrder::getType, CASH_PAYABLE_TYPES)));
+                break;
+            case COMPLETED:
+                wrapper.eq(PosOrder::getState, COMPLETED_STATE)
+                        .eq(PosOrder::getAfterSaleStatus, NO_AFTER_SALE);
+                break;
+            case CANCELLED:
+                wrapper.eq(PosOrder::getState, CANCELLED_STATE)
+                        .eq(PosOrder::getAfterSaleStatus, NO_AFTER_SALE);
+                break;
+            case REFUND:
+                wrapper.gt(PosOrder::getAfterSaleStatus, NO_AFTER_SALE);
+                break;
+            case ALL:
+            default:
+                break;
+        }
+        return wrapper;
+    }
+
+    /** 外卖订单内存判定,与 posOrderWrapper 的 SQL 条件同源同语义。 */
+    public boolean matches(PosOrder order) {
+        switch (this) {
+            case UNPAID:
+                return longEq(order.getPayStatus(), UNPAID_STATUS)
+                        && order.getPayType() != null && ONLINE_PAY_TYPES.contains(order.getPayType())
+                        && longNotEq(order.getState(), CANCELLED_STATE)
+                        && longEq(order.getAfterSaleStatus(), NO_AFTER_SALE);
+            case ACTIVE:
+                return order.getState() != null && ACTIVE_STATES.contains(order.getState())
+                        && longEq(order.getAfterSaleStatus(), NO_AFTER_SALE)
+                        && (longEq(order.getPayStatus(), PAID)
+                                || (CASH_PAY_TYPE.equals(order.getPayType())
+                                        && order.getType() != null && CASH_PAYABLE_TYPES.contains(order.getType())));
+            case COMPLETED:
+                return longEq(order.getState(), COMPLETED_STATE)
+                        && longEq(order.getAfterSaleStatus(), NO_AFTER_SALE);
+            case CANCELLED:
+                return longEq(order.getState(), CANCELLED_STATE)
+                        && longEq(order.getAfterSaleStatus(), NO_AFTER_SALE);
+            case REFUND:
+                return order.getAfterSaleStatus() != null && order.getAfterSaleStatus() > NO_AFTER_SALE;
+            case ALL:
+            default:
+                return true;
+        }
+    }
+
+    /** 构造闪送 tab 条件;待付款与退款·售后闪送侧恒空,调用即视为程序错误。 */
+    public LambdaQueryWrapper<FlashDeliveryOrder> flashWrapper() {
+        if (!queriesFlash()) {
+            throw new IllegalStateException("闪送无 " + code + " tab,查询层应跳过闪送路");
+        }
+        LambdaQueryWrapper<FlashDeliveryOrder> wrapper = new LambdaQueryWrapper<>();
+        switch (this) {
+            case ACTIVE:
+                wrapper.in(FlashDeliveryOrder::getStatus, FLASH_ACTIVE_STATUSES);
+                break;
+            case COMPLETED:
+                wrapper.eq(FlashDeliveryOrder::getStatus, FlashDeliveryStatus.COMPLETED);
+                break;
+            case CANCELLED:
+                wrapper.eq(FlashDeliveryOrder::getStatus, FlashDeliveryStatus.CANCELLED);
+                break;
+            case ALL:
+            default:
+                break;
+        }
+        return wrapper;
+    }
+
+    /** 闪送订单内存判定,与 flashWrapper 的 SQL 条件同源同语义。 */
+    public boolean matches(FlashDeliveryOrder order) {
+        switch (this) {
+            case ACTIVE:
+                return order.getStatus() != null && FLASH_ACTIVE_STATUSES.contains(order.getStatus());
+            case COMPLETED:
+                return Objects.equals(order.getStatus(), FlashDeliveryStatus.COMPLETED);
+            case CANCELLED:
+                return Objects.equals(order.getStatus(), FlashDeliveryStatus.CANCELLED);
+            case UNPAID:
+            case REFUND:
+                return false;
+            case ALL:
+            default:
+                return true;
+        }
+    }
+
+    private static boolean longEq(Long value, long expected) {
+        return value != null && value == expected;
+    }
+
+    private static boolean longNotEq(Long value, long unexpected) {
+        return value != null && value != unexpected;
+    }
+}

+ 83 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/unifiedorder/dto/UnifiedOrderListItem.java

@@ -0,0 +1,83 @@
+package com.ruoyi.app.unifiedorder.dto;
+
+import lombok.Data;
+
+import java.util.Date;
+
+/**
+ * 统一订单列表项:公共字段 + 外卖渲染块 + 闪送渲染块,前端按 sourceType 复用现有两种卡片。
+ * 状态族原值透传,文案由前端渲染;不适用的块字段为空。
+ */
+@Data
+public class UnifiedOrderListItem {
+    // ---------- 公共字段 ----------
+
+    /** 来源:takeaway 外卖 / flash 闪送,卡片样式分支依据。 */
+    private String sourceType;
+    /** 角色:sender 寄件 / receiver 收件;外卖恒 sender。 */
+    private String role;
+    /** 源订单 ID(两表自增 ID 可能重叠,须与 sourceType 联用)。 */
+    private Long orderId;
+    /** 详情跳转键:外卖为业务单号 ddId,闪送为 orderNo。 */
+    private String orderNo;
+    /** 统一状态:unpaid/active/completed/cancelled/refund(all 查询下为该单实际归属)。 */
+    private String unifiedStatus;
+    /** 下单时间毫秒时间戳。 */
+    private Long createTime;
+    /** 订单金额。 */
+    private Long amount;
+    /** 币种。 */
+    private String currency;
+    /** 外卖售后标记(afterSaleStatus 大于 0);闪送恒 false。 */
+    private Boolean hasAfterSale;
+
+    // ---------- 外卖渲染块 ----------
+
+    /** 订单类型:0外送,1自取,2堂食。 */
+    private Long type;
+    /** 订单状态:0待处理,1已接单,2已出餐,3已完成,4已取消。 */
+    private Long state;
+    /** 配送状态:0待接单,1骑手已接单,2配送中,3已送达(仅外送)。 */
+    private Long deliveryStatus;
+    /** 支付状态:0未支付,1已支付,2已退款。 */
+    private Long payStatus;
+    /** 支付方式:1到付,2OMG,3LINE Pay,4现金,5余额,6转账。 */
+    private String payType;
+    /** 售后状态:0无售后,1申请中,2退款中,3已退款,4退款拒绝,5客服介入,6售后完成。 */
+    private Long afterSaleStatus;
+    /** 店铺名称(按商家 ID 批量回填)。 */
+    private String storeName;
+    /** 到付/现金支付标记。 */
+    private String collectPayment;
+
+    // ---------- 闪送渲染块 ----------
+
+    /** 闪送状态:WAITING_ACCEPTANCE/ACCEPTED/PICKED_UP/DELIVERED/COMPLETED/CANCELLED。 */
+    private String status;
+    /** 业务场景:HELP_SEND=帮送、HELP_PICKUP=帮取。 */
+    private String serviceType;
+    /** 配送等级:NORMAL=普通、URGENT=加急。 */
+    private String deliveryType;
+    /** 配送车型:1=机车、2=轿车。 */
+    private Integer vehicleType;
+    /** 取件方式:NOW=立即、SCHEDULED=预约。 */
+    private String deliveryMode;
+    /** 预约取件时间窗开始时间,立即单为空。 */
+    private Date scheduledPickupStartAt;
+    /** 预约取件时间窗结束时间,立即单为空。 */
+    private Date scheduledPickupEndAt;
+    /** 取件主地址。 */
+    private String pickupAddress;
+    /** 取件详细地址。 */
+    private String pickupDetailAddress;
+    /** 收件主地址。 */
+    private String deliveryAddress;
+    /** 收件详细地址。 */
+    private String deliveryDetailAddress;
+    /** 物品类别。 */
+    private String packageType;
+    /** 物品数量。 */
+    private Integer quantity;
+    /** 小费。 */
+    private Long tipAmount;
+}

+ 17 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/unifiedorder/dto/UnifiedOrderPageView.java

@@ -0,0 +1,17 @@
+package com.ruoyi.app.unifiedorder.dto;
+
+import lombok.Data;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/** 统一订单列表单页结果:列表项 + 滚动分页游标信息。 */
+@Data
+public class UnifiedOrderPageView {
+    /** 当前页列表,已按下单时间倒序排好。 */
+    private List<UnifiedOrderListItem> list = new ArrayList<>();
+    /** 是否还有下一页。 */
+    private boolean hasMore;
+    /** 下一页游标(不透明字符串,仅 hasMore 为 true 时有值)。 */
+    private String nextCursor;
+}

+ 2 - 0
ruoyi-admin/src/main/resources/i18n/messages.properties

@@ -76,6 +76,8 @@ no.user.password.not.null=Mật khẩu không được để trống
 no.user.jcaptcha.error=CAPTCHA không đúng
 no.user.not.exist=Người dùng không tồn tại
 no.user.login.success=Đăng nhập thành công
+no.unifiedorder.param.invalid=Tham số truy vấn không hợp lệ
+no.unifiedorder.cursor.invalid=Con trỏ phân trang không hợp lệ
 no.user.password.error=Mật khẩu sai
 no.system.error=Lỗi hệ thống
 no.sms.send.success=Gửi SMS thành công

+ 2 - 0
ruoyi-admin/src/main/resources/i18n/messages_en_US.properties

@@ -76,6 +76,8 @@ no.user.password.not.null=Password cannot be empty
 no.user.jcaptcha.error=Incorrect verification code
 no.user.not.exist=User does not exist
 no.user.login.success=Login successful
+no.unifiedorder.param.invalid=Invalid query parameters
+no.unifiedorder.cursor.invalid=Invalid pagination cursor
 no.user.password.error=Password error
 no.system.error=System error
 no.sms.send.success=SMS sent successfully

+ 2 - 0
ruoyi-admin/src/main/resources/i18n/messages_th_TH.properties

@@ -76,6 +76,8 @@ no.user.password.not.null=รหัสผ่านต้องไม่ว่า
 no.user.jcaptcha.error=รหัสยืนยันไม่ถูกต้อง
 no.user.not.exist=ไม่มีผู้ใช้นี้
 no.user.login.success=เข้าสู่ระบบสำเร็จ
+no.unifiedorder.param.invalid=พາรາມິเตอร์ทີ่กາรค้นหາไม่ถูกต้อง
+no.unifiedorder.cursor.invalid=เคอร์เซอร์แบ่งหน้าไม่ถูกต้อງ
 no.user.password.error=รหัสผ่านไม่ถูกต้อง
 no.system.error=เกิดข้อผิดพลาดของระบบ
 no.sms.send.success=ส่ง SMS สำเร็จ

+ 2 - 0
ruoyi-admin/src/main/resources/i18n/messages_vi.properties

@@ -76,6 +76,8 @@ no.user.password.not.null=Mật khẩu không được để trống
 no.user.jcaptcha.error=CAPTCHA không đúng
 no.user.not.exist=Người dùng không tồn tại
 no.user.login.success=Đăng nhập thành công
+no.unifiedorder.param.invalid=Tham số truy vấn không hợp lệ
+no.unifiedorder.cursor.invalid=Con trỏ phân trang không hợp lệ
 no.user.password.error=Mật khẩu sai
 no.system.error=Lỗi hệ thống
 no.sms.send.success=Gửi SMS thành công

+ 2 - 0
ruoyi-admin/src/main/resources/i18n/messages_zh_CN.properties

@@ -76,6 +76,8 @@ no.user.password.not.null=密码不能为空
 no.user.jcaptcha.error=验证码不正确
 no.user.not.exist=用户不存在
 no.user.login.success=登录成功
+no.unifiedorder.param.invalid=查询参数不合法
+no.unifiedorder.cursor.invalid=分页游标不合法
 no.user.password.error=密码错误
 no.system.error=系统错误
 no.sms.send.success=短信发送成功

+ 2 - 0
ruoyi-admin/src/main/resources/i18n/messages_zh_TW.properties

@@ -76,6 +76,8 @@ no.user.password.not.null=密碼不能為空
 no.user.jcaptcha.error=驗證碼不正確
 no.user.not.exist=用戶不存在
 no.user.login.success=登入成功
+no.unifiedorder.param.invalid=查詢參數不合法
+no.unifiedorder.cursor.invalid=分頁游標不合法
 no.user.password.error=密碼錯誤
 no.system.error=系統錯誤
 no.sms.send.success=短信發送成功

+ 119 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/unifiedorder/UnifiedCursorTest.java

@@ -0,0 +1,119 @@
+package com.ruoyi.app.unifiedorder;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+/** 统一订单列表游标:生成/解析/校验/全序比较器/各源推进条件。 */
+class UnifiedCursorTest {
+
+    @Test
+    void encodeParseRoundTripKeepsAllComponents() {
+        UnifiedCursor cursor = UnifiedCursor.of(1726812345000L, OrderSource.FLASH, 1024L);
+        assertEquals("1726812345000_flash_1024", cursor.encode());
+
+        UnifiedCursor parsed = UnifiedCursor.parse(cursor.encode());
+        assertEquals(cursor.getTimeMillis(), parsed.getTimeMillis());
+        assertEquals(cursor.getSource(), parsed.getSource());
+        assertEquals(cursor.getOrderId(), parsed.getOrderId());
+        assertEquals(cursor.encode(), parsed.encode());
+    }
+
+    @Test
+    void ofAcceptsDateAndKeepsMillis() {
+        Date time = new Date(1726812345000L);
+        UnifiedCursor cursor = UnifiedCursor.of(time, OrderSource.TAKEAWAY, 7L);
+        assertEquals(1726812345000L, cursor.getTimeMillis());
+        assertEquals("1726812345000_takeaway_7", cursor.encode());
+    }
+
+    @Test
+    void parseRejectsIllegalInputs() {
+        assertThrows(IllegalArgumentException.class, () -> UnifiedCursor.parse(null));
+        assertThrows(IllegalArgumentException.class, () -> UnifiedCursor.parse(""));
+        assertThrows(IllegalArgumentException.class, () -> UnifiedCursor.parse("   "));
+        assertThrows(IllegalArgumentException.class, () -> UnifiedCursor.parse("garbage"));
+        // 段数不对
+        assertThrows(IllegalArgumentException.class, () -> UnifiedCursor.parse("1726812345000_flash"));
+        assertThrows(IllegalArgumentException.class, () -> UnifiedCursor.parse("1726812345000_flash_1_extra"));
+        // 非数字
+        assertThrows(IllegalArgumentException.class, () -> UnifiedCursor.parse("abc_flash_1"));
+        assertThrows(IllegalArgumentException.class, () -> UnifiedCursor.parse("1726812345000_flash_abc"));
+        // 非法来源
+        assertThrows(IllegalArgumentException.class, () -> UnifiedCursor.parse("1726812345000_nothing_1"));
+        // 负数与零
+        assertThrows(IllegalArgumentException.class, () -> UnifiedCursor.parse("-1_flash_1"));
+        assertThrows(IllegalArgumentException.class, () -> UnifiedCursor.parse("1726812345000_flash_-1"));
+        assertThrows(IllegalArgumentException.class, () -> UnifiedCursor.parse("1726812345000_flash_0"));
+        // 越界(超过 Long 范围)
+        assertThrows(IllegalArgumentException.class,
+                () -> UnifiedCursor.parse("99999999999999999999_flash_1"));
+        assertThrows(IllegalArgumentException.class,
+                () -> UnifiedCursor.parse("1726812345000_flash_99999999999999999999"));
+    }
+
+    @Test
+    void ofRejectsIllegalArguments() {
+        assertThrows(IllegalArgumentException.class, () -> UnifiedCursor.of(-1L, OrderSource.FLASH, 1L));
+        assertThrows(IllegalArgumentException.class, () -> UnifiedCursor.of(1L, null, 1L));
+        assertThrows(IllegalArgumentException.class, () -> UnifiedCursor.of(1L, OrderSource.FLASH, 0L));
+        assertThrows(IllegalArgumentException.class, () -> UnifiedCursor.of(1L, OrderSource.FLASH, -1L));
+        assertThrows(IllegalArgumentException.class, () -> UnifiedCursor.of((Date) null, OrderSource.FLASH, 1L));
+    }
+
+    @Test
+    void displayOrderSortsByTimeDescThenSourceAscThenIdDesc() {
+        List<UnifiedCursor> keys = new ArrayList<>(List.of(
+                UnifiedCursor.of(1000L, OrderSource.TAKEAWAY, 5L),
+                UnifiedCursor.of(2000L, OrderSource.FLASH, 1L),
+                UnifiedCursor.of(1000L, OrderSource.FLASH, 9L),
+                UnifiedCursor.of(1000L, OrderSource.FLASH, 3L),
+                UnifiedCursor.of(2000L, OrderSource.TAKEAWAY, 2L),
+                UnifiedCursor.of(3000L, OrderSource.TAKEAWAY, 7L)));
+        keys.sort(UnifiedCursor.DISPLAY_ORDER);
+        assertEquals(List.of(
+                "3000_takeaway_7",
+                "2000_flash_1",
+                "2000_takeaway_2",
+                "1000_flash_9",
+                "1000_flash_3",
+                "1000_takeaway_5"), keys.stream().map(UnifiedCursor::encode).toList());
+    }
+
+    @Test
+    void displayOrderIsAntisymmetricOnTies() {
+        UnifiedCursor flash9 = UnifiedCursor.of(1000L, OrderSource.FLASH, 9L);
+        UnifiedCursor flash3 = UnifiedCursor.of(1000L, OrderSource.FLASH, 3L);
+        UnifiedCursor takeaway5 = UnifiedCursor.of(1000L, OrderSource.TAKEAWAY, 5L);
+        // 同秒:闪送排在外卖前
+        assertEquals(-1, Integer.signum(UnifiedCursor.DISPLAY_ORDER.compare(flash9, takeaway5)));
+        assertEquals(1, Integer.signum(UnifiedCursor.DISPLAY_ORDER.compare(takeaway5, flash9)));
+        // 同秒同源:id 大者在前
+        assertEquals(-1, Integer.signum(UnifiedCursor.DISPLAY_ORDER.compare(flash9, flash3)));
+        assertEquals(1, Integer.signum(UnifiedCursor.DISPLAY_ORDER.compare(flash3, flash9)));
+        // 完全相同视为相等
+        assertEquals(0, UnifiedCursor.DISPLAY_ORDER.compare(flash9, UnifiedCursor.of(1000L, OrderSource.FLASH, 9L)));
+    }
+
+    @Test
+    void advanceBooleansFollowCursorSourceOrder() {
+        // 游标落在 flash:外卖源序更大,同秒整段包含;flash 本源需 id 更小
+        UnifiedCursor flashCursor = UnifiedCursor.of(1000L, OrderSource.FLASH, 9L);
+        assertEquals(true, flashCursor.includesSameTimeAnyId(OrderSource.TAKEAWAY));
+        assertEquals(false, flashCursor.includesSameTimeLowerId(OrderSource.TAKEAWAY));
+        assertEquals(false, flashCursor.includesSameTimeAnyId(OrderSource.FLASH));
+        assertEquals(true, flashCursor.includesSameTimeLowerId(OrderSource.FLASH));
+
+        // 游标落在 takeaway:flash 源序更小,同秒整段不包含且无 id 条件
+        UnifiedCursor takeawayCursor = UnifiedCursor.of(1000L, OrderSource.TAKEAWAY, 5L);
+        assertEquals(false, takeawayCursor.includesSameTimeAnyId(OrderSource.FLASH));
+        assertEquals(false, takeawayCursor.includesSameTimeLowerId(OrderSource.FLASH));
+        assertEquals(false, takeawayCursor.includesSameTimeAnyId(OrderSource.TAKEAWAY));
+        assertEquals(true, takeawayCursor.includesSameTimeLowerId(OrderSource.TAKEAWAY));
+    }
+}

+ 135 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/unifiedorder/UnifiedOrderQueryControllerTest.java

@@ -0,0 +1,135 @@
+package com.ruoyi.app.unifiedorder;
+
+import com.ruoyi.app.unifiedorder.dto.UnifiedOrderPageView;
+import com.ruoyi.common.annotation.Anonymous;
+import com.ruoyi.common.core.domain.AjaxResult;
+import com.ruoyi.common.utils.MessageUtils;
+import com.ruoyi.system.utils.Auth;
+import com.ruoyi.system.utils.JwtUtil;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.MockedStatic;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestHeader;
+import org.springframework.web.bind.annotation.RequestParam;
+
+import java.lang.reflect.Method;
+import java.lang.reflect.Parameter;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.ArgumentMatchers.isNull;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.mockStatic;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+
+/** 统一订单列表接口契约:显式参数注解、非法参数错误、结果透传。 */
+class UnifiedOrderQueryControllerTest {
+    private MockedStatic<MessageUtils> messages;
+    private UnifiedOrderQueryService service;
+
+    @BeforeEach
+    void setUp() {
+        messages = mockStatic(MessageUtils.class);
+        messages.when(() -> MessageUtils.message(any())).thenAnswer(invocation -> invocation.getArgument(0));
+        service = mock(UnifiedOrderQueryService.class);
+    }
+
+    @AfterEach
+    void closeMessages() {
+        messages.close();
+    }
+
+    @Test
+    void listEndpointDeclaresExplicitParameterAnnotations() throws Exception {
+        Method list = UnifiedOrderQueryController.class.getMethod("list",
+                String.class, String.class, String.class, String.class, int.class);
+        assertArrayEquals(new String[]{"/list"}, list.getAnnotation(GetMapping.class).value());
+        assertNotNull(list.getAnnotation(Auth.class));
+        assertNotNull(list.getAnnotation(Anonymous.class));
+
+        Parameter[] parameters = list.getParameters();
+        assertNotNull(parameters[0].getAnnotation(RequestHeader.class));
+        for (int index = 1; index < parameters.length; index++) {
+            RequestParam requestParam = parameters[index].getAnnotation(RequestParam.class);
+            assertNotNull(requestParam, parameters[index].getName());
+            assertFalse(java.util.Map.class.isAssignableFrom(parameters[index].getType()));
+        }
+        assertEquals("all", parameters[1].getAnnotation(RequestParam.class).defaultValue());
+        assertEquals("all", parameters[2].getAnnotation(RequestParam.class).defaultValue());
+        assertFalse(parameters[3].getAnnotation(RequestParam.class).required());
+        assertEquals("10", parameters[4].getAnnotation(RequestParam.class).defaultValue());
+    }
+
+    @Test
+    void invalidTabReturnsParamErrorWithoutTouchingService() {
+        UnifiedOrderQueryController controller = new UnifiedOrderQueryController(service);
+        AjaxResult result = controller.list(JwtUtil.token("9", "user"), "bogus", "all", null, 10);
+        assertEquals(500, ((Number) result.get(AjaxResult.CODE_TAG)).intValue());
+        assertEquals("no.unifiedorder.param.invalid", result.get(AjaxResult.MSG_TAG));
+        verifyNoInteractions(service);
+    }
+
+    @Test
+    void invalidSourceReturnsParamErrorWithoutTouchingService() {
+        UnifiedOrderQueryController controller = new UnifiedOrderQueryController(service);
+        AjaxResult result = controller.list(JwtUtil.token("9", "user"), "all", "bogus", null, 10);
+        assertEquals(500, ((Number) result.get(AjaxResult.CODE_TAG)).intValue());
+        assertEquals("no.unifiedorder.param.invalid", result.get(AjaxResult.MSG_TAG));
+        verifyNoInteractions(service);
+    }
+
+    @Test
+    void invalidCursorReturnsCursorErrorWithoutTouchingService() {
+        UnifiedOrderQueryController controller = new UnifiedOrderQueryController(service);
+        AjaxResult result = controller.list(JwtUtil.token("9", "user"), "all", "all", "garbage", 10);
+        assertEquals(500, ((Number) result.get(AjaxResult.CODE_TAG)).intValue());
+        assertEquals("no.unifiedorder.cursor.invalid", result.get(AjaxResult.MSG_TAG));
+        verifyNoInteractions(service);
+    }
+
+    @Test
+    void validRequestDelegatesToServiceAndPutsResultInData() {
+        UnifiedOrderPageView view = new UnifiedOrderPageView();
+        when(service.query(eq(9L), eq(UnifiedOrderTab.ALL), isNull(), isNull(), anyInt())).thenReturn(view);
+
+        UnifiedOrderQueryController controller = new UnifiedOrderQueryController(service);
+        AjaxResult result = controller.list(JwtUtil.token("9", "user"), "all", "all", null, 10);
+
+        assertEquals(200, ((Number) result.get(AjaxResult.CODE_TAG)).intValue());
+        assertSame(view, result.get(AjaxResult.DATA_TAG));
+    }
+
+    @Test
+    void cursorStringIsParsedAndForwardedToService() {
+        UnifiedOrderPageView view = new UnifiedOrderPageView();
+        when(service.query(eq(9L), eq(UnifiedOrderTab.ACTIVE), eq(OrderSource.FLASH),
+                eq(UnifiedCursor.parse("1726812345000_flash_1024")), anyInt())).thenReturn(view);
+
+        UnifiedOrderQueryController controller = new UnifiedOrderQueryController(service);
+        AjaxResult result = controller.list(JwtUtil.token("9", "user"), "active", "flash",
+                "1726812345000_flash_1024", 10);
+
+        assertEquals(200, ((Number) result.get(AjaxResult.CODE_TAG)).intValue());
+        assertSame(view, result.get(AjaxResult.DATA_TAG));
+    }
+
+    @Test
+    void nullUserIdFromBrokenTokenYieldsErrorInsteadOfCrash() {
+        UnifiedOrderQueryController controller = new UnifiedOrderQueryController(service);
+        AjaxResult result = controller.list("broken-token", "all", "all", null, 10);
+        assertEquals(500, ((Number) result.get(AjaxResult.CODE_TAG)).intValue());
+        verifyNoInteractions(service);
+    }
+}

+ 333 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/unifiedorder/UnifiedOrderQueryServiceTest.java

@@ -0,0 +1,333 @@
+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.InfoUser;
+import com.ruoyi.system.domain.PosOrder;
+import com.ruoyi.system.domain.flash.FlashDeliveryOrder;
+import com.ruoyi.system.domain.flash.FlashDeliveryStatus;
+import com.ruoyi.system.mapper.InfoUserMapper;
+import com.ruoyi.system.mapper.PosOrderMapper;
+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 InfoUserMapper infoUserMapper = mock(InfoUserMapper.class);
+    private final UnifiedOrderQueryService service =
+            new UnifiedOrderQueryService(posOrderMapper, flashOrderMapper, infoUserMapper);
+
+    @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<Page> 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<Page> 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<FlashDeliveryOrder> flashAll = List.of(
+                flash(31L, 3000L), flash(32L, 2000L), flash(34L, 1000L), flash(33L, 1000L));
+        List<PosOrder> takeawayAll = List.of(
+                takeaway(21L, 3000L), takeaway(22L, 2000L), takeaway(23L, 1000L));
+        int size = 3;
+
+        List<String> 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<Page> 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
+    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 takeawayItemsCarryStoreNameFromSingleBatchLookup() {
+        PosOrder first = takeaway(21L, 3000L);
+        first.setShId(55L);
+        PosOrder second = takeaway(22L, 2000L);
+        second.setShId(56L);
+        PosOrder third = takeaway(23L, 1000L);
+        third.setShId(55L);
+        doReturn(pageOf(List.of(first, second, third))).when(posOrderMapper).selectPage(any(), any());
+        doReturn(pageOf(List.of())).when(flashOrderMapper).selectPage(any(), any());
+        InfoUser shopA = new InfoUser();
+        shopA.setUserId(55L);
+        shopA.setNickName("店铺A");
+        InfoUser shopB = new InfoUser();
+        shopB.setUserId(56L);
+        shopB.setNickName("店铺B");
+        when(infoUserMapper.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());
+        verify(infoUserMapper, times(1)).selectBatchIds(anyCollection());
+    }
+
+    @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<com.baomidou.mybatisplus.core.conditions.Wrapper> captor =
+                ArgumentCaptor.forClass(com.baomidou.mybatisplus.core.conditions.Wrapper.class);
+        verify(flashOrderMapper).selectPage(any(), captor.capture());
+        LambdaQueryWrapper<FlashDeliveryOrder> wrapper =
+                (LambdaQueryWrapper<FlashDeliveryOrder>) 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());
+    }
+
+    // ---------- 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 <T> Page<T> pageOf(List<T> records) {
+        Page<T> page = new Page<>(1, Math.max(records.size(), 1), false);
+        page.setRecords(new ArrayList<>(records));
+        return page;
+    }
+
+    /** 模拟 keyset:返回该源中展示序严格晚于游标的前 limit 行。 */
+    private static <T> List<T> afterCursor(List<T> sourceRows, OrderSource source, UnifiedCursor cursor, int limit) {
+        List<T> 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;
+    }
+}

+ 278 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/unifiedorder/UnifiedOrderTabTest.java

@@ -0,0 +1,278 @@
+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.ruoyi.system.domain.PosOrder;
+import com.ruoyi.system.domain.flash.FlashDeliveryOrder;
+import com.ruoyi.system.domain.flash.FlashDeliveryStatus;
+import org.apache.ibatis.builder.MapperBuilderAssistant;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+import java.util.Collection;
+import java.util.List;
+
+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.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/** 统一订单列表 tab 归类:两域内存判定全覆盖,规则照抄用户端订单列表现行语义。 */
+class UnifiedOrderTabTest {
+
+    @BeforeAll
+    static void initializeTableMetadata() {
+        TableInfoHelper.initTableInfo(
+                new MapperBuilderAssistant(new MybatisConfiguration(), ""), PosOrder.class);
+        TableInfoHelper.initTableInfo(
+                new MapperBuilderAssistant(new MybatisConfiguration(), ""), FlashDeliveryOrder.class);
+    }
+
+    // ---------- fromCode / queriesFlash ----------
+
+    @Test
+    void fromCodeDefaultsToAllAndRejectsUnknownValues() {
+        assertSame(UnifiedOrderTab.ALL, UnifiedOrderTab.fromCode(null));
+        assertSame(UnifiedOrderTab.ALL, UnifiedOrderTab.fromCode(""));
+        assertSame(UnifiedOrderTab.ALL, UnifiedOrderTab.fromCode("  "));
+        assertSame(UnifiedOrderTab.ALL, UnifiedOrderTab.fromCode("all"));
+        assertSame(UnifiedOrderTab.UNPAID, UnifiedOrderTab.fromCode("unpaid"));
+        assertSame(UnifiedOrderTab.ACTIVE, UnifiedOrderTab.fromCode("active"));
+        assertSame(UnifiedOrderTab.COMPLETED, UnifiedOrderTab.fromCode("completed"));
+        assertSame(UnifiedOrderTab.CANCELLED, UnifiedOrderTab.fromCode("cancelled"));
+        assertSame(UnifiedOrderTab.REFUND, UnifiedOrderTab.fromCode("refund"));
+        assertNull(UnifiedOrderTab.fromCode("bogus"));
+        assertNull(UnifiedOrderTab.fromCode("ALL"));
+    }
+
+    @Test
+    void unpaidAndRefundTabsNeverQueryFlash() {
+        assertFalse(UnifiedOrderTab.UNPAID.queriesFlash());
+        assertFalse(UnifiedOrderTab.REFUND.queriesFlash());
+        assertTrue(UnifiedOrderTab.ALL.queriesFlash());
+        assertTrue(UnifiedOrderTab.ACTIVE.queriesFlash());
+        assertTrue(UnifiedOrderTab.COMPLETED.queriesFlash());
+        assertTrue(UnifiedOrderTab.CANCELLED.queriesFlash());
+    }
+
+    // ---------- 外卖内存判定 ----------
+
+    @Test
+    void unpaidMatchesOnlyUnpaidOnlineOrders() {
+        // 在线支付未付
+        assertTrue(UnifiedOrderTab.UNPAID.matches(pos(0L, 0L, 0L, "2", 0L)));
+        assertTrue(UnifiedOrderTab.UNPAID.matches(pos(3L, 2L, 0L, "3", 0L)));
+        // 已付不进待付款
+        assertFalse(UnifiedOrderTab.UNPAID.matches(pos(0L, 0L, 1L, "2", 0L)));
+        // 货到付款/现金不进待付款(沿用现有语义:仅在线支付未付)
+        assertFalse(UnifiedOrderTab.UNPAID.matches(pos(0L, 0L, 0L, "1", 0L)));
+        assertFalse(UnifiedOrderTab.UNPAID.matches(pos(0L, 0L, 0L, "4", 0L)));
+        // 已取消不进待付款
+        assertFalse(UnifiedOrderTab.UNPAID.matches(pos(4L, 0L, 0L, "2", 0L)));
+        // 售后中不进待付款
+        assertFalse(UnifiedOrderTab.UNPAID.matches(pos(0L, 0L, 0L, "2", 1L)));
+    }
+
+    @Test
+    void activeMatchesFulfillingOrdersWithoutAfterSale() {
+        // 在线已付履约中
+        assertTrue(UnifiedOrderTab.ACTIVE.matches(pos(0L, 0L, 1L, "2", 0L)));
+        assertTrue(UnifiedOrderTab.ACTIVE.matches(pos(2L, 0L, 1L, "3", 0L)));
+        // 货到付款(payType=1)仅自取/堂食可履约
+        assertTrue(UnifiedOrderTab.ACTIVE.matches(pos(1L, 1L, 0L, "1", 0L)));
+        assertTrue(UnifiedOrderTab.ACTIVE.matches(pos(2L, 2L, 0L, "1", 0L)));
+        assertFalse(UnifiedOrderTab.ACTIVE.matches(pos(0L, 0L, 0L, "1", 0L)));
+        // 在线未付不履约
+        assertFalse(UnifiedOrderTab.ACTIVE.matches(pos(0L, 0L, 0L, "2", 0L)));
+        // 终态不进进行中
+        assertFalse(UnifiedOrderTab.ACTIVE.matches(pos(3L, 0L, 1L, "2", 0L)));
+        assertFalse(UnifiedOrderTab.ACTIVE.matches(pos(4L, 0L, 1L, "2", 0L)));
+        // 售后单从进行中剔除
+        assertFalse(UnifiedOrderTab.ACTIVE.matches(pos(1L, 0L, 1L, "2", 2L)));
+    }
+
+    @Test
+    void completedMatchesStateThreeWithoutAfterSale() {
+        assertTrue(UnifiedOrderTab.COMPLETED.matches(pos(3L, 0L, 1L, "2", 0L)));
+        assertFalse(UnifiedOrderTab.COMPLETED.matches(pos(2L, 0L, 1L, "2", 0L)));
+        assertFalse(UnifiedOrderTab.COMPLETED.matches(pos(3L, 0L, 1L, "2", 1L)));
+    }
+
+    @Test
+    void cancelledMatchesStateFourWithoutAfterSale() {
+        assertTrue(UnifiedOrderTab.CANCELLED.matches(pos(4L, 0L, 0L, "2", 0L)));
+        assertFalse(UnifiedOrderTab.CANCELLED.matches(pos(3L, 0L, 1L, "2", 0L)));
+        assertFalse(UnifiedOrderTab.CANCELLED.matches(pos(4L, 0L, 0L, "2", 1L)));
+    }
+
+    @Test
+    void refundMatchesAnyAfterSaleMark() {
+        assertTrue(UnifiedOrderTab.REFUND.matches(pos(1L, 0L, 1L, "2", 1L)));
+        // payStatus=2 已退款必然带售后标记,归退款 tab
+        assertTrue(UnifiedOrderTab.REFUND.matches(pos(4L, 0L, 2L, "2", 3L)));
+        assertFalse(UnifiedOrderTab.REFUND.matches(pos(4L, 0L, 1L, "2", 0L)));
+    }
+
+    @Test
+    void allTabNeverFiltersTakeawayOrders() {
+        for (long state = 0; state <= 4; state++) {
+            for (long afterSale = 0; afterSale <= 3; afterSale++) {
+                assertTrue(UnifiedOrderTab.ALL.matches(pos(state, 0L, 0L, "2", afterSale)),
+                        "state=" + state + ", afterSale=" + afterSale);
+            }
+        }
+    }
+
+    @Test
+    void nullFieldsNeverMatchConstrainedTabs() {
+        PosOrder sparse = new PosOrder();
+        assertFalse(UnifiedOrderTab.UNPAID.matches(sparse));
+        assertFalse(UnifiedOrderTab.ACTIVE.matches(sparse));
+        assertFalse(UnifiedOrderTab.COMPLETED.matches(sparse));
+        assertFalse(UnifiedOrderTab.CANCELLED.matches(sparse));
+        assertFalse(UnifiedOrderTab.REFUND.matches(sparse));
+        assertTrue(UnifiedOrderTab.ALL.matches(sparse));
+    }
+
+    // ---------- 闪送内存判定 ----------
+
+    @Test
+    void flashNonFinalStatusesMapToActive() {
+        for (String status : new String[]{FlashDeliveryStatus.WAITING_ACCEPTANCE,
+                FlashDeliveryStatus.ACCEPTED, FlashDeliveryStatus.PICKED_UP,
+                FlashDeliveryStatus.DELIVERED}) {
+            FlashDeliveryOrder order = flash(status);
+            assertTrue(UnifiedOrderTab.ACTIVE.matches(order), status);
+            assertFalse(UnifiedOrderTab.COMPLETED.matches(order), status);
+            assertFalse(UnifiedOrderTab.CANCELLED.matches(order), status);
+            assertFalse(UnifiedOrderTab.UNPAID.matches(order), status);
+            assertFalse(UnifiedOrderTab.REFUND.matches(order), status);
+            assertTrue(UnifiedOrderTab.ALL.matches(order), status);
+        }
+    }
+
+    @Test
+    void flashCompletedMapsOnlyToCompleted() {
+        FlashDeliveryOrder order = flash(FlashDeliveryStatus.COMPLETED);
+        assertTrue(UnifiedOrderTab.COMPLETED.matches(order));
+        assertFalse(UnifiedOrderTab.ACTIVE.matches(order));
+        assertFalse(UnifiedOrderTab.CANCELLED.matches(order));
+    }
+
+    @Test
+    void flashCancelledMapsOnlyToCancelled() {
+        FlashDeliveryOrder order = flash(FlashDeliveryStatus.CANCELLED);
+        assertTrue(UnifiedOrderTab.CANCELLED.matches(order));
+        assertFalse(UnifiedOrderTab.ACTIVE.matches(order));
+        assertFalse(UnifiedOrderTab.COMPLETED.matches(order));
+    }
+
+    @Test
+    void flashStatusNullFailsAllConstrainedTabs() {
+        FlashDeliveryOrder order = flash(null);
+        assertFalse(UnifiedOrderTab.ACTIVE.matches(order));
+        assertFalse(UnifiedOrderTab.COMPLETED.matches(order));
+        assertFalse(UnifiedOrderTab.CANCELLED.matches(order));
+        assertTrue(UnifiedOrderTab.ALL.matches(order));
+    }
+
+    // ---------- wrapper SQL 片段断言(防 SQL 构造与 matches 内存判定漂移) ----------
+
+    @Test
+    void unpaidWrapperCarriesOnlineUnpaidConditions() {
+        LambdaQueryWrapper<PosOrder> wrapper = UnifiedOrderTab.UNPAID.posOrderWrapper();
+        String sql = wrapper.getSqlSegment();
+        assertTrue(sql.contains("pay_status ="), sql);
+        assertTrue(sql.contains("pay_type IN"), sql);
+        assertTrue(sql.contains("state <>"), sql);
+        assertTrue(sql.contains("after_sale_status ="), sql);
+        Collection<Object> params = wrapper.getParamNameValuePairs().values();
+        assertTrue(params.containsAll(List.of("2", "3")), params.toString());
+    }
+
+    @Test
+    void activeWrapperCarriesStateAndPaymentConditions() {
+        String sql = UnifiedOrderTab.ACTIVE.posOrderWrapper().getSqlSegment();
+        assertTrue(sql.contains("state IN"), sql);
+        assertTrue(sql.contains("after_sale_status ="), sql);
+        assertTrue(sql.contains("(pay_status ="), sql);
+        assertTrue(sql.contains("OR (pay_type ="), sql);
+        assertTrue(sql.contains("type IN"), sql);
+    }
+
+    @Test
+    void terminalTabWrappersCarryStateAndAfterSaleConditions() {
+        LambdaQueryWrapper<PosOrder> completedWrapper = UnifiedOrderTab.COMPLETED.posOrderWrapper();
+        String completed = completedWrapper.getSqlSegment();
+        assertTrue(completed.contains("state ="), completed);
+        assertTrue(completed.contains("after_sale_status ="), completed);
+        assertTrue(completedWrapper.getParamNameValuePairs().containsValue(3L),
+                completedWrapper.getParamNameValuePairs().toString());
+        LambdaQueryWrapper<PosOrder> cancelledWrapper = UnifiedOrderTab.CANCELLED.posOrderWrapper();
+        String cancelled = cancelledWrapper.getSqlSegment();
+        assertTrue(cancelled.contains("state ="), cancelled);
+        assertTrue(cancelledWrapper.getParamNameValuePairs().containsValue(4L),
+                cancelledWrapper.getParamNameValuePairs().toString());
+    }
+
+    @Test
+    void refundWrapperFiltersAfterSaleMark() {
+        String sql = UnifiedOrderTab.REFUND.posOrderWrapper().getSqlSegment();
+        assertTrue(sql.contains("after_sale_status >"), sql);
+    }
+
+    @Test
+    void allTabWrapperHasNoConditions() {
+        String takeawaySql = UnifiedOrderTab.ALL.posOrderWrapper().getSqlSegment();
+        assertTrue(takeawaySql == null || takeawaySql.isBlank(), String.valueOf(takeawaySql));
+        String flashSql = UnifiedOrderTab.ALL.flashWrapper().getSqlSegment();
+        assertTrue(flashSql == null || flashSql.isBlank(), String.valueOf(flashSql));
+    }
+
+    @Test
+    void flashActiveWrapperContainsFourNonFinalStatuses() {
+        LambdaQueryWrapper<FlashDeliveryOrder> wrapper = UnifiedOrderTab.ACTIVE.flashWrapper();
+        assertTrue(wrapper.getSqlSegment().contains("status IN"), wrapper.getSqlSegment());
+        Collection<Object> params = wrapper.getParamNameValuePairs().values();
+        assertTrue(params.containsAll(List.of(FlashDeliveryStatus.WAITING_ACCEPTANCE,
+                FlashDeliveryStatus.ACCEPTED, FlashDeliveryStatus.PICKED_UP,
+                FlashDeliveryStatus.DELIVERED)), params.toString());
+    }
+
+    @Test
+    void flashTerminalTabWrappersMatchExactStatus() {
+        LambdaQueryWrapper<FlashDeliveryOrder> completed = UnifiedOrderTab.COMPLETED.flashWrapper();
+        assertTrue(completed.getSqlSegment().contains("status ="), completed.getSqlSegment());
+        assertTrue(completed.getParamNameValuePairs().containsValue(FlashDeliveryStatus.COMPLETED));
+        LambdaQueryWrapper<FlashDeliveryOrder> cancelled = UnifiedOrderTab.CANCELLED.flashWrapper();
+        assertTrue(cancelled.getSqlSegment().contains("status ="), cancelled.getSqlSegment());
+        assertTrue(cancelled.getParamNameValuePairs().containsValue(FlashDeliveryStatus.CANCELLED));
+    }
+
+    @Test
+    void flashWrapperForAlwaysEmptyTabsThrows() {
+        assertThrows(IllegalStateException.class, () -> UnifiedOrderTab.UNPAID.flashWrapper());
+        assertThrows(IllegalStateException.class, () -> UnifiedOrderTab.REFUND.flashWrapper());
+    }
+
+    // ---------- helpers ----------
+
+    private static PosOrder pos(Long state, Long type, Long payStatus, String payType, Long afterSaleStatus) {
+        PosOrder order = new PosOrder();
+        order.setState(state);
+        order.setType(type);
+        order.setPayStatus(payStatus);
+        order.setPayType(payType);
+        order.setAfterSaleStatus(afterSaleStatus);
+        return order;
+    }
+
+    private static FlashDeliveryOrder flash(String status) {
+        FlashDeliveryOrder order = new FlashDeliveryOrder();
+        order.setStatus(status);
+        return order;
+    }
+}

+ 35 - 0
specs/030-unified-order-list/checklists/requirements.md

@@ -0,0 +1,35 @@
+# Specification Quality Checklist: 用户端统一订单列表
+
+**Purpose**: Validate specification completeness and quality before proceeding to planning
+**Created**: 2026-09-20
+**Feature**: [spec.md](../spec.md)
+
+## Content Quality
+
+- [x] No implementation details (languages, frameworks, APIs) — 注:tab/来源枚举、cursor、源表字段族为产品契约与项目既有概念(沿用 017/029 行文惯例);架构决策(否决中间表/视图)记录于 Assumptions 供后续追溯
+- [x] Focused on user value and business needs
+- [x] Written for non-technical stakeholders
+- [x] All mandatory sections completed
+
+## Requirement Completeness
+
+- [x] No [NEEDS CLARIFICATION] markers remain — 关键决策已在头脑风暴确认:方案=读时聚合(否决中间表/视图)、滚动加载→游标分页、收件人视角纳入列表并按角色字段区分
+- [x] Requirements are testable and unambiguous
+- [x] Success criteria are measurable
+- [x] Success criteria are technology-agnostic (no implementation details) — SC-005 以"与现有单域列表同量级"表述
+- [x] All acceptance scenarios are defined
+- [x] Edge cases are identified(同秒跨源并列/恒空 tab/非法游标/size 钳制/售后剔除/空用户/旧接口回归/详情键一致性)
+- [x] Scope is clearly bounded(不含商家端/骑手端;不做角标;零 DB 变更)
+- [x] Dependencies and assumptions identified
+
+## Feature Readiness
+
+- [x] All functional requirements have clear acceptance criteria
+- [x] User scenarios cover primary flows(混排浏览/tab 归类/收件人视角三条独立可测故事)
+- [x] Feature meets measurable outcomes defined in Success Criteria
+- [x] No implementation details leak into specification
+
+## Notes
+
+- 所有条目通过,可直接进入 `/speckit-plan`。
+- FR-009 的索引核实为上线前置检查项,plan/tasks 阶段落实。

+ 85 - 0
specs/030-unified-order-list/contracts/api-contract.md

@@ -0,0 +1,85 @@
+# 前端接口契约:统一订单列表(uni-app 用户端)
+
+**Date**: 2026-09-20 | **Spec**: [spec.md](../spec.md)
+
+> 外卖 + 闪送订单合并为一个列表。前端滚动加载游标分页,卡片复用现有两种样式混排,按 `sourceType` 渲染、按 `role` 加"我收的"标签。
+
+## 1. 接口
+
+```
+GET /system/unifiedOrder/list
+Header: token
+Query:
+  tab     可选,枚举 all/unpaid/active/completed/cancelled/refund,默认 all
+  source  可选,枚举 all/takeaway/flash,默认 all
+  cursor  可选,首页不传;后续传上一页返回的 nextCursor 原样透传
+  size    可选,默认 10,服务端钳制 1-50
+```
+
+## 2. 返回
+
+```json
+{
+  "code": 200,
+  "data": {
+    "list": [ /* UnifiedOrderListItem[] */ ],
+    "hasMore": true,
+    "nextCursor": "1726812345000_flash_1024"
+  }
+}
+```
+
+- 滚动逻辑:`hasMore=false` 停止加载;`nextCursor` 为不透明字符串,**不要解析、不要修改**,仅透传。
+- 错误(code=500):游标非法 / 参数非法,提示后重置回首页重新加载。
+
+## 3. 列表项 UnifiedOrderListItem
+
+### 公共字段
+
+| 字段 | 类型 | 说明 |
+|---|---|---|
+| sourceType | String | `takeaway` 外卖 / `flash` 闪送 —— **卡片样式分支依据** |
+| role | String | `sender`/`receiver`,仅闪送有区分意义("我收的"标签) |
+| orderId | Long | 源订单 ID(与 sourceType 联用才唯一) |
+| orderNo | String | 外卖=ddId、闪送=orderNo —— **详情跳转键** |
+| unifiedStatus | String | unpaid/active/completed/cancelled/refund(all 下为该单实际归属) |
+| createTime | Long | 下单时间毫秒 |
+| amount / currency | Long/String | 金额与币种 |
+| hasAfterSale | Boolean | 外卖售后角标;闪送恒 false |
+
+### takeaway 渲染块(字段值语义与现有外卖列表完全一致,渲染逻辑直接复用)
+
+`type`(0外送/1自取/2堂食)、`state`(0待处理/1已接单/2已出餐/3已完成/4已取消)、`deliveryStatus`(0待接/1已接/2配送中/3已送达)、`payStatus`(0未付/1已付/2已退)、`payType`(1到付/2OMG/3LINE/4现金/5余额/6转账)、`afterSaleStatus`(0-6)、`storeName`、`collectPayment`
+
+### flash 渲染块(对齐现有闪送列表卡片)
+
+`status`(WAITING_ACCEPTANCE/ACCEPTED/PICKED_UP/DELIVERED/COMPLETED/CANCELLED)、`serviceType`、`deliveryType`、`vehicleType`、`deliveryMode`(NOW/SCHEDULED)、`scheduledPickupStartAt/scheduledPickupEndAt`、`pickupAddress/pickupDetailAddress`、`deliveryAddress/deliveryDetailAddress`、`packageType`、`quantity`、`tipAmount`
+
+## 4. tab 归类语义(前端文案对照)
+
+| tab | 含义 | 外卖 | 闪送 |
+|---|---|---|---|
+| all 全部 | 所有订单 | ✅ | ✅ |
+| unpaid 待付款 | 在线支付未付(货到付款不在此列) | ✅ | —(永空) |
+| active 进行中 | 履约中 | ✅ | 非终态(待接/已接/已取件/已送达) |
+| completed 已完成 | 终态·完成 | ✅ | COMPLETED |
+| cancelled 已取消 | 终态·取消 | ✅ | CANCELLED |
+| refund 退款/售后 | 售后流程中/已退款 | ✅ | —(永空) |
+
+## 5. 前端行为要点
+
+1. **排序**:列表已按下单时间倒序排好,前端直接顺序渲染,勿再排序。
+2. **详情跳转**:`sourceType=takeaway` → 现有外卖详情(`orderNo`=ddId);`sourceType=flash` → 现有闪送详情(`orderId` 或 `orderNo`)。
+3. **角色标签**:`sourceType=flash && role=receiver` 的卡片显示"我收的"标识。
+4. **角标**:`hasAfterSale=true` 的外卖卡片显示售后角标。
+5. **空 tab**:待付款/退款售后里没有闪送单是正常现象,空态文案正常展示即可。
+6. 旧的 `/system/userOrder/orderList` 与 `/system/flashDelivery/orders` 不下线,其它页面继续用。
+
+## 6. 联调自测清单
+
+1. 同一账号混有外卖+闪送单 → 全部 tab 按时间倒序混排,两种卡片正确渲染。
+2. 连续滚动到底 → 与该账号实际订单总数一致,无重复无遗漏。
+3. 6 个 tab × 3 个来源筛选逐一核对归类(重点:待付款不含货到付款;售后单只在退款/售后 tab)。
+4. A 寄给 B 的闪送单:A、B 各自列表都出现,角色分别为寄件/收件;自己寄给自己只出现一条。
+5. 非法 cursor(手改字符串)→ 报错提示,重置回首页可用。
+6. 旧外卖列表页/闪送列表页(若有保留入口)行为不变。

+ 110 - 0
specs/030-unified-order-list/data-model.md

@@ -0,0 +1,110 @@
+# Data Model: 用户端统一订单列表
+
+**Date**: 2026-09-20 | **Spec**: [spec.md](spec.md)
+
+> 本功能**无新表、无视图、无字段变更**。本文定义组合视图结构、游标格式与 tab 归类谓词的权威规则。
+
+## 1. 统一列表项 UnifiedOrderListItem(DTO,无落库)
+
+### 公共字段
+
+| 字段 | 类型 | 说明 |
+|---|---|---|
+| sourceType | String | `takeaway` / `flash` |
+| role | String | 仅闪送有意义:`sender` 寄件 / `receiver` 收件;外卖恒 `sender` |
+| orderId | Long | 源表主键(两表自增 ID 数值可能重叠,必须与 sourceType 联用) |
+| orderNo | String | 外卖=业务单号 ddId;闪送=orderNo(FD 前缀)——详情跳转键 |
+| unifiedStatus | String | 归属 tab 同款枚举(unpaid/active/completed/cancelled/refund;all 查询下返回该单实际归属的 tab 值) |
+| createTime | Long | 下单时间毫秒时间戳(排序与游标基准) |
+| amount | Long | 订单金额(外卖取现有列表展示金额字段,实现时对齐;闪送 amount) |
+| currency | String | 币种(闪送 VND;外卖按现有返回,可为空) |
+| hasAfterSale | Boolean | 外卖售后标记(afterSaleStatus>0);闪送恒 false |
+
+### 外卖渲染块(takeaway,前端复用现有外卖卡片)
+
+`type`(0外送/1自取/2堂食)、`state`(0-4)、`deliveryStatus`(0-3,仅外送)、`payStatus`(0/1/2)、`payType`(1-6)、`afterSaleStatus`(0-6)、`storeName`(批量回填商家昵称)、`collectPayment`(到付标记)——数值原值透传,文案由前端渲染。
+
+### 闪送渲染块(flash,对齐 FlashDeliveryUserOrderListView 字段子集)
+
+`status`(六态枚举原值)、`serviceType`、`deliveryType`、`vehicleType`、`deliveryMode`+`scheduledPickupStartAt/scheduledPickupEndAt`、`pickupAddress/pickupDetailAddress`、`deliveryAddress/deliveryDetailAddress`、`packageType`、`quantity`、`tipAmount`、`amount`。
+
+## 2. 游标 UnifiedCursor
+
+- **格式**:`{createTimeMillis}_{sourceType}_{orderId}`,如 `1726812345000_flash_1024`。服务端生成,前端视为不透明字符串透传。
+- **排序全序**(归并与游标推进的同一规则):
+  1. `createTime DESC`
+  2. 同刻:`sourceType` 字典序 ASC(`flash` < `takeaway`,即同秒闪送排前面)
+  3. 同刻同源:`orderId DESC`
+- **推进条件**(每源 SQL):本源取 `(t < T) OR (t = T AND 本源序 > cursor源序) OR (t = T AND 本源 = cursor源 AND id < cursorId)`,其中"本源序 > cursor 源序"在单源 SQL 中退化为常量布尔(实现封装在 UnifiedCursor,禁止散落拼接)。
+- **校验**:格式非法/数值越界 → 参数错误(消息 key),不静默当首页。
+- **有效期**:游标仅是位置标记,无过期概念;源数据变化导致的位置偏移属正常现象(新下单会出现在首页,不影响旧游标正确性)。
+
+## 3. tab 归类谓词(唯一事实源,SQL 条件与内存判定同源)
+
+### 外卖(pos_order)——照搬 `UserOrderController.orderList:555-582` 现行条件
+
+| tab | 条件 |
+|---|---|
+| unpaid | `payStatus=0 AND payType IN (2,3) AND state≠4 AND afterSaleStatus=0`(仅在线支付未付) |
+| active | `state IN (0,1,2) AND afterSaleStatus=0 AND (payStatus=1 OR (payType='1' AND type IN (1,2)))` |
+| completed | `state=3 AND afterSaleStatus=0` |
+| cancelled | `state=4 AND afterSaleStatus=0` |
+| refund | `afterSaleStatus>0` |
+| all | 无条件(以上皆不过滤) |
+
+不变量提醒(specs/018):payStatus=2 必然 state=4 且 afterSaleStatus=3——refund 条件天然覆盖已退款单。
+
+### 闪送(flash_delivery_order)
+
+| tab | 条件 |
+|---|---|
+| unpaid | —(恒空,查询层直接跳过闪送路) |
+| active | `status IN (WAITING_ACCEPTANCE, ACCEPTED, PICKED_UP, DELIVERED)`(非终态) |
+| completed | `status = COMPLETED` |
+| cancelled | `status = CANCELLED` |
+| refund | —(恒空,同上) |
+| all | 无条件 |
+
+### 谓词实现约束
+
+- `UnifiedOrderTab` 枚举内聚两域规则:`wrapperFor(posOrder)` / `matches(PosOrder)` 与 `wrapperFor(flash)` / `matches(FlashDeliveryOrder)`;SQL 构造与内存判定使用同一组常量,单测双向校验(构造出的条件查回的样本必须全部通过内存判定)。
+- `unifiedStatus` 取值 = 该单在谓词下命中的 tab(all 查询时由内存判定得出)。
+
+## 4. 查询形态(每源,每次滚动)
+
+```sql
+-- 外卖路(role 恒 sender)
+-- 注:PosOrder 实体无 del_flag 字段,且现有 orderList 亦不过滤,为保持行为零漂移不加该条件
+SELECT ... FROM pos_order
+WHERE user_id = ? AND <tab条件> AND <游标推进>
+ORDER BY cretim DESC, id DESC
+LIMIT size+1
+
+-- 闪送路(含收件视角,OR 单查询天然去重)
+SELECT ... FROM flash_delivery_order
+WHERE (user_id = ? OR receiver_user_id = ?) AND <tab条件> AND <游标推进>
+ORDER BY create_time DESC, id DESC
+LIMIT size+1
+```
+
+- 游标推进中的"同刻同源 id < cursorId"与源内 `ORDER BY ... id DESC` 对应;跨源同刻并列由 Java 归并的 source 字典序处理。
+- `tab=unpaid/refund`:跳过闪送路;`source` 单选:跳过另一路。
+- Java 归并:按全序(§2)取前 size 条,第 size+1 条存在 → `hasMore=true`;`nextCursor` = 末条(t, source, id)。
+
+## 5. 索引核实清单(上线前置,条件性 SQL,写入 updatesql/sql.md 不直接执行)
+
+```sql
+-- 2026-09-20 统一订单列表查询索引(030-unified-order-list,核实后执行:已存在则跳过)
+ALTER TABLE pos_order ADD INDEX idx_user_cretim (user_id, cretim);
+ALTER TABLE flash_delivery_order ADD INDEX idx_user_ctime (user_id, create_time);
+ALTER TABLE flash_delivery_order ADD INDEX idx_receiver_ctime (receiver_user_id, create_time);
+```
+
+开发者先在库上 `SHOW INDEX` 核实,缺哪条执行哪条。
+
+## 6. 错误消息 key(新增,多语言)
+
+| key | 用途 |
+|---|---|
+| `no.unifiedorder.cursor.invalid` | 游标格式非法 |
+| `no.unifiedorder.param.invalid` | tab/source 取值非法 |

+ 92 - 0
specs/030-unified-order-list/plan.md

@@ -0,0 +1,92 @@
+# Implementation Plan: 用户端统一订单列表
+
+**Branch**: `030-unified-order-list` | **Date**: 2026-09-20 | **Spec**: [spec.md](spec.md)
+
+**Input**: Feature specification from `/specs/030-unified-order-list/spec.md`
+
+## Summary
+
+用户端外卖(pos_order)与闪送(flash_delivery_order)订单合并为一个统一列表:Java 读时聚合(否决中间表/视图),新包 `com.ruoyi.app.unifiedorder` 提供独立接口 `GET /system/unifiedOrder/list`,支持 6 tab(沿用外卖现有枚举 all/unpaid/active/completed/cancelled/refund)+ 来源筛选(all/takeaway/flash)+ 游标滚动分页(严格 keyset,同秒跨源决胜:source 字典序 flash<takeaway + id DESC)。两路各查 `size+1` 条内存归并;闪送路含收件人视角(`user_id OR receiver_user_id`,role 字段区分);unpaid/refund tab 跳过闪送路。tab 归类做成表驱动谓词(同一份规则生成 SQL 条件与内存判定)。**零数据库结构变更**(唯一条件项:核实/补两表查询索引)。
+
+## Technical Context
+
+**Language/Version**: Java 21(Maven 多模块:ruoyi-admin / ruoyi-system / ruoyi-common)
+
+**Primary Dependencies**: Spring Boot、MyBatis-Plus(分页用 `Page<>` 构造,游标模式下 searchCount=false)、Lombok;复用现有 `PosOrderMapper` / `FlashDeliveryOrderMapper`(只读,不新增写路径)
+
+**Storage**: 无新表无视图。只读两张源表:`pos_order`(时间字段 `cretim`,Date)、`flash_delivery_order`(时间字段 `create_time`,Date;两表均为秒级 datetime,同秒并列真实存在,决胜规则必须确定性)。条件项:`pos_order(user_id, cretim)`、`flash_delivery_order(user_id, create_time)` 与 `(receiver_user_id)` 索引上线前核实,缺则按流程补(updatesql/sql.md)
+
+**Testing**: JUnit 5 + Mockito,沿用 029 建立的 SpringUtils 静态桩 + MockHttpServletRequest 模式;测试位于 `ruoyi-admin/src/test/java/com/ruoyi/app/unifiedorder/`。构建:`JAVA_HOME=%USERPROFILE%\.jdks\graalvm-jdk-21.0.12+7.1` + `mvn -pl ruoyi-admin -am test -Dtest='Xxx' -Dsurefire.failIfNoSpecifiedTests=false`
+
+**Target Platform**: Linux 服务器 Spring Boot(越南用户端 uni-app 的后端)
+
+**Project Type**: web-service(单体多模块 Maven)
+
+**Performance Goals**: 单次滚动请求每源至多一次索引查询(各取 size+1 行,无 count 查询);耗时与现有单域列表同量级
+
+**Constraints**: Controller 参数规范(GET 全 @RequestParam + @RequestHeader token);不触碰 CLAUDE.md 废弃代码;现有两个列表接口零改动;保留 CRLF
+
+**Scale/Scope**: 新包约 6 个主文件 + 3 个测试文件 + 1 条条件性索引 SQL;不动任何既有订单写路径
+
+## Constitution Check
+
+*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
+
+`.specify/memory/constitution.md` 为空模板;以 CLAUDE.md 项目规范替代核对:
+
+| 约束 | 结果 |
+|---|---|
+| Controller 请求参数规范(GET 显式 @RequestParam、token @RequestHeader、无 Map 入参) | PASS |
+| 模块依赖方向 ruoyi-admin → ruoyi-system | PASS — 新包在 ruoyi-admin,仅用 ruoyi-system 实体/Mapper |
+| 数据库变更管理(不直接执行,写 sql.md) | PASS — 仅条件性索引补充 |
+| 废弃代码清单不触碰 | PASS — 只读现有表,不参考 PayController/TestTask 等 |
+| Java 注释安全 | PASS |
+| 后端错误消息走 MessageUtils | PASS — 非法 cursor 等错误用消息 key |
+
+Phase 1 设计后复核:全部 PASS,无违规项。
+
+## Project Structure
+
+### Documentation (this feature)
+
+```text
+specs/030-unified-order-list/
+├── plan.md              # This file
+├── research.md          # Phase 0 output
+├── data-model.md        # Phase 1 output(DTO/cursor/谓词规则)
+├── quickstart.md        # Phase 1 output
+├── contracts/
+│   └── api-contract.md  # 前端契约
+└── tasks.md             # Phase 2 output (/speckit-tasks)
+```
+
+### Source Code (repository root)
+
+```text
+ruoyi-admin/src/main/java/com/ruoyi/app/unifiedorder/
+├── UnifiedOrderQueryController.java   # GET /system/unifiedOrder/list
+├── UnifiedOrderQueryService.java      # 两路查询 + 归并 + 游标推进 + 店铺名批量回填
+├── UnifiedOrderTab.java               # tab 枚举 + 表驱动谓词(wrapper 条件构造 & 实体内存判定同源)
+├── OrderSource.java                   # takeaway/flash 枚举(字典序决胜依据)
+├── UnifiedCursor.java                 # 游标:生成/解析/校验(时间_source_id)
+└── dto/
+    ├── UnifiedOrderListItem.java      # 公共字段 + takeaway 渲染块 + flash 渲染块
+    └── UnifiedOrderPageView.java      # 分页信封:list/hasMore/nextCursor
+
+ruoyi-admin/src/test/java/com/ruoyi/app/unifiedorder/
+├── UnifiedOrderTabTest.java          # 两域×6 tab×边界样本归类 + wrapper SQL 片段断言
+├── UnifiedCursorTest.java             # 生成/解析/非法输入/同秒并列
+├── UnifiedOrderQueryServiceTest.java  # 归并排序、翻页完整性、hasMore、tab 跳过闪送路、role 判定
+└── UnifiedOrderQueryControllerTest.java # 参数契约 + 组合查询
+
+updatesql/sql.md                      # 条件性索引补充(标注"核实后执行")
+(复用只读)ruoyi-system: PosOrderMapper / FlashDeliveryOrderMapper / InfoUser(店铺名回填)
+```
+
+**Structure Decision**: 新功能独立成包(不向 700+ 行的 UserOrderController 继续堆叠);两源表的查询直接走各自 MP Mapper(游标模式下 `new Page<>(1, limit, false)` 固定第一页,天然无 count 开销);tab 谓词集中在一个枚举类里同时服务 SQL 构造与内存判定(唯一事实源,FR-007)。
+
+## Complexity Tracking
+
+> **Fill ONLY if Constitution Check has violations that must be justified**
+
+无违规项。

+ 54 - 0
specs/030-unified-order-list/quickstart.md

@@ -0,0 +1,54 @@
+# Quickstart: 用户端统一订单列表
+
+**Date**: 2026-09-20 | **Spec**: [spec.md](spec.md) | **契约**: [contracts/api-contract.md](contracts/api-contract.md)
+
+## 前置条件
+
+1. **索引核实(上线前置)**:按 `data-model.md` §5 在库上 `SHOW INDEX` 核实三处索引,缺则执行 updatesql/sql.md 中对应条目(本功能除索引外零 DB 变更)。
+2. **JDK**:
+   ```bash
+   export JAVA_HOME="$HOME/.jdks/graalvm-jdk-21.0.12+7.1"
+   export PATH="$JAVA_HOME/bin:$PATH"
+   ```
+3. 测试数据:目标用户名下构造样本——外卖(在线支付未付 / 货到付款各状态 / 售后中 / 已取消 / 已完成)+ 闪送(六态各一 + 一单寄件人=收件人 + 一单 A 寄 B)。
+
+## 自动化验证
+
+```bash
+mvn -pl ruoyi-admin -am test -Dtest='UnifiedOrder*Test,UnifiedCursorTest' -Dsurefire.failIfNoSpecifiedTests=false
+mvn -pl ruoyi-admin -am compile
+```
+
+预期:全绿(含翻页完整性——测试内多次滚动聚合断言与样本全集一致)。
+
+## 手动端到端(服务起本地,token 为登录凭证)
+
+```bash
+# 1. 首页:混排 + 倒序
+curl 'http://localhost:8080/system/unifiedOrder/list?tab=all&source=all&size=10' -H 'token: <t>'
+# 预期:list 内 createTime 严格非递增;takeaway/flash 混排;hasMore/nextCursor 正确
+
+# 2. 滚动第二页(透传 nextCursor)
+curl 'http://localhost:8080/system/unifiedOrder/list?cursor=<nextCursor>' -H 'token: <t>'
+
+# 3. tab 归类抽查
+curl '.../list?tab=unpaid'   # 仅外卖在线支付未付单
+curl '.../list?tab=refund'  # 仅外卖售后单
+curl '.../list?tab=active'  # 外卖履约中 + 闪送非终态
+
+# 4. 来源筛选 + 收件视角
+curl '.../list?source=flash' -H 'token: <B的token>'   # 含 A 寄 B 的单,role=receiver
+curl '.../list?source=takeaway'                        # 仅外卖
+
+# 5. 非法游标
+curl '.../list?cursor=garbage'                          # 参数错误提示
+```
+
+## 回归(必须零差异)
+
+- 旧接口 `GET /system/userOrder/orderList`、`GET /system/flashDelivery/orders` 原测试全绿 + 手工抽查不变。
+- `mvn -pl ruoyi-admin -am test`(全模块)无新增失败(已知基线红:flash payType 白名单,见项目记忆)。
+
+## 通过标准
+
+- 自动化全绿;§手动场景逐条与契约一致;旧接口零回归。

+ 61 - 0
specs/030-unified-order-list/research.md

@@ -0,0 +1,61 @@
+# Research: 用户端统一订单列表
+
+**Date**: 2026-09-20 | **Status**: 决策已与用户确认,无未解决 NEEDS CLARIFICATION
+
+决策来源:头脑风暴对话(方案对比/滚动加载确认/收件视角确认)+ 代码探索报告(现有列表接口、状态字段全集、写入点清单、索引现状)。
+
+## D1: 架构——Java 读时聚合(否决中间表/视图/ES)
+
+- **Decision**: 读侧新 Service 双路查询 + 内存归并;**不建中间表、不建视图、不引入 ES**;零 DB 结构变更。
+- **Rationale**: ① 中间表双写一致性风险大——探索确认两域状态写入点多达 20+ 处(商家四状态、骑手三连、用户取消、平台改状态、LINE/OMG 支付回调、闪送状态机六方法、两个自动任务),每处都要同步,且该区域遍布废弃代码(TestTask/ZaloPay 等),漏改即脏数据;② 视图把 tab 状态映射埋进 SQL DDL(每分支 CASE WHEN 30-60 行),不可单测,每次调口径要人工执行 DDL;③ ES 是亿级订单方案,本项目体量杀鸡用牛刀;④ 个人订单列表数据量小(单用户几百单级),读时聚合性能足够,数据永远实时,写侧零侵入。
+- **Alternatives**: ① 中间表统一存储(用户最初设想)——被一致性与维护成本否决;② MySQL UNION 视图——被可测性与变更成本否决;③ ES/搜索服务——体量不符,否决。将来若演进为订单搜索服务,仅替换读实现,接口契约不变。
+
+## D2: 分页——游标滚动(严格 keyset,含同秒跨源决胜)
+
+- **Decision**: cursor = 不透明字符串 `时间毫秒_source_id`;前端滚动加载透传 nextCursor。排序:`createTime DESC → source 字典序(flash<takeaway) → id DESC`。两表时间均为秒级 datetime,同秒跨源并列必须确定性决胜,否则翻页漏/重。
+- **Rationale**: 用户确认前端是滚动加载(非页码),keyset 每次滚动代价恒定(每路一条索引查询取 size+1 行);页码模式的深翻页浪费不存在。
+- **Alternatives**: 页码分页(前端现状不是页码,且深翻页两边各取 N×size 条浪费)——否决。
+
+## D3: tab 枚举与归类规则——沿用外卖现有语义
+
+- **Decision**: tab = `all/unpaid/active/completed/cancelled/refund`(与 `UserOrderController.orderList` 现有参数一致)。外卖侧条件**照搬现有实现**(unpaid: payStatus=0 且 payType∈{2,3} 且 state≠4 且无售后;active: state∈{0,1,2} 且无售后且已付或到付自取堂食;completed: state=3 无售后;cancelled: state=4 无售后;refund: 售后>0)。闪送侧映射:非终态(WAITING_ACCEPTANCE/ACCEPTED/PICKED_UP/DELIVERED)→active、COMPLETED→completed、CANCELLED→cancelled;unpaid/refund 闪送侧恒空。
+- **Rationale**: 前端 tab 参数名不变减少改动;外卖语义经过现网验证;归类规则抽成一份谓词(SQL 条件构造 + 内存判定同源),杜绝两处漂移(FR-007)。
+- **Alternatives**: 新造一套 tab 枚举——徒增前端映射,否决。
+
+## D4: 闪送收件人视角——纳入统一列表
+
+- **Decision**: 闪送路查询条件 `(user_id=? OR receiver_user_id=?)` 单条 OR 查询;role 判定:user_id 命中→sender,否则 receiver;寄件人=收件人只出一条(判 sender)。列表项带 role 字段,前端渲染"我收的"标签。
+- **Rationale**: 用户明确选择"统一进列表,前端通过字段区分";OR 单查询天然去重(不会一单两行)。
+- **Alternatives**: 收件视角留在闪送独立页面——用户否决。
+
+## D5: 统一卡片 DTO——公共字段 + 两源渲染块
+
+- **Decision**: 公共字段(sourceType/role/orderId/orderNo/unifiedStatus/createTime毫秒/amount/currency/hasAfterSale)+ 外卖块(type/state/deliveryStatus/payStatus/payType/afterSaleStatus 原值 + storeName)+ 闪送块(对齐 `FlashDeliveryUserOrderListView` 字段子集:status/serviceType/deliveryType/vehicleType/deliveryMode/预约窗/取收地址/packageType/quantity/tipAmount 等)。前端按 sourceType 复用现有两种卡片渲染。
+- **Rationale**: 状态族原值透传,前端现有渲染逻辑(数值/枚举→文案映射)零改动;详情跳转键外卖用业务单号 ddId、闪送用订单 ID/单号,直达现有详情接口。
+- **Alternatives**: 后端把状态渲染成文案——与现有"前端 i18n 渲染"分工不符,否决。
+
+## D6: 范围裁剪——不做角标、旧接口不动、只做用户端
+
+- **Decision**: 不做 tab 数量角标(未提出,YAGNI,后续可加 counts 接口);现有外卖/闪送两个列表接口原样保留(其它入口在用);商家/骑手端不涉及。
+- **Rationale**: 范围控制;旧接口零回归是 FR-008 的验证项。
+
+## D7: 索引——上线前核实,缺则补(唯一条件性 SQL)
+
+- **Decision**: 探索确认 `flash_delivery_order` 现有索引仅 PK + uk(order_no) + uk(user_id, client_request_id),**缺 (user_id, create_time) 与 (receiver_user_id) 索引**;`pos_order` 建表 DDL 不在 sql.md(历史表),索引需在库上核实。补索引语句写入 updatesql/sql.md 并标注"核实后执行",不直接执行。
+- **Rationale**: 读时聚合的每路查询 = `WHERE user/角色 = ? AND 时间游标条件 ORDER BY 时间 DESC LIMIT n`,有索引才是索引扫描;个人列表行数不大,无索引也能跑但属于隐患。备注:`(user_id, client_request_id)` 唯一索引的 user_id 前缀可部分复用,但不含 create_time 排序。
+- **Alternatives**: 不核实不补——性能隐患留坑,否决。
+
+## D8: 每路分页实现——MP `Page<>(1, limit, false)` 固定第一页
+
+- **Decision**: 游标推进等价于"永远查第一页",每路 `new Page<>(1, size+1, false)`(关闭 count 查询),取 size+1 行判定 hasMore。
+- **Rationale**: 游标模式下页码恒为 1;关 count 省一半查询;hasMore 由第 size+1 行是否存在判定。
+- **Alternatives**: PageHelper.startPage——项目新代码已统一 MP 分页(探索结论 7),且 PageHelper 的 count 对本场景多余,否决。
+
+## 探索报告关键事实存档(实现时引用)
+
+- 外卖列表接口:`GET /system/userOrder/orderList`(`UserOrderController.java:536-601`),tab 条件 555-582 行;返回 MP Page 原始实体。
+- 闪送列表接口:`GET /system/flashDelivery/orders`(`FlashDeliveryUserController.java:55-62` → `FlashDeliveryApplicationService.userOrders`),无 tab,role=sender/receiver。
+- 状态字段:外卖 `state(0待处理1已接单2已出餐3已完成4已取消)` + `deliveryStatus(0/1/2/3)` + `payStatus(0未付1已付2已退)` + `afterSaleStatus(0-6)`;payType 常量 `OrderLifecycleService.java:33-40`(OFFLINE=1/OMG=2/LINE=3/CASH=4/APPLE_PAY=5/TRANSFER=6)。闪送 `FlashDeliveryStatus` 六态枚举。
+- 两表主键均为独立 AUTO_INCREMENT BIGINT,**数值必然重叠**,全局键必须带 source。
+- 外卖业务单号 `ddId` 为客户端传入 String;闪送 `orderNo` 服务端生成(FD+UUID24)。
+- 时间字段:外卖 `cretim`、闪送 `create_time`,均 Date(秒级)。

+ 116 - 0
specs/030-unified-order-list/spec.md

@@ -0,0 +1,116 @@
+# Feature Specification: 用户端统一订单列表(外卖+闪送合并)
+
+**Feature Branch**: `030-unified-order-list`
+
+**Created**: 2026-09-20
+
+**Status**: Draft
+
+**Input**: User description: "用户端订单列表现在是外卖订单和闪送订单分开的。现在需要做到一起,都放到同样的列表中。列表分为全部 待付款 进行中 已完成 已取消 退款/售后;要求也能外卖、闪送筛选。"(方案经头脑风暴确认为 Java 读时聚合:不用中间表、不用视图、零数据库结构变更)
+
+## User Scenarios & Testing *(mandatory)*
+
+### User Story 1 - 统一列表浏览与混排 (Priority: P1)
+
+用户打开订单页,看到的不再需要区分"外卖订单""闪送订单"两个入口,而是一个按下单时间倒序混排的统一列表:外卖单和闪送单交替出现,各自用现有的卡片样式渲染(外卖卡显示店铺/商品/配送信息,闪送卡显示取收地址/物品/小费)。列表支持无限滚动加载;顶部支持来源筛选(全部/外卖/闪送)。点击任意一单跳转到该单现有的详情页。
+
+**Why this priority**: 这是功能的核心价值——"像淘宝京东一样所有订单一个列表";没有它其余无意义。
+
+**Independent Test**: 构造同一用户名下不同时间的外卖单与闪送单,验证混排顺序严格按时间倒序、两种卡片字段齐全、来源筛选生效、滚动到底 hasMore=false。
+
+**Acceptance Scenarios**:
+
+1. **Given** 用户名下同时存在外卖单与闪送单,**When** 打开统一列表(tab=全部,来源=全部),**Then** 两类订单按下单时间倒序混排,卡片按来源用各自样式渲染
+2. **Given** 滚动到列表底部,**When** 继续上拉,**When** 服务端按游标返回下一页,**Then** 所有订单在全部翻页中恰好出现一次(不漏不重)
+3. **Given** 来源筛选选择"外卖"(或"闪送"),**When** 查询,**Then** 仅返回该来源订单,滚动分页同样完整
+4. **Given** 点击列表项,**When** 前端跳转,**Then** 凭列表提供的订单键直达该来源现有详情页
+
+---
+
+### User Story 2 - 六个状态 tab 的正确归类 (Priority: P2)
+
+列表提供 6 个 tab:全部 / 待付款 / 进行中 / 已完成 / 已取消 / 退款·售后。归类规则:外卖沿用现有列表的判定语义(在线支付未付=待付款;履约中=进行中;state=3=已完成;state=4=已取消;有售后记录=退款·售后);闪送按状态机映射(非终态=进行中;已完成=已完成;已取消=已取消;闪送无待付款、无售后,这两个 tab 天然只含外卖单)。
+
+**Why this priority**: tab 是用户找单的主要手段;语义与现有列表不一致会造成"这边看得到那边看不到"的困惑。
+
+**Independent Test**: 构造覆盖每个 tab 的两域订单样本(含货到付款单、售后中的单、闪送各状态单),逐 tab 断言归属。
+
+**Acceptance Scenarios**:
+
+1. **Given** 外卖在线支付单已创建未支付,**When** 查待付款 tab,**Then** 该单出现;闪送单永不出现于此 tab
+2. **Given** 闪送单处于待接单/已接单/已取件/已送达任一状态,**When** 查进行中 tab,**Then** 该单出现;已完成的闪送单只出现在已完成 tab
+3. **Given** 外卖单处于售后流程(售后标记>0),**When** 查进行中/已完成/已取消 tab,**Then** 该单均不出现,仅出现在退款·售后 tab
+4. **Given** 货到付款的外卖单(创建即视为已付),**When** 查待付款 tab,**Then** 不出现(沿用现有语义:待付款仅限在线支付未付)
+
+---
+
+### User Story 3 - 闪送"我收的"订单进列表 (Priority: P3)
+
+闪送存在收件人视角(现有闪送列表有"我发的/我收的")。统一列表将"我收的"闪送单一并纳入:同一闪送单会出现在寄件人与收件人两个账号的列表中,列表项携带角色标记(寄件/收件),前端据此渲染"我收的"标签。用户自己寄给自己的单只出现一条(判寄件人角色)。
+
+**Why this priority**: 增量视角,保证统一列表后用户不丢失原闪送列表的"我收的"能力;不阻塞主流程。
+
+**Independent Test**: 构造 A 寄给 B 的闪送单,验证 A、B 两账号的统一列表均含该单且角色标记分别为寄件、收件;构造 A 寄给 A 的单,验证 A 列表仅一条且角色为寄件。
+
+**Acceptance Scenarios**:
+
+1. **Given** 闪送单寄件人=A 收件人=B,**When** B 查统一列表,**Then** 该单出现且角色标记=收件
+2. **Given** 同上,**When** A 查统一列表,**Then** 该单出现且角色标记=寄件
+3. **Given** 闪送单寄件人=收件人=A,**When** A 查统一列表,**Then** 该单仅出现一次,角色标记=寄件
+
+---
+
+### Edge Cases
+
+- **滚动加载的同秒跨源并列**:外卖单与闪送单下单时间同一秒时,排序必须有确定规则(来源序+单ID决胜),翻页不漏不重。
+- **待付款/退款·售后 tab + 闪送**:闪送侧恒为空集(正确语义,非错误);查询实现上应直接跳过闪送路,不发无效查询。
+- **游标参数非法**(格式错误/伪造):返回明确的参数错误,不按首页静默处理(避免用户端死循环)。
+- **每页条数**:缺省 10,服务端钳制 1-50,超范围取边界值。
+- **售后中的外卖单**:从进行中/已完成/已取消 tab 剔除,只归退款·售后(沿用现有列表语义)。
+- **空列表用户**:新用户无任何订单,各 tab 返回空列表 + hasMore=false,不报错。
+- **旧接口回归**:现有外卖列表接口、闪送列表接口(其它页面仍在用)行为完全不变。
+- **列表项与详情页一致性**:列表提供的订单键(外卖业务单号 / 闪送订单ID)必须能直达现有详情接口。
+
+## Requirements *(mandatory)*
+
+### Functional Requirements
+
+- **FR-001**: 系统 MUST 提供统一订单列表查询接口:登录用户按下单时间倒序查看名下全部外卖与闪送订单的混排结果,支持无限滚动(游标分页:请求携带上一页返回的 cursor,响应含 list/hasMore/nextCursor)。
+- **FR-002**: 列表 MUST 提供 6 个 tab 筛选(all/unpaid/active/completed/cancelled/refund,枚举名与现有外卖列表一致)且归类满足:外卖沿用现有判定(在线支付未付且无售后→待付款;履约中且无售后→进行中;已完成且无售后→已完成;已取消且无售后→已取消;售后标记>0→退款·售后);闪送按状态映射(非终态→进行中;已完成→已完成;已取消→已取消;待付款/退款·售后 tab 闪送侧为空)。
+- **FR-003**: 列表 MUST 支持来源筛选(all/takeaway/flash 三值),与 tab 可自由组合。
+- **FR-004**: 游标分页 MUST 保证翻页完整性:全部翻页遍历中每个订单恰好出现一次,含同秒跨源并列场景(确定性决胜规则)。
+- **FR-005**: 统一列表项 MUST 携带:来源类型、角色标记(仅闪送:寄件/收件)、订单标识与业务单号(详情跳转键)、统一状态(tab 同款枚举)、下单时间、金额与币种、售后标记,以及两个来源各自的卡片渲染字段块(外卖块含订单类型/状态族原值/店铺名等;闪送块对齐现有闪送列表卡片字段子集)。
+- **FR-006**: 闪送订单 MUST 同时按寄件人与收件人视角纳入列表并以角色字段区分;寄件人=收件人的单仅出现一条(判寄件人)。
+- **FR-007**: 外卖 tab 判定规则 MUST 与现有外卖列表接口语义一致(同一谓词来源,避免两处规则漂移)。
+- **FR-008**: 现有外卖列表接口与闪送列表接口 MUST 保持行为完全不变(零回归)。
+- **FR-009**: 本功能 MUST NOT 引入任何数据库结构变更(不建中间表、不建视图);上线前仅核实既有索引(外卖:用户+下单时间;闪送:寄件人+下单时间、收件人)是否覆盖查询,若缺按《数据库变更管理》流程补。
+- **FR-010**: 服务层自动化测试 MUST 覆盖:tab 归类(两域×6 tab×边界样本)、游标归并与翻页完整性(含同秒并列)、接口参数契约。
+- **FR-011**: 单次滚动请求 MUST 在每个来源至多一次索引查询内完成(两路各取一页归并);待付款/退款·售后 tab MUST NOT 发起闪送查询。
+
+### Key Entities *(include if feature involves data)*
+
+- **统一列表项(组合视图,无新表)**:公共字段(来源类型/角色/订单标识/业务单号/统一状态/时间/金额/币种/售后标记)+ 外卖渲染块 + 闪送渲染块。只读组合,数据仍存于两张源表。
+- **游标(Cursor)**:`下单时间_来源_单ID` 不透明字符串,服务端生成、前端透传,用于严格 keyset 分页。
+- **tab 归类谓词(表驱动)**:同一份规则既生成数据库查询条件又做内存判定,是归类语义的唯一事实源。
+- **复用实体(只读)**:外卖订单(含订单状态/配送状态/支付状态/售后标记四字段族)、闪送订单(状态机六态 + 角色两字段)。本功能不修改任何源表数据与写入路径。
+
+## Success Criteria *(mandatory)*
+
+### Measurable Outcomes
+
+- **SC-001**: 用户在单一列表按时间倒序看到全部外卖与闪送订单,滚动到底后无遗漏(与两域订单总数一致)。
+- **SC-002**: 任一 tab 与来源筛选组合下,翻页遍历每个订单恰好出现一次(自动化用例验证)。
+- **SC-003**: 混排正确性可验证:返回列表内任意相邻两项的下单时间非递增。
+- **SC-004**: 现有外卖/闪送两个列表接口回归零差异(原测试全绿 + 行为对照)。
+- **SC-005**: 单次滚动请求的耗时与现有单域列表查询处于同一量级,不因合并明显变慢。
+- **SC-006**: 全部新增自动化测试通过,覆盖 SC-002/SC-003 的可判定断言。
+
+## Assumptions
+
+- 前端为滚动加载(已确认),接口只提供游标分页,不提供页码模式;首页请求不带 cursor。
+- tab 枚举与来源枚举返回 code,展示文案由前端 i18n 渲染(与现有列表一致的分工)。
+- 卡片渲染复用现有两种样式,后端字段块与现有列表接口返回等价信息(含店铺名批量回填)。
+- 不做 tab 数量角标(本期未提出;后续可按需增加计数接口)。
+- 闪送无支付状态与售后概念(spec 024 FR-058),故待付款/退款·售后两 tab 闪送侧为空是正确行为。
+- 商家端、骑手端订单列表不在本功能范围;统一列表只服务用户端 App。
+- 架构决策(头脑风暴已确认):读时聚合,否决中间表(双写一致性风险、写入点过多)与数据库视图(状态映射埋 SQL 不可测、变更需人工 DDL);将来若演进为订单搜索服务,仅替换读实现,接口契约不变。

+ 149 - 0
specs/030-unified-order-list/tasks.md

@@ -0,0 +1,149 @@
+# Tasks: 用户端统一订单列表
+
+**Input**: Design documents from `/specs/030-unified-order-list/`(spec.md / plan.md / research.md / data-model.md / contracts/api-contract.md / quickstart.md)
+
+**Prerequisites**: plan.md (required), spec.md (required for user stories), research.md, data-model.md, contracts/
+
+**Tests**: spec(FR-010/SC-006)明确要求自动化测试,TDD:先红后绿。
+
+**Organization**: Tasks are grouped by user story to enable independent implementation and testing of each story.
+
+## Format: `[ID] [P?] [Story] Description`
+
+- **[P]**: Can run in parallel (different files, no dependencies)
+- **[Story]**: Which user story this task belongs to (e.g. US1, US2, US3)
+- Include exact file paths in descriptions
+
+## 构建与测试命令(所有验证任务通用)
+
+```bash
+export JAVA_HOME="$HOME/.jdks/graalvm-jdk-21.0.12+7.1"
+export PATH="$JAVA_HOME/bin:$PATH"
+mvn -pl ruoyi-admin -am test -Dtest='XxxTest' -Dsurefire.failIfNoSpecifiedTests=false
+```
+
+已知基线红测试:flash payType 白名单(见项目记忆),与本 feature 无关,不修。
+
+---
+
+## Phase 1: Setup (Shared Infrastructure)
+
+- [x] T001 基线验证:`mvn -pl ruoyi-admin -am compile` 通过;`git status` 工作区干净(test 分支同步状态)(2026-09-21 编译通过;工作区仅本 feature 文档未提交,代码零改动)
+
+---
+
+## Phase 2: Foundational (Blocking Prerequisites)
+
+**Purpose**: 游标、来源枚举、tab 谓词——三个用户故事共同依赖的纯逻辑件(全部可独立单测)
+
+- [x] T002 新增 i18n 错误 key:`no.unifiedorder.cursor.invalid`(游标非法)、`no.unifiedorder.param.invalid`(tab/source 非法)加到 `ruoyi-admin/src/main/resources/i18n/` 全部 6 个语言文件(**用 raw 字节模式 perl 脚本追加,沿用 029 教训,勿用 perl -i -pe 直接改写**);先 `grep -n "no.unifiedorder\|no.user.login.success" messages.properties` 找锚点追加(2026-09-21 完成:锚点 no.user.login.success 行后插入,git diff 每文件 +2 行,CR 计数=行数,UTF-8 单次编码)
+- [x] T003 TDD 先红:新建 `ruoyi-admin/src/test/java/com/ruoyi/app/unifiedorder/UnifiedCursorTest.java`——① 生成与解析往返一致 ② 非法输入(null/垃圾串/负数/越界)抛参数异常 ③ 排序全序比较器(时间 DESC → flash<takeaway → id DESC)④ 各源推进条件布尔(本源序 vs cursor 源序)判定正确(2026-09-21 编译失败红→绿)
+- [x] T004 [P] 新建 `ruoyi-admin/src/main/java/com/ruoyi/app/unifiedorder/OrderSource.java`(takeaway/flash 枚举 + 字典序)与 `UnifiedCursor.java`(生成/解析/校验/比较器/各源推进条件),使 T003 转绿(2026-09-21 UnifiedCursorTest 7/7 绿)
+- [x] T005 TDD 先红后绿:新建 `UnifiedOrderTabTest.java`(两域内存判定全覆盖:外卖 5 tab 条件样本——含货到付款不进 unpaid、payType=1+自取/堂食进 active、售后单从 active/completed/cancelled 剔除、payStatus=2 归 refund;闪送六态映射;all 不过滤),然后新建 `ruoyi-admin/src/main/java/com/ruoyi/app/unifiedorder/UnifiedOrderTab.java`(枚举内聚 `posOrderWrapper()/matches(PosOrder)` 与 `flashWrapper()/matches(FlashDeliveryOrder)`,条件常量同一份)转绿。**规则照抄 data-model.md §3,不得自行发明**(2026-09-21 13/13 绿;flashWrapper 对 unpaid/refund 抛 IllegalStateException 防误用,matches 恒 false)
+
+**Checkpoint**: 纯逻辑件全绿;US1/US2/US3 可并行开始
+
+---
+
+## Phase 3: User Story 1 - 统一列表浏览与混排 (Priority: P1) 🎯 MVP
+
+**Goal**: 新接口 `/system/unifiedOrder/list` 两路查询 + 归并 + 游标滚动 + 来源筛选 + 统一卡片
+
+**Independent Test**: 构造混排样本(不依赖 tab 语义细节),断言倒序混排、翻页完整、hasMore/nextCursor 正确
+
+### Tests for User Story 1(先写、先红)
+
+- [x] T006 TDD 先红:新建 `ruoyi-admin/src/test/java/com/ruoyi/app/unifiedorder/UnifiedOrderQueryServiceTest.java`(Mockito mock 两个 mapper,沿用 029 的 ReflectionTestUtils 注入模式):① 两路各查 size+1、归并后恰返回 size 条且时间非递增 ② 第 size+1 行存在 → hasMore=true + nextCursor=末条游标 ③ 滚动多轮遍历与样本全集一致(不漏不重,含同秒跨源并列构造)④ size 缺省 10、钳制 1-50 ⑤ source 单选时只调对应 mapper ⑥ tab=unpaid/refund 时不调闪送 mapper ⑦ 空结果 hasMore=false(2026-09-21 红→绿;构造器注入代替 setField,另加店铺名批量回填用例)
+- [x] T007 TDD 先红:新建 `UnifiedOrderQueryControllerTest.java`:① 反射断言 `@GetMapping("/list")` + 每参数 `@RequestParam` + token `@RequestHeader`(Controller 规范) ② tab/source 非法值返回 `no.unifiedorder.param.invalid` ③ cursor 非法返回 `no.unifiedorder.cursor.invalid` ④ 正常请求透传 Service 结果(2026-09-21 红→绿;加坏 token 防御用例)
+
+### Implementation for User Story 1
+
+- [x] T008 新建 `ruoyi-admin/src/main/java/com/ruoyi/app/unifiedorder/dto/UnifiedOrderListItem.java`(字段按 data-model.md §1:公共块 + takeaway 块 + flash 块,Lombok @Data)(2026-09-21 完成;另加 dto/UnifiedOrderPageView.java 作分页信封——plan 文件数"约 6"内允许)
+- [x] T009 新建 `ruoyi-admin/src/main/java/com/ruoyi/app/unifiedorder/UnifiedOrderQueryService.java`:两路 MP `new Page<>(1, size+1, false)` 查询(外卖 `PosOrderMapper`:userId+tab 条件+游标推进+`cretim DESC,id DESC`;闪送 `FlashDeliveryOrderMapper`:同构)→ Java 按 UnifiedCursor 全序归并 → 截取 size → DTO 组装(外卖批量回填 storeName:收集 shId 批量查 InfoUser 昵称)→ `unifiedStatus` 用 UnifiedOrderTab.matches 判定(all 查询下也要给)(2026-09-21 完成;UnifiedCursor 补 equals/hashCode 值语义)
+- [x] T010 新建 `ruoyi-admin/src/main/java/com/ruoyi/app/unifiedorder/UnifiedOrderQueryController.java`:`GET /system/unifiedOrder/list`,`@RequestHeader String token` + 显式 `@RequestParam`(tab/source/cursor/size);userId 解析方式复用 `UserOrderController.orderList` 现有 token 解析实现;参数校验用 MessageUtils 错误 key(2026-09-21 完成;@Auth+@Anonymous 照抄 orderList,坏 token 返回参数错误而非 500 崩溃)
+- [x] T011 转绿:`UnifiedOrder*Test` 全绿;`mvn -pl ruoyi-admin -am test -Dtest='UnifiedOrder*'` 通过(2026-09-21 35/35 绿)
+
+**Checkpoint**: MVP 可演示(全 tab 混排滚动 + 来源筛选,用测试数据)
+
+---
+
+## Phase 4: User Story 2 - tab 归类与 SQL 条件一致性 (Priority: P2)
+
+**Goal**: 6 tab 归类语义与现有外卖列表一致,且 SQL 条件与内存判定不漂移
+
+**Independent Test**: 谓词边界样本全量 + wrapper SQL 片段断言
+
+- [x] T012 TDD 先红:在 `UnifiedOrderTabTest` 追加 wrapper 断言用例:对每个 tab 断言 `posOrderWrapper().getCustomSqlSegment()` / `flashWrapper().getCustomSqlSegment()` 含预期条件片段(如 unpaid 含 pay_status 与 pay_type IN、refund 含 after_sale_status、闪送 active 含四个状态枚举值),防止 SQL 与 matches() 漂移(2026-09-21 完成,用 getSqlSegment() 求片段;注意 MP paramNameValuePairs 为懒填充,须先 getSqlSegment() 再读参数)
+- [x] T013 若 T012 发现 wrapper 片段与预期不符,修正 `UnifiedOrderTab.java` 条件构造(常量同源),转绿(无需修正:全部片段断言一次通过,零漂移)
+- [x] T014 在 `UnifiedOrderQueryServiceTest` 追加组合用例:tab×source 组合抽查(refund+flash 空路、active+takeaway 只外卖)转绿(2026-09-21 通过)
+
+**Checkpoint**: 归类语义有双保险(内存判定 + SQL 片段断言)
+
+---
+
+## Phase 5: User Story 3 - 闪送收件人视角 (Priority: P3)
+
+**Goal**: "我收的"闪送单进统一列表并带角色标记
+
+**Independent Test**: mock 断言闪送路 wrapper 含 `(user_id = ? OR receiver_user_id = ?)`,role 判定正确
+
+- [x] T015 TDD 先红:`UnifiedOrderQueryServiceTest` 追加:① 闪送路查询条件含 receiver_user_id OR(捕获 wrapper 断言)② A 寄 B 的单在 B 的查询结果中 role=receiver、A 的结果中 role=sender ③ user_id=receiver_user_id 同一人时结果仅一条且 role=sender ④ 渲染块含 role 字段(2026-09-21 红:临时剥离 OR/role 后 2 用例如期失败,self-send 基线通过)
+- [x] T016 实现:闪送路查询条件加 `receiver_user_id` OR;`UnifiedOrderQueryService` 组装 flash 项时判定 role(user_id 命中优先 sender)→ 转绿(2026-09-21 复原转绿,48/48)
+
+**Checkpoint**: 三条故事全部可独立验收
+
+---
+
+## Phase 6: Polish & Cross-Cutting Concerns
+
+- [x] T017 按 data-model.md §5 在 `updatesql/sql.md` 追加三条索引语句(**标注"核实后执行:已存在则跳过"**,不直接执行)(2026-09-21 追加,CRLF 保持)
+- [x] T018 契约一致性核对:逐条比对 `contracts/api-contract.md`(接口/返回/字段块/tab 语义/错误)与实现,偏差改实现或改契约注明(2026-09-21 全对齐;3 处文档偏差已回写:data-model §4 去掉 del_flag(实体无此字段且现有列表不过滤,保持零漂移)、plan.md 测试文件名对齐 UnifiedOrderTabTest、plan.md 补 UnifiedOrderPageView)
+- [x] T019 全量验证:`mvn -pl ruoyi-admin -am test -Dtest='UnifiedOrder*'` 全绿 + `mvn -pl ruoyi-admin -am test` 全模块(旧接口零回归,基线红除外)+ compile 通过 + quickstart 手动场景核对;确认未触碰废弃代码清单文件(2026-09-21:UnifiedOrder* 48/48 绿;全模块 552 测试唯一失败=已知基线红 createRejectsUnsupportedPayType,零新增失败;未修改任何既有 Java 文件;quickstart 手动 curl 场景待开发者起服务后核对)
+- [x] T020 收尾:`git status` 对照 plan.md Project Structure 一致;交付说明列明:索引核实事项、契约文档移交前端、旧接口保留说明(2026-09-21 结构一致:7 主文件 + 4 测试 + i18n +2 行×6 + sql.md 追加)
+
+---
+
+## Dependencies & Execution Order
+
+### Phase Dependencies
+
+- **Phase 1**:无依赖
+- **Phase 2**:依赖 Phase 1;**阻塞所有故事**(cursor/谓词是地基)
+- **US1 (Phase 3)**:依赖 Phase 2;US2/US3 都在其 Service 之上追加测试与实现
+- **US2 (Phase 4)**:依赖 Phase 2(谓词);与 US1 的 Service 部分耦合(wrapper 断言)
+- **US3 (Phase 5)**:依赖 Phase 3 的 Service 存在
+- **Polish (Phase 6)**:依赖全部故事完成
+
+### 单人执行顺序(推荐)
+
+T001 → T002 → T003/T004 → T005 → T006-T011(US1 一体)→ T012-T014(US2)→ T015-T016(US3)→ T017-T020
+
+### Parallel Opportunities
+
+- Phase 2 内 T004(cursor 文件)与 T005(tab 文件)不同文件可并行
+- T006 与 T007(两个测试文件)可并行写
+
+---
+
+## Implementation Strategy
+
+### MVP First (Phase 1-3)
+
+数据层纯逻辑件 + Service/Controller 主链路完成后,统一列表已可端到端演示(全部 tab + 来源筛选 + 滚动)。
+
+### Incremental Delivery
+
+1. Phase 2 → 纯逻辑件(可先行 review)
+2. Phase 3 → MVP 混排列表
+3. Phase 4 → 归类双保险
+4. Phase 5 → 收件视角补齐
+5. Phase 6 → 索引/契约/回归/交付
+
+---
+
+## Notes
+
+- 所有新文件 Lombok + 现有注释风格;保留 CRLF;Java 注释无 `*/` 序列
+- **不修改任何既有订单写路径与旧列表接口**(FR-008 回归靠现有测试守护)
+- 错误消息一律 MessageUtils;tab/source 枚举校验在 Controller 层
+- 闪送路 wrapper 的 OR 条件用 MP `and(w -> w.eq(...).or().eq(...))` 嵌套,注意与 tab 条件的括号层级

+ 10 - 0
updatesql/sql.md

@@ -1613,3 +1613,13 @@ CREATE TABLE info_user_device (
   PRIMARY KEY (device_id)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='设备-手机号信任表(029)';
 ```
+
+## 2026-09-21 统一订单列表查询索引(030-unified-order-list)
+
+```sql
+-- 2026-09-21 统一订单列表查询索引(030-unified-order-list,核实后执行:已存在则跳过)
+-- 上线前先 SHOW INDEX 核实,缺哪条执行哪条;读时聚合按 用户/角色 + 时间游标扫描,有索引才是索引扫描
+ALTER TABLE pos_order ADD INDEX idx_user_cretim (user_id, cretim);
+ALTER TABLE flash_delivery_order ADD INDEX idx_user_ctime (user_id, create_time);
+ALTER TABLE flash_delivery_order ADD INDEX idx_receiver_ctime (receiver_user_id, create_time);
+```