浏览代码

merge: 合并 test-202609v1 到 test-202609v2

冲突解决(两分支并行演进的融合):
- UserOrderController: 取 v2 的 applyTabFilter 抽取版(逻辑与 v1 内联版一致);
  现金硬闸门保持 v1 放宽(堂食/自取放行,交由 031 PaymentMethodGateService 按商家勾选管控),避免旧闸门拦截 US7 现金下单
- InfoUserController/LineCallbackController: v2 的 LINE 三端架构为基,
  移植 v1 的 029 设备信任(deviceConfirm 分支仅限用户端,业务线 state 为 LINE 授权状态非 deviceId)
  oauthDeviceConfirm 适配 v2 约定:line_user 命名、bindingProviders 查找、findOauthBindingUser、canOauthLogin
- i18n: 以 v2 结构为基(默认包 zh_TW、无 vi),追加 v1 新增的 4 个 key(no.oauth.device.*、no.unifiedorder.*)×5 语言文件
- feature.json/CLAUDE.md: 取 v2(031 指针)
- messages_vi.properties: 保持 v2 的删除
- 测试适配: DeviceTrustFlowTest/LineCallbackDeviceTrustTest 按 v2 渠道桩调整;
  InfoUserControllerTest/LineCallbackControllerTest 注入 deviceTrustService 桩

验证: ruoyi-admin 722 tests 全部通过
qmj 3 天之前
父节点
当前提交
c682267528
共有 57 个文件被更改,包括 4718 次插入 和 10 次删除
  1. 1 1
      ruoyi-admin/src/main/java/com/ruoyi/app/order/OrderLifecycleService.java
  2. 2 2
      ruoyi-admin/src/main/java/com/ruoyi/app/order/PosOrderShOprateController.java
  3. 3 2
      ruoyi-admin/src/main/java/com/ruoyi/app/order/UserOrderController.java
  4. 56 0
      ruoyi-admin/src/main/java/com/ruoyi/app/unifiedorder/OrderSource.java
  5. 132 0
      ruoyi-admin/src/main/java/com/ruoyi/app/unifiedorder/UnifiedCursor.java
  6. 73 0
      ruoyi-admin/src/main/java/com/ruoyi/app/unifiedorder/UnifiedOrderQueryController.java
  7. 261 0
      ruoyi-admin/src/main/java/com/ruoyi/app/unifiedorder/UnifiedOrderQueryService.java
  8. 194 0
      ruoyi-admin/src/main/java/com/ruoyi/app/unifiedorder/UnifiedOrderTab.java
  9. 89 0
      ruoyi-admin/src/main/java/com/ruoyi/app/unifiedorder/dto/UnifiedOrderListItem.java
  10. 17 0
      ruoyi-admin/src/main/java/com/ruoyi/app/unifiedorder/dto/UnifiedOrderPageView.java
  11. 116 0
      ruoyi-admin/src/main/java/com/ruoyi/app/user/InfoUserController.java
  12. 23 1
      ruoyi-admin/src/main/java/com/ruoyi/app/user/LineCallbackController.java
  13. 29 2
      ruoyi-admin/src/main/java/com/ruoyi/app/user/RiderPositionController.java
  14. 3 0
      ruoyi-admin/src/main/java/com/ruoyi/app/user/dto/OAuthBindDto.java
  15. 21 0
      ruoyi-admin/src/main/java/com/ruoyi/app/user/dto/OAuthDeviceConfirmDto.java
  16. 3 0
      ruoyi-admin/src/main/java/com/ruoyi/app/user/dto/OAuthLoginDto.java
  17. 77 0
      ruoyi-admin/src/main/java/com/ruoyi/app/user/service/DeviceTrustService.java
  18. 6 0
      ruoyi-admin/src/main/resources/i18n/messages.properties
  19. 6 0
      ruoyi-admin/src/main/resources/i18n/messages_en_US.properties
  20. 6 0
      ruoyi-admin/src/main/resources/i18n/messages_th_TH.properties
  21. 6 0
      ruoyi-admin/src/main/resources/i18n/messages_zh_CN.properties
  22. 6 0
      ruoyi-admin/src/main/resources/i18n/messages_zh_TW.properties
  23. 4 2
      ruoyi-admin/src/test/java/com/ruoyi/app/order/OrderListPaymentFilterTest.java
  24. 142 0
      ruoyi-admin/src/test/java/com/ruoyi/app/unifiedorder/UnifiedCursorTest.java
  25. 153 0
      ruoyi-admin/src/test/java/com/ruoyi/app/unifiedorder/UnifiedOrderQueryControllerTest.java
  26. 451 0
      ruoyi-admin/src/test/java/com/ruoyi/app/unifiedorder/UnifiedOrderQueryServiceTest.java
  27. 284 0
      ruoyi-admin/src/test/java/com/ruoyi/app/unifiedorder/UnifiedOrderTabTest.java
  28. 397 0
      ruoyi-admin/src/test/java/com/ruoyi/app/user/DeviceTrustFlowTest.java
  29. 4 0
      ruoyi-admin/src/test/java/com/ruoyi/app/user/InfoUserControllerTest.java
  30. 4 0
      ruoyi-admin/src/test/java/com/ruoyi/app/user/LineCallbackControllerTest.java
  31. 173 0
      ruoyi-admin/src/test/java/com/ruoyi/app/user/LineCallbackDeviceTrustTest.java
  32. 181 0
      ruoyi-admin/src/test/java/com/ruoyi/app/user/RiderPositionControllerTest.java
  33. 137 0
      ruoyi-admin/src/test/java/com/ruoyi/app/user/service/DeviceTrustServiceTest.java
  34. 43 0
      ruoyi-system/src/main/java/com/ruoyi/system/domain/InfoUserDevice.java
  35. 2 0
      ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/UserDTO.java
  36. 13 0
      ruoyi-system/src/main/java/com/ruoyi/system/mapper/InfoUserDeviceMapper.java
  37. 2 0
      specs/027-offline-transfer-payment/data-model.md
  38. 22 0
      specs/027-offline-transfer-payment/spec.md
  39. 2 0
      specs/027-offline-transfer-payment/tasks.md
  40. 35 0
      specs/029-device-trust-phone-bind/checklists/requirements.md
  41. 90 0
      specs/029-device-trust-phone-bind/contracts/api-contract.md
  42. 68 0
      specs/029-device-trust-phone-bind/data-model.md
  43. 103 0
      specs/029-device-trust-phone-bind/frontend-guide.md
  44. 98 0
      specs/029-device-trust-phone-bind/plan.md
  45. 82 0
      specs/029-device-trust-phone-bind/quickstart.md
  46. 59 0
      specs/029-device-trust-phone-bind/research.md
  47. 110 0
      specs/029-device-trust-phone-bind/spec.md
  48. 174 0
      specs/029-device-trust-phone-bind/tasks.md
  49. 35 0
      specs/030-unified-order-list/checklists/requirements.md
  50. 87 0
      specs/030-unified-order-list/contracts/api-contract.md
  51. 112 0
      specs/030-unified-order-list/data-model.md
  52. 92 0
      specs/030-unified-order-list/plan.md
  53. 56 0
      specs/030-unified-order-list/quickstart.md
  54. 61 0
      specs/030-unified-order-list/research.md
  55. 116 0
      specs/030-unified-order-list/spec.md
  56. 163 0
      specs/030-unified-order-list/tasks.md
  57. 33 0
      updatesql/sql.md

+ 1 - 1
ruoyi-admin/src/main/java/com/ruoyi/app/order/OrderLifecycleService.java

@@ -32,7 +32,7 @@ public class OrderLifecycleService {
 
     public static final String PAY_TYPE_OFFLINE = "1";
     public static final String PAY_TYPE_OMG = "2";
-    /** 现金支付:仅商家创建订单可用(客户端下单拒绝);创建时未支付,商家App确认收款后置已支付 */
+    /** 现金支付:堂食/自取用户可直接下单;外送仅商家创建订单可用。创建时未支付,商家App确认收款后置已支付 */
     public static final String PAY_TYPE_CASH = "4";
     /** 線下轉賬支付:用户线下银行转账,商家确认收款(payStatus 从 0 置 1)后解锁骑手接单 */
     public static final String PAY_TYPE_TRANSFER = "6";

+ 2 - 2
ruoyi-admin/src/main/java/com/ruoyi/app/order/PosOrderShOprateController.java

@@ -826,9 +826,9 @@ public class PosOrderShOprateController extends BaseController {
         switch (tab == null ? "" : tab) {
             case "pending":
                 wrapper.eq(PosOrder::getState, 0L)
-                       // 现金自取/堂食可直接待受理;在线支付的自取/堂食必须先完成付款。
+                       // 到付/现金自取/堂食可直接待受理;在线支付的自取/堂食必须先完成付款。
                        .and(w -> w.eq(PosOrder::getPayStatus, 1L)
-                               .or(cash -> cash.eq(PosOrder::getPayType, "1")
+                               .or(cash -> cash.in(PosOrder::getPayType, "1", "4")
                                        .in(PosOrder::getType, 1L, 2L)));
                 break;
             case "preparing":

+ 3 - 2
ruoyi-admin/src/main/java/com/ruoyi/app/order/UserOrderController.java

@@ -167,8 +167,9 @@ public class UserOrderController extends BaseController {
             }
             shdz= JSON.toJSONString(usadd);
         }
-        // 现金支付(4)仅商家创建订单可用,客户端下单直接拒绝
-        if (OrderLifecycleService.PAY_TYPE_CASH.equals(input.getPaymentMethod())) {
+        // 现金支付(4):堂食/自取放行(可用性由下方 PaymentMethodGateService 按商家勾选管控);外送仍仅商家创建订单可用
+        if (OrderLifecycleService.PAY_TYPE_CASH.equals(input.getPaymentMethod())
+                && input.getType() != 1 && input.getType() != 2) {
             throw new ServiceException(MessageUtils.message("no.paytype.cash.merchant.only"));
         }
         // === 发票意图(010 Phase 11)===

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

@@ -0,0 +1,56 @@
+package com.ruoyi.app.unifiedorder;
+
+/**
+ * 统一订单列表来源枚举;编码字典序(dinein 小于 flash 小于 pickup 小于 takeaway)是同秒跨源并列时的确定性决胜依据。
+ * dinein/pickup 是 pos_order 按 type 拆出的子集筛选,仅在来源单选查询出现,
+ * 混排查询(source=all)的行与游标只会有 takeaway/flash 两种编码。
+ */
+public enum OrderSource {
+    /** 外卖订单(pos_order,仅外送单 type=0)。 */
+    TAKEAWAY("takeaway", 0L),
+    /** 闪送订单(flash_delivery_order)。 */
+    FLASH("flash", null),
+    /** 堂食订单(pos_order type=2)。 */
+    DINEIN("dinein", 2L),
+    /** 自取订单(pos_order type=1)。 */
+    PICKUP("pickup", 1L);
+
+    private final String code;
+    /** pos_order.type 过滤值;null 表示不走 pos_order 路(闪送)。 */
+    private final Long posOrderType;
+
+    OrderSource(String code, Long posOrderType) {
+        this.code = code;
+        this.posOrderType = posOrderType;
+    }
+
+    /** 前端契约中的来源编码。 */
+    public String code() {
+        return code;
+    }
+
+    /** 该来源是否查询 pos_order 外卖路(takeaway/dinein/pickup)。 */
+    public boolean queriesPosOrder() {
+        return posOrderType != null;
+    }
+
+    /** 外卖路的 pos_order.type 过滤值;仅 queriesPosOrder() 为真时调用。 */
+    public Long posOrderType() {
+        return posOrderType;
+    }
+
+    /** 按契约编码解析;非法值返回 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/dinein/pickup/flash,默认 all;takeaway 仅外送单,dinein=堂食,pickup=自取
+     * @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));
+    }
+}

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

@@ -0,0 +1,261 @@
+package com.ruoyi.app.unifiedorder;
+
+import com.alibaba.fastjson2.JSONArray;
+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.PosOrder;
+import com.ruoyi.system.domain.PosStore;
+import com.ruoyi.system.domain.flash.FlashDeliveryOrder;
+import com.ruoyi.system.mapper.PosOrderMapper;
+import com.ruoyi.system.mapper.PosStoreMapper;
+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 闪送侧恒空,直接跳过闪送路;来源单选时跳过另一路。
+ * dinein/pickup 是外卖路按 pos_order.type 的子集筛选(堂食=2、自取=1、takeaway=0 纯外送),
+ * 其游标编码相应为 dinein/pickup,同源翻页精确到 id 决胜。
+ */
+@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 PosStoreMapper posStoreMapper;
+
+    public UnifiedOrderQueryService(PosOrderMapper posOrderMapper,
+                                    FlashDeliveryOrderMapper flashOrderMapper,
+                                    PosStoreMapper posStoreMapper) {
+        this.posOrderMapper = posOrderMapper;
+        this.flashOrderMapper = flashOrderMapper;
+        this.posStoreMapper = posStoreMapper;
+    }
+
+    /** 查询统一列表: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.queriesPosOrder()) {
+            OrderSource takeawayTag = source == null ? OrderSource.TAKEAWAY : source;
+            Page<PosOrder> page = posOrderMapper.selectPage(
+                    new Page<>(1, limit + 1, false), takeawayWrapper(userId, tab, cursor, source));
+            for (PosOrder order : page.getRecords()) {
+                rows.add(new Row(UnifiedCursor.of(safeTime(order.getCretim()), takeawayTag, 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, PosStore> stores = storesOf(displayed);
+        List<UnifiedOrderListItem> items = new ArrayList<>(displayed.size());
+        for (Row row : displayed) {
+            items.add(row.takeaway() != null
+                    ? takeawayItem(row.takeaway(), stores)
+                    : 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;
+    }
+
+    /** 外卖路条件:用户 + 来源 type 过滤 + tab 谓词 + 游标推进,排序 cretim DESC, id DESC;source 为 all 时不限 type。 */
+    private LambdaQueryWrapper<PosOrder> takeawayWrapper(Long userId, UnifiedOrderTab tab, UnifiedCursor cursor,
+                                                         OrderSource source) {
+        LambdaQueryWrapper<PosOrder> wrapper = tab.posOrderWrapper()
+                .eq(PosOrder::getUserId, userId);
+        if (source != null) {
+            wrapper.eq(PosOrder::getType, source.posOrderType());
+        }
+        wrapper.orderByDesc(PosOrder::getCretim)
+                .orderByDesc(PosOrder::getId);
+        // 游标推进按本路查询源判定:混排(source=all)按 takeaway,单选按所选源
+        OrderSource advanceSource = source == null ? OrderSource.TAKEAWAY : source;
+        if (cursor != null) {
+            Date time = new Date(cursor.getTimeMillis());
+            if (cursor.includesSameTimeAnyId(advanceSource)) {
+                wrapper.le(PosOrder::getCretim, time);
+            } else if (cursor.includesSameTimeLowerId(advanceSource)) {
+                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;
+    }
+
+    /** 外卖条目按门店 ID 批量查 pos_store,一次回填店铺名称/头像。 */
+    private Map<Long, PosStore> storesOf(List<Row> displayed) {
+        Set<Long> mdIds = new HashSet<>();
+        for (Row row : displayed) {
+            if (row.takeaway() != null && row.takeaway().getMdId() != null) {
+                mdIds.add(row.takeaway().getMdId());
+            }
+        }
+        Map<Long, PosStore> stores = new HashMap<>();
+        if (!mdIds.isEmpty()) {
+            for (PosStore store : posStoreMapper.selectBatchIds(mdIds)) {
+                if (store != null && store.getId() != null) {
+                    stores.put(store.getId().longValue(), store);
+                }
+            }
+        }
+        return stores;
+    }
+
+    private UnifiedOrderListItem takeawayItem(PosOrder order, Map<Long, PosStore> stores) {
+        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());
+        // 店铺已删或 mdId 缺失时回退订单创建时的快照 posName/logo
+        PosStore store = order.getMdId() == null ? null : stores.get(order.getMdId());
+        item.setStoreName(store != null ? store.getPosName() : order.getPosName());
+        item.setStoreAvatar(store != null ? store.getLogo() : order.getLogo());
+        item.setGoodsCount(takeawayGoodsCount(order.getFood()));
+        item.setCollectPayment(order.getCollectPayment());
+        return item;
+    }
+
+    /** 商品件数:food 快照数组各行 number 求和;food 为空或非法返回 null。 */
+    private Integer takeawayGoodsCount(String food) {
+        if (food == null || food.isBlank()) {
+            return null;
+        }
+        try {
+            JSONArray rows = JSONArray.parseArray(food);
+            int count = 0;
+            for (int i = 0; i < rows.size(); i++) {
+                Integer num = rows.getJSONObject(i).getInteger("number");
+                if (num != null) {
+                    count += num;
+                }
+            }
+            return count;
+        } catch (RuntimeException e) {
+            return null;
+        }
+    }
+
+    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.setOrderVersion(order.getVersion());
+        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");
+    /** 可线下收款的支付方式:到付(1)、现金(4)。 */
+    private static final List<String> OFFLINE_PAYABLE_PAY_TYPES = List.of("1", "4");
+    /** 可线下收款的订单类型:自取、堂食。 */
+    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.in(PosOrder::getPayType, OFFLINE_PAYABLE_PAY_TYPES)
+                                        .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)
+                                || (order.getPayType() != null && OFFLINE_PAYABLE_PAY_TYPES.contains(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;
+    }
+}

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

@@ -0,0 +1,89 @@
+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 查 pos_store.posName 批量回填;店铺已删时回退订单快照 posName)。 */
+    private String storeName;
+    /** 店铺头像(pos_store.logo;店铺已删时回退订单快照 logo)。 */
+    private String storeAvatar;
+    /** 商品件数(订单 food 快照数组 number 求和;无商品数据为 null)。 */
+    private Integer goodsCount;
+    /** 到付/现金支付标记。 */
+    private String collectPayment;
+
+    // ---------- 闪送渲染块 ----------
+
+    /** 闪送状态:WAITING_ACCEPTANCE/ACCEPTED/PICKED_UP/DELIVERED/COMPLETED/CANCELLED。 */
+    private String status;
+    /** 闪送乐观锁版本(用户编辑待接单订单须回传,同 FlashDeliveryUserOrderListView.orderVersion)。 */
+    private Integer orderVersion;
+    /** 业务场景: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;
+}

+ 116 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/user/InfoUserController.java

@@ -40,9 +40,12 @@ import com.ruoyi.system.utils.AuthContext;
 import com.ruoyi.system.utils.JwtUtil;
 import com.ruoyi.system.utils.MobileSMS;
 import com.ruoyi.app.user.dto.OAuthBindDto;
+import com.ruoyi.app.user.dto.OAuthDeviceConfirmDto;
 import com.ruoyi.app.user.dto.OAuthLoginDto;
 import com.ruoyi.app.utils.oauth.OAuthAccountType;
 import com.ruoyi.app.utils.oauth.LineOAuthStateService;
+import com.ruoyi.app.user.service.DeviceTrustService;
+import com.ruoyi.system.domain.InfoUserDevice;
 import com.ruoyi.app.utils.oauth.OAuthVerifyService;
 import com.ruoyi.system.mapper.InfoUserOauthMapper;
 
@@ -116,8 +119,12 @@ public class InfoUserController extends BaseController {
     private ISysDictDataService dictDataService;
     @Autowired
     private IRiderPositionService riderPositionService;
+    @Autowired
+    private DeviceTrustService deviceTrustService;
     /** 三方登录首登临时凭证 Redis 前缀 */
     private static final String OAUTH_TEMP_PREFIX = "oauth:bind:";
+    /** 设备确认临时凭证 Redis 前缀:值为 deviceId,与 OAUTH_TEMP_PREFIX 同 TTL 成对写入(029) */
+    private static final String OAUTH_TEMP_DEVICE_PREFIX = "oauth:bind:dev:";
 
 
     @Anonymous
@@ -1021,6 +1028,7 @@ public class InfoUserController extends BaseController {
         infoUserService.saveOrUpdate(info);
         InfoUser inus = findPhoneLoginUser(userDTO.getPhone());
         createUserWallet(inus.getUserId());
+        deviceTrustService.recordLogin(userDTO.getDeviceId(), userDTO.getPhone(), inus.getUserId());
         redisCache.deleteKeys(CacheConstants.USER_TOKEN_KEY + inus.getUserId() + ":" + "*");
         // 补充登录用户信息
         LoginUserDto userDto = new LoginUserDto();
@@ -1053,6 +1061,7 @@ public class InfoUserController extends BaseController {
         user.setDeviceToken(userDTO.getDeviceToken());
         user.setVoIPToken(userDTO.getVoIPToken());
         infoUserService.saveOrUpdate(user);
+        deviceTrustService.recordLogin(userDTO.getDeviceId(), user.getPhone(), user.getUserId());
         QueryWrapper<VipUser> queryWrapper = new QueryWrapper<>();
         queryWrapper.eq("user_id", infoUser.getUserId());
         VipUser vipUser = vipUserService.getOne(queryWrapper);
@@ -1118,14 +1127,30 @@ public class InfoUserController extends BaseController {
             u.setDeviceToken(dto.getDeviceToken());
             u.setVoIPToken(dto.getVoIPToken());
             infoUserService.saveOrUpdate(u);
+            deviceTrustService.recordLogin(dto.getDeviceId(), u.getPhone(), u.getUserId());
             return issueOauthToken(u, dto.getProvider());
         }
 
         // 未绑定:缓存 {provider, providerUid},返回 needPhone + tempKey
         log.info("[OAuth] 未绑定,返回 needPhone provider={}", dto.getProvider());
+        // (029:同设备已验证过手机号 → 免短信一键确认 deviceConfirm 分支)
         String tempKey = UUID.randomUUID().toString().replace("-", "");
         redisCache.setCacheObject(OAUTH_TEMP_PREFIX + tempKey,
                 dto.getProvider() + "@" + providerUid, 5, TimeUnit.MINUTES);
+        // 029:设备信任免短信确认仅用户端(会员)适用;业务账号绑定必须真实短信,不走免确认
+        String trustedPhone = accountType == null || accountType == OAuthAccountType.USER
+                ? deviceTrustService.getTrustedPhone(dto.getDeviceId()) : null;
+        if (trustedPhone != null) {
+            redisCache.setCacheObject(OAUTH_TEMP_DEVICE_PREFIX + tempKey, dto.getDeviceId(), 5, TimeUnit.MINUTES);
+            log.info("[OAuth] 设备已信任,返回 deviceConfirm provider={}, deviceId={}, phone={}",
+                    dto.getProvider(), dto.getDeviceId(), DeviceTrustService.maskPhone(trustedPhone));
+            Map<String, Object> data = new HashMap<>();
+            data.put("status", "deviceConfirm");
+            data.put("tempKey", tempKey);
+            data.put("maskedPhone", DeviceTrustService.maskPhone(trustedPhone));
+            return AjaxResult.success(MessageUtils.message("no.oauth.device.confirm"), data);
+        }
+        log.info("[OAuth] 未绑定,返回 needPhone provider={}, providerUid={}", dto.getProvider(), providerUid);
         Map<String, Object> data = new HashMap<>();
         data.put("status", "needPhone");
         data.put("tempKey", tempKey);
@@ -1245,6 +1270,97 @@ public class InfoUserController extends BaseController {
         }
         log.info("[OAuth] 写入绑定 provider={}, userId={}", provider, user.getUserId());
 
+        redisCache.deleteObject(OAUTH_TEMP_PREFIX + dto.getTempKey());
+        deviceTrustService.recordLogin(dto.getDeviceId(), phone, user.getUserId());
+        return issueOauthToken(user, provider);
+    }
+
+    /**
+     * 设备信任免绑一键确认(029-device-trust-phone-bind):oauthLogin 返回 deviceConfirm 后,
+     * App 弹只读脱敏手机号确认框,用户点确认调本接口免短信完成绑定登录。
+     */
+    @Anonymous
+    @PostMapping("/oauthDeviceConfirm")
+    public AjaxResult oauthDeviceConfirm(@RequestBody OAuthDeviceConfirmDto dto) {
+        if (dto == null || dto.getTempKey() == null || dto.getTempKey().isEmpty()) {
+            log.warn("[OAuth] oauthDeviceConfirm 缺 tempKey");
+            return error(MessageUtils.message("no.oauth.tempkey.missing"));
+        }
+        if (dto.getDeviceId() == null || dto.getDeviceId().isEmpty()) {
+            log.warn("[OAuth] oauthDeviceConfirm 缺 deviceId tempKey={}", dto.getTempKey());
+            return error(MessageUtils.message("no.oauth.device.mismatch"));
+        }
+        String cached = redisCache.getCacheObject(OAUTH_TEMP_PREFIX + dto.getTempKey());
+        if (cached == null) {
+            log.warn("[OAuth] 设备确认 tempKey 已过期/不存在 tempKey={}", dto.getTempKey());
+            return error(MessageUtils.message("no.oauth.tempkey.expired"));
+        }
+        // 凭证与设备必须匹配:dev 键由 oauthLogin 的 deviceConfirm 分支写入,换设备重放即拒绝
+        String boundDevice = redisCache.getCacheObject(OAUTH_TEMP_DEVICE_PREFIX + dto.getTempKey());
+        InfoUserDevice trust = deviceTrustService.getTrust(dto.getDeviceId());
+        if (boundDevice == null || !boundDevice.equals(dto.getDeviceId()) || trust == null) {
+            log.warn("[OAuth] 设备确认凭证与设备不匹配 tempKey={}, deviceId={}", dto.getTempKey(), dto.getDeviceId());
+            return error(MessageUtils.message("no.oauth.device.mismatch"));
+        }
+        int at = cached.indexOf('@');
+        String provider = cached.substring(0, at);
+        String providerUid = cached.substring(at + 1);
+        OAuthAccountType accountType = OAuthAccountType.forProvider(provider);
+        if (accountType != null && accountType != OAuthAccountType.USER) {
+            // 业务账号绑定必须真实短信验证,设备信任免确认不适用
+            log.warn("[OAuth] 设备确认不支持业务渠道 provider={}", provider);
+            return error(MessageUtils.message("no.oauth.device.mismatch"));
+        }
+        log.info("[OAuth] 设备确认流程 provider={}, providerUid={}, deviceId={}", provider, providerUid, dto.getDeviceId());
+
+        // 容错:若期间已被绑定,直接登录(与 oauthBindPhone 一致)
+        InfoUserOauth exist = infoUserOauthMapper.selectOne(
+                new LambdaQueryWrapper<InfoUserOauth>()
+                        .in(InfoUserOauth::getProvider, OAuthAccountType.bindingProviders(provider))
+                        .eq(InfoUserOauth::getProviderUid, providerUid));
+        if (exist != null) {
+            log.info("[OAuth] 设备确认期间已被绑定,直接登录 userId={}", exist.getUserId());
+            InfoUser u = infoUserService.getById(exist.getUserId());
+            if (accountType != null && !canOauthLogin(accountType, u)) {
+                return error(MessageUtils.message("no.user.stop"));
+            }
+            return issueOauthToken(u, provider);
+        }
+
+        // 信任手机号即账号主键:已注册校验停用、未注册自动创建(与 oauthBindPhone 一致,防绕过停用)
+        String phone = trust.getPhone();
+        InfoUser user = findOauthBindingUser(OAuthAccountType.USER, phone);
+        if (user != null && !"0".equals(user.getStatus())) {
+            log.warn("[OAuth] 设备确认账号已停用 phone={}", DeviceTrustService.maskPhone(phone));
+            return error(MessageUtils.message("no.user.stop"));
+        }
+        if (user == null) {
+            InfoUser info = new InfoUser();
+            info.setPhone(phone);
+            info.setNickName(phone);
+            info.setUserType("0");
+            info.setMycode(new UUIDUtil().get8UUID());
+            infoUserService.saveOrUpdate(info);
+            user = findOauthBindingUser(OAuthAccountType.USER, phone);
+            if (user == null) {
+                return error(MessageUtils.message("no.user.not.exist"));
+            }
+            createUserWallet(user.getUserId());
+            log.info("[OAuth] 设备确认新建账号 userId={}", user.getUserId());
+        }
+
+        // 写三方绑定(一键确认即免短信绑定)
+        InfoUserOauth bind = new InfoUserOauth();
+        bind.setUserId(user.getUserId());
+        bind.setProvider("line".equals(provider) ? "line_user" : provider);
+        bind.setProviderUid(providerUid);
+        bind.setCreateTime(new Date());
+        infoUserOauthMapper.insert(bind);
+        log.info("[OAuth] 设备确认写入绑定 provider={}, providerUid={}, userId={}", provider, providerUid, user.getUserId());
+
+        redisCache.deleteObject(OAUTH_TEMP_PREFIX + dto.getTempKey());
+        redisCache.deleteObject(OAUTH_TEMP_DEVICE_PREFIX + dto.getTempKey());
+        deviceTrustService.recordLogin(dto.getDeviceId(), phone, user.getUserId());
         return issueOauthToken(user, provider);
     }
 

+ 23 - 1
ruoyi-admin/src/main/java/com/ruoyi/app/user/LineCallbackController.java

@@ -2,6 +2,7 @@ package com.ruoyi.app.user;
 
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.ruoyi.app.utils.oauth.LineOAuthProperties;
+import com.ruoyi.app.user.service.DeviceTrustService;
 import com.ruoyi.app.utils.oauth.OAuthVerifyService;
 import com.ruoyi.app.utils.oauth.OAuthAccountType;
 import com.ruoyi.app.utils.oauth.LineOAuthStateService;
@@ -41,13 +42,15 @@ import java.nio.charset.StandardCharsets;
 import java.util.UUID;
 import java.util.concurrent.TimeUnit;
 
-/** LINE 三端共用服务端回调。 */
+/** LINE 三端共用服务端回调;旧用户 App(provider=line)未绑定时支持 029 设备信任免短信确认。 */
 @RestController
 @RequestMapping("/auth/line")
 public class LineCallbackController extends BaseController {
 
     private static final Logger log = LoggerFactory.getLogger(LineCallbackController.class);
     private static final String OAUTH_TEMP_PREFIX = "oauth:bind:";
+    /** 与 InfoUserController.OAUTH_TEMP_DEVICE_PREFIX 一致:设备确认临时凭证 Redis 前缀(029) */
+    private static final String OAUTH_TEMP_DEVICE_PREFIX = "oauth:bind:dev:";
 
     @Autowired
     private OAuthVerifyService oauthVerifyService;
@@ -63,6 +66,8 @@ public class LineCallbackController extends BaseController {
     private MerchantStoreAccessService merchantStoreAccessService;
     @Autowired
     private LineOAuthStateService lineOAuthStateService;
+    @Autowired
+    private DeviceTrustService deviceTrustService;
 
     @Anonymous
     @PostMapping("/authorize")
@@ -117,6 +122,12 @@ public class LineCallbackController extends BaseController {
                     return;
                 }
                 String token = buildOauthToken(user, channel);
+                // 029:旧用户 App 契约 state 由前端传 deviceId(specs/029 §5),登录即记录设备信任;
+                // 业务线 state 为 LINE 授权状态,不是 deviceId,不记录
+                if (!businessLine) {
+                    deviceTrustService.recordLogin(state, user.getPhone(), user.getUserId());
+                }
+                log.info("[OAuth][LINE] callback 已绑定登录成功 userId={}", user.getUserId());
                 response.sendRedirect(channel.appRedirect() + "?token=" + enc(token) + stateSuffix);
                 return;
             }
@@ -124,6 +135,17 @@ public class LineCallbackController extends BaseController {
             String tempKey = UUID.randomUUID().toString().replace("-", "");
             redisCache.setCacheObject(OAUTH_TEMP_PREFIX + tempKey,
                     provider + "@" + providerUid, 5, TimeUnit.MINUTES);
+            // 029:旧用户 App 未绑定且设备已信任 → deviceConfirm 免短信分支;业务线不适用
+            String trustedPhone = businessLine ? null : deviceTrustService.getTrustedPhone(state);
+            if (trustedPhone != null) {
+                redisCache.setCacheObject(OAUTH_TEMP_DEVICE_PREFIX + tempKey, state, 5, TimeUnit.MINUTES);
+                log.info("[OAuth][LINE] callback 设备已信任,返回 deviceConfirm providerUid={}, phone={}",
+                        providerUid, DeviceTrustService.maskPhone(trustedPhone));
+                response.sendRedirect(channel.appRedirect() + "?deviceConfirm=1&tempKey=" + enc(tempKey)
+                        + "&maskedPhone=" + enc(DeviceTrustService.maskPhone(trustedPhone)));
+                return;
+            }
+            log.info("[OAuth][LINE] callback 未绑定,返回 needPhone provider={}", provider);
             response.sendRedirect(channel.appRedirect() + "?needPhone=1&tempKey=" + enc(tempKey) + stateSuffix);
         } catch (Exception exception) {
             log.error("[OAuth][LINE] callback 异常 provider={}, type={}", provider, exception.getClass().getSimpleName());

+ 29 - 2
ruoyi-admin/src/main/java/com/ruoyi/app/user/RiderPositionController.java

@@ -16,6 +16,7 @@ import com.ruoyi.system.utils.Auth;
 import com.ruoyi.system.utils.JwtUtil;
 import org.springframework.security.access.prepost.PreAuthorize;
 import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.dao.DuplicateKeyException;
 import org.springframework.web.bind.annotation.*;
 import com.ruoyi.common.annotation.Log;
 import com.ruoyi.common.core.controller.BaseController;
@@ -59,7 +60,8 @@ public class RiderPositionController extends BaseController
         InfoUser user = infoUserService.getById(id);
         QueryWrapper<RiderPosition> queryWrapper= new QueryWrapper<>();
         queryWrapper.eq("rider_id",id);
-        RiderPosition position = riderPositionService.getOne(queryWrapper);
+        // 骑手定位一人一条:历史重复行取第一条继续更新,不再抛 selectOne 多行异常
+        RiderPosition position = riderPositionService.getOne(queryWrapper, false);
         RiderPosition rider = new RiderPosition();
         if(position==null){
             rider.setRiderId(Long.valueOf(id));
@@ -70,7 +72,14 @@ public class RiderPositionController extends BaseController
             }
             System.out.println("setRiderposition更新定位,用户id,新增信息:"+ JSON.toJSONString( rider));
             logger.info("setRiderposition更新定位,用户id,新增信息:"+ JSON.toJSONString( rider));
-            Boolean org = riderPositionService.saveOrUpdate(rider);
+            Boolean org;
+            try {
+                org = riderPositionService.saveOrUpdate(rider);
+            } catch (DuplicateKeyException e) {
+                // 并发上传同时查空双双插入,撞 rider_id 唯一索引时转按骑手更新已有记录
+                logger.info("setRiderposition并发插入撞唯一索引转更新,用户id"+id);
+                org = riderPositionService.update(rider, queryWrapper);
+            }
             if(org){
                 return success();
             }else {
@@ -95,6 +104,24 @@ public class RiderPositionController extends BaseController
         }
     }
 
+    /**
+     * 通过骑手用户id获取骑手经纬度
+     *
+     * @param token 登录令牌
+     * @param riderId 骑手用户id
+     * @return 骑手定位记录(含 latitude/longitude),骑手尚无定位记录时 data 为 null
+     */
+    @Anonymous
+    @Auth
+    @GetMapping("/getRiderposition")
+    public AjaxResult getRiderposition(@RequestHeader String token, @RequestParam Long riderId) {
+        QueryWrapper<RiderPosition> queryWrapper = new QueryWrapper<>();
+        queryWrapper.eq("rider_id", riderId);
+        // 一人一条:历史重复行取第一条,不抛 selectOne 多行异常
+        RiderPosition position = riderPositionService.getOne(queryWrapper, false);
+        return success(position);
+    }
+
     /**
      * 查询RiderPosition列表
      */

+ 3 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/user/dto/OAuthBindDto.java

@@ -25,4 +25,7 @@ public class OAuthBindDto {
     private String cidType;
     private String deviceToken;
     private String voIPToken;
+
+    /** 设备唯一标识(App 首启生成持久化,可选,≤64;029 设备信任免绑) */
+    private String deviceId;
 }

+ 21 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/user/dto/OAuthDeviceConfirmDto.java

@@ -0,0 +1,21 @@
+package com.ruoyi.app.user.dto;
+
+import lombok.Data;
+
+/**
+ * 设备信任免绑一键确认入参(029-device-trust-phone-bind)。
+ * oauthLogin 返回 deviceConfirm 分支后,App 弹只读脱敏手机号确认框,
+ * 用户点确认调本 DTO 对应接口完成免短信绑定登录。
+ *
+ * @author foodie
+ * @date 2026-09-20
+ */
+@Data
+public class OAuthDeviceConfirmDto {
+
+    /** oauthLogin 返回的临时凭证 */
+    private String tempKey;
+
+    /** 设备唯一标识(须与 oauthLogin 时一致,否则拒绝) */
+    private String deviceId;
+}

+ 3 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/user/dto/OAuthLoginDto.java

@@ -25,4 +25,7 @@ public class OAuthLoginDto {
     private String cidType;
     private String deviceToken;
     private String voIPToken;
+
+    /** 设备唯一标识(App 首启生成持久化,可选,≤64;029 设备信任免绑) */
+    private String deviceId;
 }

+ 77 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/user/service/DeviceTrustService.java

@@ -0,0 +1,77 @@
+package com.ruoyi.app.user.service;
+
+import com.ruoyi.system.domain.InfoUserDevice;
+import com.ruoyi.system.mapper.InfoUserDeviceMapper;
+import org.springframework.stereotype.Service;
+
+import java.util.Date;
+
+/**
+ * 设备-手机号信任服务(029-device-trust-phone-bind)。
+ * <p>规则一句话:手机号验证通过 + 登录成功 + 带了合法 deviceId → upsert 信任记录。</p>
+ * <p>调用点:lodeing 短信登录、oauthBindPhone 三方短信绑定、oauthLogin 已绑定直登、
+ * oauthDeviceConfirm 一键确认、LINE 回调已绑定路径。同设备以最近一次为准(覆盖),
+ * 入参不全时静默跳过,绝不影响登录主流程。</p>
+ *
+ * @author foodie
+ * @date 2026-09-20
+ */
+@Service
+public class DeviceTrustService {
+
+    private final InfoUserDeviceMapper deviceMapper;
+
+    public DeviceTrustService(InfoUserDeviceMapper deviceMapper) {
+        this.deviceMapper = deviceMapper;
+    }
+
+    /** deviceId 合法性:非空且 ≤64(空串/超长按未传处理,老版本 App 兼容) */
+    public static boolean isValidDeviceId(String deviceId) {
+        return deviceId != null && !deviceId.isEmpty() && deviceId.length() <= 64;
+    }
+
+    /** 记录/刷新设备信任(同设备以最近一次为准;入参不全时静默跳过) */
+    public void recordLogin(String deviceId, String phone, Long userId) {
+        if (!isValidDeviceId(deviceId) || phone == null || phone.isEmpty() || userId == null) {
+            return;
+        }
+        InfoUserDevice exist = deviceMapper.selectById(deviceId);
+        Date now = new Date();
+        if (exist == null) {
+            InfoUserDevice row = new InfoUserDevice();
+            row.setDeviceId(deviceId);
+            row.setPhone(phone);
+            row.setUserId(userId);
+            row.setCreateTime(now);
+            row.setUpdateTime(now);
+            deviceMapper.insert(row);
+        } else {
+            exist.setPhone(phone);
+            exist.setUserId(userId);
+            exist.setUpdateTime(now);
+            deviceMapper.updateById(exist);
+        }
+    }
+
+    /** 查询设备信任记录(deviceId 非法返回 null,不查库) */
+    public InfoUserDevice getTrust(String deviceId) {
+        if (!isValidDeviceId(deviceId)) {
+            return null;
+        }
+        return deviceMapper.selectById(deviceId);
+    }
+
+    /** 查询设备最近一次验证通过的手机号,无信任记录返回 null */
+    public String getTrustedPhone(String deviceId) {
+        InfoUserDevice trust = getTrust(deviceId);
+        return trust == null ? null : trust.getPhone();
+    }
+
+    /** 手机号脱敏:保留前 3 后 4,中间 ****(长度不足 8 原样返回,与原日志脱敏规则一致) */
+    public static String maskPhone(String phone) {
+        if (phone == null || phone.length() < 8) {
+            return phone;
+        }
+        return phone.substring(0, 3) + "****" + phone.substring(phone.length() - 4);
+    }
+}

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

@@ -396,3 +396,9 @@ flash.delivery.rating.stars.invalid=騎手評分必須是1到5之間的數值
 flash.delivery.schedule.not.open=預約取件時間未開始或已結束,暫不能接單
 no.message.push.new.flash.order=有新的閃送訂單
 no.message.push.new.food.order=有新的外送訂單
+no.oauth.device.confirm=请确认绑定本机已验证的手机号
+no.oauth.device.mismatch=设备确认凭证无效,请重新登录
+
+# 订单发票校验(010)
+no.unifiedorder.cursor.invalid=Con trỏ phân trang không hợp lệ
+no.unifiedorder.param.invalid=Tham số truy vấn không hợp lệ

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

@@ -393,3 +393,9 @@ flash.delivery.stop.id.required=A pickup station ID is required for this order.
 flash.delivery.intervention.required=Select the problem station and record the intervention result.
 flash.delivery.handoff.required=Record the destination of collected goods and handoff evidence.
 flash.delivery.schedule.not.open=The scheduled pickup window has not started or has already ended
+no.oauth.device.confirm=Confirm binding the phone number verified on this device
+no.oauth.device.mismatch=Invalid device confirmation, please log in again
+
+# Order invoice validation (010)
+no.unifiedorder.cursor.invalid=Invalid pagination cursor
+no.unifiedorder.param.invalid=Invalid query parameters

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

@@ -394,3 +394,9 @@ flash.delivery.stop.id.required=คำสั่งซื้อหลายจุ
 flash.delivery.intervention.required=เลือกจุดที่มีปัญหาและบันทึกผลการจัดการของแพลตฟอร์ม
 flash.delivery.handoff.required=บันทึกปลายทางของสินค้าที่รับแล้วและหลักฐานส่งมอบ
 flash.delivery.schedule.not.open=ยังไม่ถึงช่วงเวลานัดรับสินค้า หรือช่วงเวลาดังกล่าวสิ้นสุดแล้ว จึงยังรับออเดอร์นี้ไม่ได้
+no.oauth.device.confirm=โปรดยืนยันการผูกหมายเลขโทรศัพท์ที่ยืนยันบนอุปกรณ์นี้
+no.oauth.device.mismatch=การยืนยันอุปกรณ์ไม่ถูกต้อง โปรดเข้าสู่ระบบอีกครั้ง
+
+# ตรวจสอบใบกำกับภาษีของคำสั่งซื้อ (010)
+no.unifiedorder.cursor.invalid=เคอร์เซอร์แบ่งหน้าไม่ถูกต้อງ
+no.unifiedorder.param.invalid=พາรາມິเตอร์ทີ่กາรค้นหາไม่ถูกต้อง

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

@@ -394,3 +394,9 @@ flash.delivery.stop.id.required=多取货点订单必须指定取货站点编号
 flash.delivery.intervention.required=请选择问题站点并记录平台处理结果。
 flash.delivery.handoff.required=请记录已取物品去向及交接凭证。
 flash.delivery.schedule.not.open=预约取件时间未开始或已结束,暂不能接单
+no.oauth.device.confirm=请确认绑定本机已验证的手机号
+no.oauth.device.mismatch=设备确认凭证无效,请重新登录
+
+# 订单发票校验(010)
+no.unifiedorder.cursor.invalid=分页游标不合法
+no.unifiedorder.param.invalid=查询参数不合法

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

@@ -394,3 +394,9 @@ flash.delivery.stop.id.required=多取貨點訂單必須指定取貨站點編號
 flash.delivery.intervention.required=請選擇問題站點並記錄平台處理結果。
 flash.delivery.handoff.required=請記錄已取物品去向及交接憑證。
 flash.delivery.schedule.not.open=預約取件時間未開始或已結束,暫不能接單
+no.oauth.device.confirm=請確認綁定本機已驗證的手機號
+no.oauth.device.mismatch=設備確認憑證無效,請重新登入
+
+# 訂單發票校驗(010)
+no.unifiedorder.cursor.invalid=分頁游標不合法
+no.unifiedorder.param.invalid=查詢參數不合法

+ 4 - 2
ruoyi-admin/src/test/java/com/ruoyi/app/order/OrderListPaymentFilterTest.java

@@ -81,9 +81,10 @@ class OrderListPaymentFilterTest {
         String sql = query.getSqlSegment();
         Collection<Object> parameters = new ArrayList<>(query.getParamNameValuePairs().values());
         assertTrue(sql.contains("(pay_status ="));
-        assertTrue(sql.contains("OR (pay_type ="));
+        assertTrue(sql.contains("OR (pay_type IN"));
         assertTrue(sql.contains("AND type IN"));
         assertEquals(1, Collections.frequency(parameters, "1"));
+        assertEquals(1, Collections.frequency(parameters, "4"));
     }
 
     @Test
@@ -109,9 +110,10 @@ class OrderListPaymentFilterTest {
         String sql = query.getSqlSegment();
         Collection<Object> parameters = new ArrayList<>(query.getParamNameValuePairs().values());
         assertTrue(sql.contains("(pay_status ="));
-        assertTrue(sql.contains("OR (pay_type ="));
+        assertTrue(sql.contains("OR (pay_type IN"));
         assertTrue(sql.contains("AND type IN"));
         assertEquals(1, Collections.frequency(parameters, "1"));
+        assertEquals(1, Collections.frequency(parameters, "4"));
     }
 
     @ParameterizedTest

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

@@ -0,0 +1,142 @@
+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));
+    }
+
+    @Test
+    void dineinAndPickupCursorsAdvanceLikeOwnSource() {
+        // dinein 游标:本源同秒需 id 更小;更大源(字典序 dinein < flash < pickup < takeaway)同秒整段包含
+        UnifiedCursor cursor = UnifiedCursor.of(1000L, OrderSource.DINEIN, 9L);
+        assertEquals("1000_dinein_9", cursor.encode());
+        assertEquals(cursor, UnifiedCursor.parse(cursor.encode()));
+        assertEquals(false, cursor.includesSameTimeAnyId(OrderSource.DINEIN));
+        assertEquals(true, cursor.includesSameTimeLowerId(OrderSource.DINEIN));
+        assertEquals(true, cursor.includesSameTimeAnyId(OrderSource.FLASH));
+        assertEquals(true, cursor.includesSameTimeAnyId(OrderSource.PICKUP));
+        assertEquals(true, cursor.includesSameTimeAnyId(OrderSource.TAKEAWAY));
+        assertEquals(false, cursor.includesSameTimeLowerId(OrderSource.PICKUP));
+
+        // pickup 游标:更小源(dinein/flash)同秒整段不包含,退化严格小于
+        UnifiedCursor pickupCursor = UnifiedCursor.of(1000L, OrderSource.PICKUP, 7L);
+        assertEquals("1000_pickup_7", pickupCursor.encode());
+        assertEquals(pickupCursor, UnifiedCursor.parse(pickupCursor.encode()));
+        assertEquals(false, pickupCursor.includesSameTimeAnyId(OrderSource.FLASH));
+        assertEquals(false, pickupCursor.includesSameTimeLowerId(OrderSource.FLASH));
+        assertEquals(false, pickupCursor.includesSameTimeAnyId(OrderSource.PICKUP));
+        assertEquals(true, pickupCursor.includesSameTimeLowerId(OrderSource.PICKUP));
+    }
+}

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

@@ -0,0 +1,153 @@
+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 dineinAndPickupSourcesAreForwardedToService() {
+        UnifiedOrderPageView view = new UnifiedOrderPageView();
+        when(service.query(eq(9L), eq(UnifiedOrderTab.ALL), eq(OrderSource.DINEIN), isNull(), anyInt()))
+                .thenReturn(view);
+        when(service.query(eq(9L), eq(UnifiedOrderTab.ALL), eq(OrderSource.PICKUP), isNull(), anyInt()))
+                .thenReturn(view);
+
+        UnifiedOrderQueryController controller = new UnifiedOrderQueryController(service);
+        AjaxResult dinein = controller.list(JwtUtil.token("9", "user"), "all", "dinein", null, 10);
+        AjaxResult pickup = controller.list(JwtUtil.token("9", "user"), "all", "pickup", null, 10);
+
+        assertEquals(200, ((Number) dinein.get(AjaxResult.CODE_TAG)).intValue());
+        assertSame(view, dinein.get(AjaxResult.DATA_TAG));
+        assertEquals(200, ((Number) pickup.get(AjaxResult.CODE_TAG)).intValue());
+        assertSame(view, pickup.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);
+    }
+}

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

@@ -0,0 +1,451 @@
+package com.ruoyi.app.unifiedorder;
+
+import com.baomidou.mybatisplus.core.MybatisConfiguration;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.ruoyi.app.unifiedorder.dto.UnifiedOrderListItem;
+import com.ruoyi.app.unifiedorder.dto.UnifiedOrderPageView;
+import com.ruoyi.system.domain.PosOrder;
+import com.ruoyi.system.domain.PosStore;
+import com.ruoyi.system.domain.flash.FlashDeliveryOrder;
+import com.ruoyi.system.domain.flash.FlashDeliveryStatus;
+import com.ruoyi.system.mapper.PosOrderMapper;
+import com.ruoyi.system.mapper.PosStoreMapper;
+import com.ruoyi.system.mapper.flash.FlashDeliveryOrderMapper;
+import org.apache.ibatis.builder.MapperBuilderAssistant;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.List;
+import java.util.Set;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyCollection;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/** 统一订单列表服务:两路查询、归并排序、翻页完整性、hasMore、tab 跳过闪送路。 */
+class UnifiedOrderQueryServiceTest {
+
+    private static final Long USER_ID = 9L;
+
+    private final PosOrderMapper posOrderMapper = mock(PosOrderMapper.class);
+    private final FlashDeliveryOrderMapper flashOrderMapper = mock(FlashDeliveryOrderMapper.class);
+    private final PosStoreMapper posStoreMapper = mock(PosStoreMapper.class);
+    private final UnifiedOrderQueryService service =
+            new UnifiedOrderQueryService(posOrderMapper, flashOrderMapper, posStoreMapper);
+
+    @BeforeAll
+    static void initializeTableMetadata() {
+        TableInfoHelper.initTableInfo(
+                new MapperBuilderAssistant(new MybatisConfiguration(), ""), PosOrder.class);
+        TableInfoHelper.initTableInfo(
+                new MapperBuilderAssistant(new MybatisConfiguration(), ""), FlashDeliveryOrder.class);
+    }
+
+    @Test
+    void twoPathsMergeInDisplayOrderAndReturnExactlySize() {
+        doReturn(pageOf(List.of(
+                takeaway(21L, 3000L), takeaway(22L, 2000L), takeaway(23L, 1000L))))
+                .when(posOrderMapper).selectPage(any(), any());
+        doReturn(pageOf(List.of(
+                flash(31L, 3000L), flash(32L, 2500L))))
+                .when(flashOrderMapper).selectPage(any(), any());
+
+        UnifiedOrderPageView view = service.query(USER_ID, UnifiedOrderTab.ALL, null, null, 3);
+
+        assertEquals(3, view.getList().size());
+        // 同秒并列:flash 排在 takeaway 前;整体时间非递增
+        assertEquals(List.of("flash", "takeaway", "flash"),
+                view.getList().stream().map(UnifiedOrderListItem::getSourceType).toList());
+        assertEquals(List.of(31L, 21L, 32L),
+                view.getList().stream().map(UnifiedOrderListItem::getOrderId).toList());
+        long previous = Long.MAX_VALUE;
+        for (UnifiedOrderListItem item : view.getList()) {
+            assertTrue(item.getCreateTime() <= previous, "createTime 非递增");
+            previous = item.getCreateTime();
+            assertEquals("completed", item.getUnifiedStatus());
+        }
+        assertTrue(view.isHasMore());
+        assertEquals("2500000_flash_32", view.getNextCursor());
+    }
+
+    @Test
+    @SuppressWarnings({"rawtypes", "unchecked"})
+    void eachPathQueriesSizePlusOneOnFirstPageWithoutCount() {
+        doReturn(pageOf(List.of())).when(posOrderMapper).selectPage(any(), any());
+        doReturn(pageOf(List.of())).when(flashOrderMapper).selectPage(any(), any());
+
+        service.query(USER_ID, UnifiedOrderTab.ALL, null, null, 10);
+
+        ArgumentCaptor<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
+    @SuppressWarnings({"rawtypes", "unchecked"})
+    void sourceFilterAddsPosOrderTypePredicate() {
+        doReturn(pageOf(List.of())).when(posOrderMapper).selectPage(any(), any());
+        doReturn(pageOf(List.of())).when(flashOrderMapper).selectPage(any(), any());
+
+        service.query(USER_ID, UnifiedOrderTab.ALL, OrderSource.TAKEAWAY, null, 10);
+        service.query(USER_ID, UnifiedOrderTab.ALL, OrderSource.DINEIN, null, 10);
+        service.query(USER_ID, UnifiedOrderTab.ALL, OrderSource.PICKUP, null, 10);
+        service.query(USER_ID, UnifiedOrderTab.ALL, null, null, 10);
+
+        ArgumentCaptor<com.baomidou.mybatisplus.core.conditions.Wrapper> captor =
+                ArgumentCaptor.forClass(com.baomidou.mybatisplus.core.conditions.Wrapper.class);
+        verify(posOrderMapper, times(4)).selectPage(any(), captor.capture());
+        List<LambdaQueryWrapper<PosOrder>> wrappers = captor.getAllValues().stream()
+                .map(wrapper -> (LambdaQueryWrapper<PosOrder>) wrapper)
+                .toList();
+        // takeaway 仅外送 type=0;dinein=堂食 type=2;pickup=自取 type=1
+        assertTrue(wrappers.get(0).getSqlSegment().contains("type ="), wrappers.get(0).getSqlSegment());
+        assertTrue(wrappers.get(0).getParamNameValuePairs().containsValue(0L),
+                wrappers.get(0).getParamNameValuePairs().toString());
+        assertTrue(wrappers.get(1).getSqlSegment().contains("type ="), wrappers.get(1).getSqlSegment());
+        assertTrue(wrappers.get(1).getParamNameValuePairs().containsValue(2L),
+                wrappers.get(1).getParamNameValuePairs().toString());
+        assertTrue(wrappers.get(2).getSqlSegment().contains("type ="), wrappers.get(2).getSqlSegment());
+        assertTrue(wrappers.get(2).getParamNameValuePairs().containsValue(1L),
+                wrappers.get(2).getParamNameValuePairs().toString());
+        // source=all 不限 type,堂食/自取单照常混排
+        assertFalse(wrappers.get(3).getSqlSegment().contains("type ="), wrappers.get(3).getSqlSegment());
+    }
+
+    @Test
+    void dineinAndPickupQueryOnlyPosOrderPathAndTagCursorWithOwnSource() {
+        doReturn(pageOf(List.of(takeaway(21L, 3000L), takeaway(22L, 2000L))))
+                .when(posOrderMapper).selectPage(any(), any());
+
+        UnifiedOrderPageView view = service.query(USER_ID, UnifiedOrderTab.ALL, OrderSource.DINEIN, null, 1);
+
+        assertEquals(1, view.getList().size());
+        // 卡片仍是外卖样式,堂食/自取由 type 字段渲染
+        assertEquals("takeaway", view.getList().get(0).getSourceType());
+        assertTrue(view.isHasMore());
+        assertEquals("3000000_dinein_21", view.getNextCursor());
+        verify(flashOrderMapper, never()).selectPage(any(), any());
+
+        service.query(USER_ID, UnifiedOrderTab.ALL, OrderSource.PICKUP, null, 10);
+        verify(posOrderMapper, times(2)).selectPage(any(), any());
+        verify(flashOrderMapper, never()).selectPage(any(), any());
+    }
+
+    @Test
+    @SuppressWarnings({"rawtypes", "unchecked"})
+    void dineinCursorDrivesSameSecondIdDecisivePredicateOnNextScroll() {
+        doReturn(pageOf(List.of())).when(posOrderMapper).selectPage(any(), any());
+
+        service.query(USER_ID, UnifiedOrderTab.ALL, OrderSource.DINEIN,
+                UnifiedCursor.parse("3000000_dinein_21"), 10);
+
+        ArgumentCaptor<com.baomidou.mybatisplus.core.conditions.Wrapper> captor =
+                ArgumentCaptor.forClass(com.baomidou.mybatisplus.core.conditions.Wrapper.class);
+        verify(posOrderMapper).selectPage(any(), captor.capture());
+        LambdaQueryWrapper<PosOrder> wrapper = (LambdaQueryWrapper<PosOrder>) captor.getValue();
+        // 同源游标:同秒 id 决胜分支存在,保证翻页不丢同秒行
+        String sql = wrapper.getSqlSegment();
+        assertTrue(sql.contains(" OR "), sql);
+        assertTrue(sql.contains("type ="), sql);
+    }
+
+    @Test
+    void unpaidAndRefundTabsNeverQueryFlashPath() {
+        doReturn(pageOf(List.of())).when(posOrderMapper).selectPage(any(), any());
+        service.query(USER_ID, UnifiedOrderTab.UNPAID, null, null, 10);
+        service.query(USER_ID, UnifiedOrderTab.REFUND, null, null, 10);
+        verify(flashOrderMapper, never()).selectPage(any(), any());
+        verify(posOrderMapper, times(2)).selectPage(any(), any());
+    }
+
+    @Test
+    void emptyResultReportsNoMoreAndNoCursor() {
+        doReturn(pageOf(List.of())).when(posOrderMapper).selectPage(any(), any());
+        doReturn(pageOf(List.of())).when(flashOrderMapper).selectPage(any(), any());
+
+        UnifiedOrderPageView view = service.query(USER_ID, UnifiedOrderTab.ALL, null, null, 10);
+
+        assertTrue(view.getList().isEmpty());
+        assertFalse(view.isHasMore());
+        assertNull(view.getNextCursor());
+    }
+
+    @Test
+    void takeawayItemsCarryStoreNameAndAvatarFromSingleBatchLookup() {
+        PosOrder first = takeaway(21L, 3000L);
+        first.setMdId(55L);
+        PosOrder second = takeaway(22L, 2000L);
+        second.setMdId(56L);
+        PosOrder third = takeaway(23L, 1000L);
+        third.setMdId(55L);
+        doReturn(pageOf(List.of(first, second, third))).when(posOrderMapper).selectPage(any(), any());
+        doReturn(pageOf(List.of())).when(flashOrderMapper).selectPage(any(), any());
+        PosStore shopA = new PosStore();
+        shopA.setId(55);
+        shopA.setPosName("店铺A");
+        shopA.setLogo("http://img/a.png");
+        PosStore shopB = new PosStore();
+        shopB.setId(56);
+        shopB.setPosName("店铺B");
+        shopB.setLogo("http://img/b.png");
+        when(posStoreMapper.selectBatchIds(anyCollection())).thenReturn(List.of(shopA, shopB));
+
+        UnifiedOrderPageView view = service.query(USER_ID, UnifiedOrderTab.ALL, OrderSource.TAKEAWAY, null, 10);
+
+        assertEquals(3, view.getList().size());
+        assertEquals("店铺A", view.getList().get(0).getStoreName());
+        assertEquals("店铺B", view.getList().get(1).getStoreName());
+        assertEquals("店铺A", view.getList().get(2).getStoreName());
+        assertEquals("http://img/a.png", view.getList().get(0).getStoreAvatar());
+        assertEquals("http://img/b.png", view.getList().get(1).getStoreAvatar());
+        verify(posStoreMapper, times(1)).selectBatchIds(anyCollection());
+    }
+
+    @Test
+    void takeawayFallsBackToOrderSnapshotWhenStoreMissing() {
+        PosOrder order = takeaway(21L, 3000L);
+        order.setMdId(404L);
+        order.setPosName("快照店铺");
+        order.setLogo("http://img/snap.png");
+        doReturn(pageOf(List.of(order))).when(posOrderMapper).selectPage(any(), any());
+        doReturn(pageOf(List.of())).when(flashOrderMapper).selectPage(any(), any());
+        when(posStoreMapper.selectBatchIds(anyCollection())).thenReturn(List.of());
+
+        UnifiedOrderPageView view = service.query(USER_ID, UnifiedOrderTab.ALL, OrderSource.TAKEAWAY, null, 10);
+
+        assertEquals("快照店铺", view.getList().get(0).getStoreName());
+        assertEquals("http://img/snap.png", view.getList().get(0).getStoreAvatar());
+    }
+
+    @Test
+    void takeawayGoodsCountSumsFoodNumberFields() {
+        PosOrder order = takeaway(21L, 3000L);
+        order.setFood("[{\"name\":\"餐A\",\"number\":2},{\"name\":\"餐B\",\"number\":3}]");
+        PosOrder emptyFood = takeaway(22L, 2000L);
+        PosOrder brokenFood = takeaway(23L, 1000L);
+        brokenFood.setFood("not-json");
+        doReturn(pageOf(List.of(order, emptyFood, brokenFood)))
+                .when(posOrderMapper).selectPage(any(), any());
+        doReturn(pageOf(List.of())).when(flashOrderMapper).selectPage(any(), any());
+        when(posStoreMapper.selectBatchIds(anyCollection())).thenReturn(List.of());
+
+        UnifiedOrderPageView view = service.query(USER_ID, UnifiedOrderTab.ALL, OrderSource.TAKEAWAY, null, 10);
+
+        assertEquals(5, view.getList().get(0).getGoodsCount());
+        assertNull(view.getList().get(1).getGoodsCount());
+        assertNull(view.getList().get(2).getGoodsCount());
+    }
+
+    @Test
+    void refundWithFlashSourceQueriesNothingAtAll() {
+        UnifiedOrderPageView view = service.query(USER_ID, UnifiedOrderTab.REFUND, OrderSource.FLASH, null, 10);
+        assertTrue(view.getList().isEmpty());
+        assertFalse(view.isHasMore());
+        assertNull(view.getNextCursor());
+        verify(posOrderMapper, never()).selectPage(any(), any());
+        verify(flashOrderMapper, never()).selectPage(any(), any());
+    }
+
+    @Test
+    void activeOrUnpaidWithTakeawaySourceQueriesOnlyTakeawayPath() {
+        doReturn(pageOf(List.of())).when(posOrderMapper).selectPage(any(), any());
+        service.query(USER_ID, UnifiedOrderTab.ACTIVE, OrderSource.TAKEAWAY, null, 10);
+        service.query(USER_ID, UnifiedOrderTab.UNPAID, OrderSource.TAKEAWAY, null, 10);
+        verify(posOrderMapper, times(2)).selectPage(any(), any());
+        verify(flashOrderMapper, never()).selectPage(any(), any());
+    }
+
+    @Test
+    @SuppressWarnings({"rawtypes", "unchecked"})
+    void flashPathQueriesBothSenderAndReceiverParticipation() {
+        doReturn(pageOf(List.of())).when(flashOrderMapper).selectPage(any(), any());
+        service.query(USER_ID, UnifiedOrderTab.ALL, OrderSource.FLASH, null, 10);
+
+        ArgumentCaptor<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());
+    }
+
+    @Test
+    void flashItemsCarryOrderVersion() {
+        FlashDeliveryOrder order = flash(31L, 3000L);
+        order.setVersion(7);
+        doReturn(pageOf(List.of(order))).when(flashOrderMapper).selectPage(any(), any());
+
+        UnifiedOrderPageView view = service.query(USER_ID, UnifiedOrderTab.ALL, OrderSource.FLASH, null, 10);
+
+        assertEquals(7, view.getList().get(0).getOrderVersion());
+    }
+
+    // ---------- helpers ----------
+
+    private static PosOrder takeaway(long id, long timeSec) {
+        PosOrder order = new PosOrder();
+        order.setId(id);
+        order.setDdId("DD" + id);
+        order.setUserId(USER_ID);
+        order.setCretim(new Date(timeSec * 1000));
+        order.setState(3L);
+        order.setType(0L);
+        order.setPayStatus(1L);
+        order.setPayType("2");
+        order.setAfterSaleStatus(0L);
+        order.setAmount(100);
+        return order;
+    }
+
+    private static FlashDeliveryOrder flash(long id, long timeSec) {
+        FlashDeliveryOrder order = new FlashDeliveryOrder();
+        order.setId(id);
+        order.setOrderNo("FD" + id);
+        order.setUserId(USER_ID);
+        order.setCreateTime(new Date(timeSec * 1000));
+        order.setStatus(FlashDeliveryStatus.COMPLETED);
+        order.setAmount(500L);
+        order.setCurrency("VND");
+        return order;
+    }
+
+    private static <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;
+    }
+}

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

@@ -0,0 +1,284 @@
+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)/现金(payType=4)仅自取/堂食可履约
+        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)));
+        assertTrue(UnifiedOrderTab.ACTIVE.matches(pos(1L, 1L, 0L, "4", 0L)));
+        assertTrue(UnifiedOrderTab.ACTIVE.matches(pos(2L, 2L, 0L, "4", 0L)));
+        assertFalse(UnifiedOrderTab.ACTIVE.matches(pos(0L, 0L, 0L, "4", 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() {
+        LambdaQueryWrapper<PosOrder> wrapper = UnifiedOrderTab.ACTIVE.posOrderWrapper();
+        String sql = wrapper.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 IN"), sql);
+        assertTrue(sql.contains("type IN"), sql);
+        assertTrue(wrapper.getParamNameValuePairs().values()
+                .containsAll(java.util.List.of("1", "4")), wrapper.getParamNameValuePairs().toString());
+    }
+
+    @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;
+    }
+}

+ 397 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/user/DeviceTrustFlowTest.java

@@ -0,0 +1,397 @@
+package com.ruoyi.app.user;
+
+import com.ruoyi.app.user.dto.OAuthBindDto;
+import com.ruoyi.app.user.dto.OAuthDeviceConfirmDto;
+import com.ruoyi.app.user.dto.OAuthLoginDto;
+import com.ruoyi.system.domain.vo.UserDTO;
+import com.ruoyi.app.user.service.DeviceTrustService;
+import com.ruoyi.common.core.domain.AjaxResult;
+import com.ruoyi.common.core.redis.RedisCache;
+import com.ruoyi.common.utils.spring.SpringUtils;
+import com.ruoyi.system.domain.InfoUser;
+import com.ruoyi.system.domain.InfoUserDevice;
+import com.ruoyi.system.domain.InfoUserOauth;
+import com.ruoyi.system.mapper.InfoUserDeviceMapper;
+import com.ruoyi.system.mapper.InfoUserOauthMapper;
+import com.ruoyi.system.service.IInfoUserService;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.config.ConfigurableListableBeanFactory;
+import org.springframework.beans.factory.support.DefaultListableBeanFactory;
+import org.springframework.context.support.StaticMessageSource;
+import org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.test.util.ReflectionTestUtils;
+import org.springframework.web.context.request.RequestContextHolder;
+import org.springframework.web.context.request.ServletRequestAttributes;
+
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.argThat;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.ArgumentMatchers.startsWith;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+
+/**
+ * 设备信任免绑端到端分支测试(029-device-trust-phone-bind,TDD 先行)。
+ * 直接以 Mockito 实例化 InfoUserController,走 oauthLogin/oauthDeviceConfirm/oauthBindPhone/lodeing
+ * 的关键分支;JwtUtil/MessageUtils 通过 SpringUtils 静态 BeanFactory 替换为测试桩。
+ */
+class DeviceTrustFlowTest {
+
+    private static ConfigurableListableBeanFactory originalBeanFactory;
+
+    private final RedisCache redisCache = mock(RedisCache.class);
+    private final IInfoUserService infoUserService = mock(IInfoUserService.class);
+    private final InfoUserOauthMapper infoUserOauthMapper = mock(InfoUserOauthMapper.class);
+    private final com.ruoyi.app.utils.oauth.OAuthVerifyService oauthVerifyService =
+            mock(com.ruoyi.app.utils.oauth.OAuthVerifyService.class);
+    private final com.ruoyi.system.service.IUserWalletService userWalletService =
+            mock(com.ruoyi.system.service.IUserWalletService.class);
+    private final com.ruoyi.system.service.IVipUserService vipUserService =
+            mock(com.ruoyi.system.service.IVipUserService.class);
+    private final InfoUserDeviceMapper deviceMapper = mock(InfoUserDeviceMapper.class);
+
+    private InfoUserController controller;
+
+    @BeforeAll
+    static void initStatics() {
+        originalBeanFactory = (ConfigurableListableBeanFactory)
+                ReflectionTestUtils.getField(SpringUtils.class, "beanFactory");
+    }
+
+    @AfterAll
+    static void restoreStatics() {
+        new SpringUtils().postProcessBeanFactory(originalBeanFactory);
+        JwtUtilRestore.restore();
+    }
+
+    @BeforeEach
+    void setUp() {
+        // MessageUtils:返回 key 本身;JwtUtil 的 getBean(RedisCache) 也从这个工厂取
+        DefaultListableBeanFactory beanFactory = new DefaultListableBeanFactory();
+        StaticMessageSource messageSource = new StaticMessageSource();
+        messageSource.setUseCodeAsDefaultMessage(true);
+        beanFactory.registerSingleton("messageSource", messageSource);
+        beanFactory.registerSingleton("redisCache", redisCache);
+        new SpringUtils().postProcessBeanFactory(beanFactory);
+
+        MockHttpServletRequest request = new MockHttpServletRequest();
+        RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request));
+
+        controller = new InfoUserController();
+        ReflectionTestUtils.setField(controller, "redisCache", redisCache);
+        ReflectionTestUtils.setField(controller, "infoUserService", infoUserService);
+        ReflectionTestUtils.setField(controller, "infoUserOauthMapper", infoUserOauthMapper);
+        ReflectionTestUtils.setField(controller, "oauthVerifyService", oauthVerifyService);
+        ReflectionTestUtils.setField(controller, "userWalletService", userWalletService);
+        ReflectionTestUtils.setField(controller, "vipUserService", vipUserService);
+        ReflectionTestUtils.setField(controller, "deviceTrustService", new DeviceTrustService(deviceMapper));
+    }
+
+    @AfterEach
+    void tearDown() {
+        RequestContextHolder.resetRequestAttributes();
+    }
+
+    /** JwtUtil 静态依赖还原占位(当前版本经 SpringUtils 取 Bean,无需额外清理) */
+    private static final class JwtUtilRestore {
+        static void restore() {
+            // JwtUtil.setToken 每次经 SpringUtils.getBean 动态获取,无静态持有,无需还原
+        }
+    }
+
+    private static InfoUser user(long id, String phone, String status) {
+        InfoUser u = new InfoUser();
+        u.setUserId(id);
+        u.setPhone(phone);
+        u.setStatus(status);
+        u.setDelFlag("0");
+        u.setUserType("0");
+        return u;
+    }
+
+    private static InfoUserDevice trust(String deviceId, String phone, long userId) {
+        InfoUserDevice t = new InfoUserDevice();
+        t.setDeviceId(deviceId);
+        t.setPhone(phone);
+        t.setUserId(userId);
+        return t;
+    }
+
+    @SuppressWarnings("unchecked")
+    private static Map<String, Object> dataMap(AjaxResult result) {
+        return (Map<String, Object>) result.get("data");
+    }
+
+    // ==================== oauthLogin 分支 ====================
+
+    @Test
+    void oauthLoginUnboundWithTrustedDeviceReturnsDeviceConfirmAndWritesBothKeys() {
+        when(oauthVerifyService.verify("google", "cred-1")).thenReturn("uid-1");
+        when(infoUserOauthMapper.selectOne(any())).thenReturn(null);
+        when(deviceMapper.selectById("dev-1")).thenReturn(trust("dev-1", "0987654321", 100L));
+
+        OAuthLoginDto dto = new OAuthLoginDto();
+        dto.setProvider("google");
+        dto.setCredential("cred-1");
+        dto.setDeviceId("dev-1");
+        AjaxResult result = controller.oauthLogin(dto);
+
+        assertEquals(200, result.get("code"));
+        Map<String, Object> data = dataMap(result);
+        assertEquals("deviceConfirm", data.get("status"));
+        assertNotNull(data.get("tempKey"));
+        assertEquals("098****4321", data.get("maskedPhone"));
+        String tempKey = (String) data.get("tempKey");
+        verify(redisCache).setCacheObject(eq("oauth:bind:" + tempKey), eq("google@uid-1"), eq(5), eq(TimeUnit.MINUTES));
+        verify(redisCache).setCacheObject(eq("oauth:bind:dev:" + tempKey), eq("dev-1"), eq(5), eq(TimeUnit.MINUTES));
+    }
+
+    @Test
+    void oauthLoginUnboundWithoutDeviceIdStillReturnsNeedPhone() {
+        when(oauthVerifyService.verify("google", "cred-1")).thenReturn("uid-1");
+        when(infoUserOauthMapper.selectOne(any())).thenReturn(null);
+
+        OAuthLoginDto dto = new OAuthLoginDto();
+        dto.setProvider("google");
+        dto.setCredential("cred-1");
+        AjaxResult result = controller.oauthLogin(dto);
+
+        Map<String, Object> data = dataMap(result);
+        assertEquals("needPhone", data.get("status"));
+        assertNotNull(data.get("tempKey"));
+        assertNull(data.get("maskedPhone"));
+        verify(redisCache, never()).setCacheObject(startsWith("oauth:bind:dev:"), anyString(), eq(5), eq(TimeUnit.MINUTES));
+    }
+
+    @Test
+    void oauthLoginUnboundWithUnknownDeviceStillReturnsNeedPhone() {
+        when(oauthVerifyService.verify("apple", "cred-2")).thenReturn("uid-2");
+        when(infoUserOauthMapper.selectOne(any())).thenReturn(null);
+        when(deviceMapper.selectById("dev-x")).thenReturn(null);
+
+        OAuthLoginDto dto = new OAuthLoginDto();
+        dto.setProvider("apple");
+        dto.setCredential("cred-2");
+        dto.setDeviceId("dev-x");
+        AjaxResult result = controller.oauthLogin(dto);
+
+        assertEquals("needPhone", dataMap(result).get("status"));
+        verify(redisCache, never()).setCacheObject(startsWith("oauth:bind:dev:"), anyString(), eq(5), eq(TimeUnit.MINUTES));
+    }
+
+    // ==================== oauthDeviceConfirm ====================
+
+    private void stubConfirmRedis(String tempKey, String deviceId) {
+        when(redisCache.getCacheObject("oauth:bind:" + tempKey)).thenReturn("google@uid-1");
+        when(redisCache.getCacheObject("oauth:bind:dev:" + tempKey)).thenReturn(deviceId);
+    }
+
+    private OAuthDeviceConfirmDto confirmDto(String tempKey, String deviceId) {
+        OAuthDeviceConfirmDto dto = new OAuthDeviceConfirmDto();
+        dto.setTempKey(tempKey);
+        dto.setDeviceId(deviceId);
+        return dto;
+    }
+
+    @Test
+    void confirmBindsTrustedPhoneExistingUserAndIssuesToken() {
+        stubConfirmRedis("tk-1", "dev-1");
+        when(deviceMapper.selectById("dev-1")).thenReturn(trust("dev-1", "0987654321", 100L));
+        when(infoUserOauthMapper.selectOne(any())).thenReturn(null);
+        InfoUser existing = user(100L, "0987654321", "0");
+        when(infoUserService.list(any(com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper.class))).thenReturn(java.util.List.of(existing));
+
+        AjaxResult result = controller.oauthDeviceConfirm(confirmDto("tk-1", "dev-1"));
+
+        assertEquals(200, result.get("code"));
+        assertEquals("no.user.login.success", result.get("msg"));
+        assertNotNull(result.get("token"));
+        verify(infoUserOauthMapper).insert(org.mockito.ArgumentMatchers.<InfoUserOauth>argThat(b -> "google".equals(b.getProvider())
+                && "uid-1".equals(b.getProviderUid()) && b.getUserId() == 100L));
+        verify(redisCache).deleteObject("oauth:bind:tk-1");
+        verify(redisCache).deleteObject("oauth:bind:dev:tk-1");
+        // recordLogin:同设备同号刷新信任
+        verify(deviceMapper).updateById(any(InfoUserDevice.class));
+    }
+
+    @Test
+    void confirmCreatesUserWhenTrustedPhoneNotRegistered() {
+        stubConfirmRedis("tk-2", "dev-1");
+        when(deviceMapper.selectById("dev-1")).thenReturn(trust("dev-1", "0999888777", 0L));
+        when(infoUserOauthMapper.selectOne(any())).thenReturn(null);
+        when(infoUserService.list(any(com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper.class))).thenReturn(java.util.List.of(), java.util.List.of(user(300L, "0999888777", "0")));
+
+        AjaxResult result = controller.oauthDeviceConfirm(confirmDto("tk-2", "dev-1"));
+
+        assertEquals(200, result.get("code"));
+        assertNotNull(result.get("token"));
+        verify(userWalletService).createUserWallet(300L);
+        verify(infoUserOauthMapper).insert(org.mockito.ArgumentMatchers.<InfoUserOauth>argThat(b -> b.getUserId() == 300L));
+    }
+
+    @Test
+    void confirmExpiredTempKeyReturnsExpiredError() {
+        when(redisCache.getCacheObject("oauth:bind:tk-3")).thenReturn(null);
+        AjaxResult result = controller.oauthDeviceConfirm(confirmDto("tk-3", "dev-1"));
+        assertEquals(500, result.get("code"));
+        assertEquals("no.oauth.tempkey.expired", result.get("msg"));
+        verify(infoUserOauthMapper, never()).insert(any(InfoUserOauth.class));
+    }
+
+    @Test
+    void confirmWithDeviceMismatchOrMissingDevKeyIsRejected() {
+        stubConfirmRedis("tk-4", "dev-other");
+        when(deviceMapper.selectById("dev-1")).thenReturn(trust("dev-1", "0987654321", 100L));
+
+        AjaxResult wrongDevice = controller.oauthDeviceConfirm(confirmDto("tk-4", "dev-1"));
+        assertEquals("no.oauth.device.mismatch", wrongDevice.get("msg"));
+
+        // dev 键缺失(tempKey 来自 needPhone 分支)
+        when(redisCache.getCacheObject("oauth:bind:tk-5")).thenReturn("google@uid-1");
+        when(redisCache.getCacheObject("oauth:bind:dev:tk-5")).thenReturn(null);
+        AjaxResult noDevKey = controller.oauthDeviceConfirm(confirmDto("tk-5", "dev-1"));
+        assertEquals("no.oauth.device.mismatch", noDevKey.get("msg"));
+
+        verify(infoUserOauthMapper, never()).insert(any(InfoUserOauth.class));
+    }
+
+    @Test
+    void confirmWhenTrustRowGoneIsRejected() {
+        stubConfirmRedis("tk-6", "dev-1");
+        when(deviceMapper.selectById("dev-1")).thenReturn(null);
+        AjaxResult result = controller.oauthDeviceConfirm(confirmDto("tk-6", "dev-1"));
+        assertEquals("no.oauth.device.mismatch", result.get("msg"));
+        verify(infoUserOauthMapper, never()).insert(any(InfoUserOauth.class));
+    }
+
+    @Test
+    void confirmRejectedWhenTrustedAccountStopped() {
+        stubConfirmRedis("tk-7", "dev-1");
+        when(deviceMapper.selectById("dev-1")).thenReturn(trust("dev-1", "0987654321", 100L));
+        when(infoUserService.list(any(com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper.class))).thenReturn(java.util.List.of(user(100L, "0987654321", "1")));
+
+        AjaxResult result = controller.oauthDeviceConfirm(confirmDto("tk-7", "dev-1"));
+        assertEquals("no.user.stop", result.get("msg"));
+        verify(infoUserOauthMapper, never()).insert(any(InfoUserOauth.class));
+    }
+
+    @Test
+    void confirmFallsBackToDirectLoginWhenBoundMeanwhile() {
+        stubConfirmRedis("tk-8", "dev-1");
+        when(deviceMapper.selectById("dev-1")).thenReturn(trust("dev-1", "0987654321", 100L));
+        InfoUserOauth exist = new InfoUserOauth();
+        exist.setUserId(100L);
+        exist.setProvider("google");
+        exist.setProviderUid("uid-1");
+        when(infoUserOauthMapper.selectOne(any())).thenReturn(exist);
+        when(infoUserService.getById(100L)).thenReturn(user(100L, "0987654321", "0"));
+
+        AjaxResult result = controller.oauthDeviceConfirm(confirmDto("tk-8", "dev-1"));
+
+        assertEquals(200, result.get("code"));
+        assertNotNull(result.get("token"));
+        verify(infoUserOauthMapper, never()).insert(any(InfoUserOauth.class));
+    }
+
+    // ==================== 记录点(US2) ====================
+
+    @Test
+    void phoneLoginSuccessRecordsDeviceTrust() {
+        when(redisCache.getCacheObject("0987654321")).thenReturn(null);
+        InfoUser existing = user(100L, "0987654321", "0");
+        when(infoUserService.list(any(com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper.class))).thenReturn(java.util.List.of(existing));
+        when(infoUserService.getOne(any())).thenReturn(existing);
+        when(vipUserService.getOne(any())).thenReturn(null);
+        when(deviceMapper.selectById("dev-1")).thenReturn(null);
+
+        UserDTO dto = new UserDTO();
+        dto.setPhone("0987654321");
+        dto.setCode("8888");
+        dto.setDeviceId("dev-1");
+        AjaxResult result = controller.lodeing(dto);
+
+        assertEquals(200, result.get("code"));
+        assertNotNull(result.get("token"));
+        verify(deviceMapper).insert(org.mockito.ArgumentMatchers.<InfoUserDevice>argThat(row -> "dev-1".equals(row.getDeviceId())
+                && "0987654321".equals(row.getPhone()) && row.getUserId() == 100L));
+    }
+
+    @Test
+    void phoneLoginWithoutDeviceIdSkipsRecording() {
+        when(redisCache.getCacheObject("0987654321")).thenReturn("1234");
+        InfoUser existing = user(100L, "0987654321", "0");
+        when(infoUserService.list(any(com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper.class))).thenReturn(java.util.List.of(existing));
+        when(infoUserService.getOne(any())).thenReturn(existing);
+        when(vipUserService.getOne(any())).thenReturn(null);
+
+        UserDTO dto = new UserDTO();
+        dto.setPhone("0987654321");
+        dto.setCode("1234");
+        AjaxResult result = controller.lodeing(dto);
+
+        assertEquals(200, result.get("code"));
+        verifyNoInteractions(deviceMapper);
+    }
+
+    @Test
+    void oauthBindPhoneSuccessRecordsDeviceTrust() {
+        when(redisCache.getCacheObject("oauth:bind:tk-b1")).thenReturn("google@uid-9");
+        when(redisCache.deleteObject("oauth:bind:tk-b1")).thenReturn(true);
+        when(redisCache.getCacheObject("0987654321")).thenReturn(null);
+        when(infoUserOauthMapper.selectOne(any())).thenReturn(null);
+        when(infoUserOauthMapper.insert(any(InfoUserOauth.class))).thenReturn(1);
+        InfoUser existing = user(100L, "0987654321", "0");
+        when(infoUserService.list(any(com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper.class))).thenReturn(java.util.List.of(existing));
+        when(infoUserService.saveOrUpdate(any(InfoUser.class))).thenReturn(true);
+        when(deviceMapper.selectById("dev-1")).thenReturn(null);
+
+        OAuthBindDto dto = new OAuthBindDto();
+        dto.setTempKey("tk-b1");
+        dto.setPhone("0987654321");
+        dto.setCode("8888");
+        dto.setDeviceId("dev-1");
+        AjaxResult result = controller.oauthBindPhone(dto);
+
+        assertEquals(200, result.get("code"));
+        assertNotNull(result.get("token"));
+        verify(deviceMapper).insert(any(InfoUserDevice.class));
+    }
+
+    @Test
+    void oauthLoginBoundDirectLoginRecordsDeviceTrust() {
+        when(oauthVerifyService.verify("line", "cred-3")).thenReturn("uid-3");
+        InfoUserOauth bind = new InfoUserOauth();
+        bind.setUserId(100L);
+        bind.setProvider("line");
+        bind.setProviderUid("uid-3");
+        when(infoUserOauthMapper.selectOne(any())).thenReturn(bind);
+        InfoUser existing = user(100L, "0987654321", "0");
+        when(infoUserService.getOne(any())).thenReturn(existing);
+        when(deviceMapper.selectById("dev-1")).thenReturn(null);
+
+        OAuthLoginDto dto = new OAuthLoginDto();
+        dto.setProvider("line");
+        dto.setCredential("cred-3");
+        dto.setDeviceId("dev-1");
+        AjaxResult result = controller.oauthLogin(dto);
+
+        assertEquals(200, result.get("code"));
+        assertNotNull(result.get("token"));
+        verify(deviceMapper).insert(org.mockito.ArgumentMatchers.<InfoUserDevice>argThat(row -> "0987654321".equals(row.getPhone())));
+    }
+}

+ 4 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/user/InfoUserControllerTest.java

@@ -12,12 +12,14 @@ import com.ruoyi.common.core.redis.RedisCache;
 import com.ruoyi.common.exception.ServiceException;
 import com.ruoyi.app.user.dto.OAuthBindDto;
 import com.ruoyi.app.user.dto.OAuthLoginDto;
+import com.ruoyi.app.user.service.DeviceTrustService;
 import com.ruoyi.app.utils.oauth.OAuthVerifyService;
 import com.ruoyi.app.utils.oauth.LineOAuthStateService;
 import com.ruoyi.system.domain.InfoUser;
 import com.ruoyi.system.domain.InfoUserOauth;
 import com.ruoyi.system.domain.PosOrder;
 import com.ruoyi.system.domain.vo.UserDTO;
+import com.ruoyi.system.mapper.InfoUserDeviceMapper;
 import com.ruoyi.system.mapper.InfoUserOauthMapper;
 import com.ruoyi.system.service.IInfoUserService;
 import com.ruoyi.system.service.IPosOrderService;
@@ -136,6 +138,8 @@ class InfoUserControllerTest {
         ReflectionTestUtils.setField(controller, "oauthVerifyService", oauthVerifyService);
         ReflectionTestUtils.setField(controller, "infoUserOauthMapper", infoUserOauthMapper);
         ReflectionTestUtils.setField(controller, "redisCache", redisCache);
+        ReflectionTestUtils.setField(controller, "deviceTrustService",
+                new DeviceTrustService(mock(InfoUserDeviceMapper.class)));
         when(infoUserOauthMapper.insert(any(InfoUserOauth.class))).thenReturn(1);
     }
 

+ 4 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/user/LineCallbackControllerTest.java

@@ -2,6 +2,7 @@ package com.ruoyi.app.user;
 
 import com.auth0.jwt.JWT;
 import com.ruoyi.app.utils.oauth.LineOAuthProperties;
+import com.ruoyi.app.user.service.DeviceTrustService;
 import com.ruoyi.app.utils.oauth.OAuthVerifyService;
 import com.ruoyi.app.utils.oauth.LineOAuthStateService;
 import com.ruoyi.common.constant.CacheConstants;
@@ -10,6 +11,7 @@ import com.ruoyi.common.exception.ServiceException;
 import com.ruoyi.common.utils.spring.SpringUtils;
 import com.ruoyi.system.domain.InfoUser;
 import com.ruoyi.system.domain.InfoUserOauth;
+import com.ruoyi.system.mapper.InfoUserDeviceMapper;
 import com.ruoyi.system.mapper.InfoUserOauthMapper;
 import com.ruoyi.system.service.IInfoUserService;
 import com.ruoyi.system.service.MerchantStoreAccessService;
@@ -184,6 +186,8 @@ class LineCallbackControllerTest {
         ReflectionTestUtils.setField(controller, "oauthVerifyService", verifier);
         ReflectionTestUtils.setField(controller, "infoUserOauthMapper", mock(InfoUserOauthMapper.class));
         ReflectionTestUtils.setField(controller, "redisCache", redis);
+        ReflectionTestUtils.setField(controller, "deviceTrustService",
+                new DeviceTrustService(mock(InfoUserDeviceMapper.class)));
         MockHttpServletResponse response = new MockHttpServletResponse();
 
         controller.callback(null, "old-code", "line_login", response);

+ 173 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/user/LineCallbackDeviceTrustTest.java

@@ -0,0 +1,173 @@
+package com.ruoyi.app.user;
+
+import com.ruoyi.app.user.service.DeviceTrustService;
+import com.ruoyi.app.utils.oauth.LineOAuthProperties;
+import com.ruoyi.common.core.redis.RedisCache;
+import com.ruoyi.common.utils.spring.SpringUtils;
+import com.ruoyi.system.domain.InfoUser;
+import com.ruoyi.system.domain.InfoUserDevice;
+import com.ruoyi.system.domain.InfoUserOauth;
+import com.ruoyi.system.mapper.InfoUserDeviceMapper;
+import com.ruoyi.system.mapper.InfoUserOauthMapper;
+import com.ruoyi.system.service.IInfoUserService;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.springframework.beans.factory.config.ConfigurableListableBeanFactory;
+import org.springframework.beans.factory.support.DefaultListableBeanFactory;
+import org.springframework.context.support.StaticMessageSource;
+import org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.mock.web.MockHttpServletResponse;
+import org.springframework.test.util.ReflectionTestUtils;
+import org.springframework.web.context.request.RequestContextHolder;
+import org.springframework.web.context.request.ServletRequestAttributes;
+
+import java.util.concurrent.TimeUnit;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+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.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.ArgumentMatchers.startsWith;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * LINE 回调设备信任免绑分支测试(029-device-trust-phone-bind,TDD 先行)。
+ * state 参数语义:前端把 deviceId 放进 LINE authorize 的 state 带回(见 contracts/api-contract.md §5)。
+ */
+class LineCallbackDeviceTrustTest {
+
+    private static ConfigurableListableBeanFactory originalBeanFactory;
+
+    private final RedisCache redisCache = mock(RedisCache.class);
+    private final IInfoUserService infoUserService = mock(IInfoUserService.class);
+    private final InfoUserOauthMapper infoUserOauthMapper = mock(InfoUserOauthMapper.class);
+    private final com.ruoyi.app.utils.oauth.OAuthVerifyService oauthVerifyService =
+            mock(com.ruoyi.app.utils.oauth.OAuthVerifyService.class);
+    private final InfoUserDeviceMapper deviceMapper = mock(InfoUserDeviceMapper.class);
+
+    private LineCallbackController controller;
+    private MockHttpServletResponse response;
+
+    @BeforeAll
+    static void saveStatics() {
+        originalBeanFactory = (ConfigurableListableBeanFactory)
+                ReflectionTestUtils.getField(SpringUtils.class, "beanFactory");
+    }
+
+    @AfterAll
+    static void restoreStatics() {
+        new SpringUtils().postProcessBeanFactory(originalBeanFactory);
+    }
+
+    @BeforeEach
+    void setUp() throws Exception {
+        DefaultListableBeanFactory beanFactory = new DefaultListableBeanFactory();
+        StaticMessageSource messageSource = new StaticMessageSource();
+        messageSource.setUseCodeAsDefaultMessage(true);
+        beanFactory.registerSingleton("messageSource", messageSource);
+        beanFactory.registerSingleton("redisCache", redisCache);
+        new SpringUtils().postProcessBeanFactory(beanFactory);
+
+        MockHttpServletRequest request = new MockHttpServletRequest();
+        request.setRemoteAddr("127.0.0.1");
+        request.addHeader("User-Agent", "JUnit");
+        RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request));
+
+        LineOAuthProperties properties = new LineOAuthProperties();
+        LineOAuthProperties.Channel user = new LineOAuthProperties.Channel();
+        user.setClientId("user-client");
+        user.setClientSecret("secret");
+        user.setRedirectUri("https://api.test/auth/line/callback");
+        user.setAppRedirect("com.test.app://oauthLogin");
+        properties.setUser(user);
+
+        controller = new LineCallbackController();
+        ReflectionTestUtils.setField(controller, "redisCache", redisCache);
+        ReflectionTestUtils.setField(controller, "infoUserService", infoUserService);
+        ReflectionTestUtils.setField(controller, "infoUserOauthMapper", infoUserOauthMapper);
+        ReflectionTestUtils.setField(controller, "oauthVerifyService", oauthVerifyService);
+        ReflectionTestUtils.setField(controller, "deviceTrustService", new DeviceTrustService(deviceMapper));
+        ReflectionTestUtils.setField(controller, "lineOAuthProperties", properties);
+
+        response = new MockHttpServletResponse();
+    }
+
+    @AfterEach
+    void tearDown() {
+        RequestContextHolder.resetRequestAttributes();
+    }
+
+    private static InfoUserDevice trust(String deviceId, String phone, long userId) {
+        InfoUserDevice t = new InfoUserDevice();
+        t.setDeviceId(deviceId);
+        t.setPhone(phone);
+        t.setUserId(userId);
+        return t;
+    }
+
+    @Test
+    void unboundWithTrustedDeviceRedirectsDeviceConfirmAndWritesBothKeys() throws Exception {
+        when(oauthVerifyService.verify("line", "code-1")).thenReturn("line-uid-1");
+        when(infoUserOauthMapper.selectOne(any())).thenReturn(null);
+        when(deviceMapper.selectById("dev-1")).thenReturn(trust("dev-1", "0987654321", 100L));
+
+        controller.callback("line", "code-1", "dev-1", response);
+
+        String location = response.getRedirectedUrl();
+        assertTrue(location != null && location.startsWith("com.test.app://oauthLogin?deviceConfirm=1&tempKey="),
+                "应回跳 deviceConfirm 分支,实际: " + location);
+        assertTrue(location.endsWith("&maskedPhone=098****4321"), "应带脱敏手机号,实际: " + location);
+
+        ArgumentCaptor<String> tempKeyCaptor = ArgumentCaptor.forClass(String.class);
+        verify(redisCache).setCacheObject(startsWith("oauth:bind:"), eq("line@line-uid-1"), eq(5), eq(TimeUnit.MINUTES));
+        verify(redisCache).setCacheObject(startsWith("oauth:bind:dev:"), eq("dev-1"), eq(5), eq(TimeUnit.MINUTES));
+    }
+
+    @Test
+    void unboundWithUnknownStateStillRedirectsNeedPhone() throws Exception {
+        when(oauthVerifyService.verify("line", "code-2")).thenReturn("line-uid-2");
+        when(infoUserOauthMapper.selectOne(any())).thenReturn(null);
+        when(deviceMapper.selectById("dev-x")).thenReturn(null);
+
+        controller.callback("line", "code-2", "dev-x", response);
+
+        String location = response.getRedirectedUrl();
+        assertTrue(location != null && location.startsWith("com.test.app://oauthLogin?needPhone=1&tempKey="),
+                "应维持 needPhone 回跳,实际: " + location);
+        verify(redisCache, never()).setCacheObject(startsWith("oauth:bind:dev:"), anyString(), anyInt(), any(TimeUnit.class));
+    }
+
+    @Test
+    void boundUserWithDeviceStateRedirectsTokenAndRecordsTrust() throws Exception {
+        when(oauthVerifyService.verify("line", "code-3")).thenReturn("line-uid-3");
+        InfoUserOauth bind = new InfoUserOauth();
+        bind.setUserId(100L);
+        bind.setProvider("line");
+        bind.setProviderUid("line-uid-3");
+        when(infoUserOauthMapper.selectOne(any())).thenReturn(bind);
+        InfoUser u = new InfoUser();
+        u.setUserId(100L);
+        u.setPhone("0987654321");
+        u.setUserType("0");
+        u.setStatus("0");
+        when(infoUserService.getOne(any())).thenReturn(u);
+        when(deviceMapper.selectById("dev-1")).thenReturn(null);
+
+        controller.callback("line", "code-3", "dev-1", response);
+
+        String location = response.getRedirectedUrl();
+        assertTrue(location != null && location.startsWith("com.test.app://oauthLogin?token="),
+                "已绑定应直接回跳 token,实际: " + location);
+        verify(deviceMapper).insert(any(InfoUserDevice.class));
+    }
+}

+ 181 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/user/RiderPositionControllerTest.java

@@ -0,0 +1,181 @@
+package com.ruoyi.app.user;
+
+import com.baomidou.mybatisplus.core.conditions.Wrapper;
+import com.ruoyi.common.annotation.Anonymous;
+import com.ruoyi.common.core.domain.AjaxResult;
+import com.ruoyi.common.utils.spring.SpringUtils;
+import com.ruoyi.system.domain.InfoUser;
+import com.ruoyi.system.domain.RiderPosition;
+import com.ruoyi.system.service.IInfoUserService;
+import com.ruoyi.system.service.IRiderPositionService;
+import com.ruoyi.system.utils.Auth;
+import com.ruoyi.system.utils.JwtUtil;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.springframework.beans.factory.config.ConfigurableListableBeanFactory;
+import org.springframework.beans.factory.support.DefaultListableBeanFactory;
+import org.springframework.context.support.StaticMessageSource;
+import org.springframework.dao.DuplicateKeyException;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.test.util.ReflectionTestUtils;
+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 java.math.BigDecimal;
+import java.util.Locale;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyBoolean;
+import static org.mockito.ArgumentMatchers.eq;
+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;
+
+/**
+ * 骑手定位上传:一人一条约束下的容错——历史重复行不再抛 selectOne 多行异常;
+ * 并发插入撞唯一索引转更新;新增/更新两分支行为不变。
+ */
+class RiderPositionControllerTest {
+
+    private static ConfigurableListableBeanFactory originalBeanFactory;
+
+    private final IRiderPositionService riderPositionService = mock(IRiderPositionService.class);
+    private final IInfoUserService infoUserService = mock(IInfoUserService.class);
+    private final RiderPositionController controller = new RiderPositionController();
+
+    @BeforeAll
+    static void initializeBeanFactory() {
+        originalBeanFactory = (ConfigurableListableBeanFactory)
+                ReflectionTestUtils.getField(SpringUtils.class, "beanFactory");
+        DefaultListableBeanFactory beanFactory = new DefaultListableBeanFactory();
+        StaticMessageSource messageSource = new StaticMessageSource();
+        messageSource.addMessage("no.action.success", Locale.getDefault(), "操作成功");
+        beanFactory.registerSingleton("messageSource", messageSource);
+        new SpringUtils().postProcessBeanFactory(beanFactory);
+    }
+
+    @AfterAll
+    static void restoreBeanFactory() {
+        new SpringUtils().postProcessBeanFactory(originalBeanFactory);
+    }
+
+    @BeforeEach
+    void setUp() {
+        ReflectionTestUtils.setField(controller, "riderPositionService", riderPositionService);
+        ReflectionTestUtils.setField(controller, "infoUserService", infoUserService);
+        when(infoUserService.getById(any())).thenReturn(new InfoUser());
+    }
+
+    @Test
+    void usesTolerantGetOneSoDuplicateRowsNoLongerCrash() {
+        RiderPosition existing = new RiderPosition();
+        existing.setId(7L);
+        existing.setRiderId(9L);
+        // 多行历史数据下 getOne(qw,false) 返回第一条,不再抛"Expected one result"
+        when(riderPositionService.getOne(any(Wrapper.class), eq(false))).thenReturn(existing);
+        when(riderPositionService.saveOrUpdate(any(RiderPosition.class))).thenReturn(true);
+
+        AjaxResult result = controller.setRiderposition(JwtUtil.token("9", "user"), new RiderPosition());
+
+        assertEquals(200, ((Number) result.get(AjaxResult.CODE_TAG)).intValue());
+        // 单参 getOne 默认 throwEx=true,是本次线上 500 的直接来源,禁止再回到该调用
+        verify(riderPositionService, never()).getOne(any(Wrapper.class));
+        ArgumentCaptor<RiderPosition> captor = ArgumentCaptor.forClass(RiderPosition.class);
+        verify(riderPositionService).saveOrUpdate(captor.capture());
+        assertEquals(7L, captor.getValue().getId());
+    }
+
+    @Test
+    void concurrentInsertOnUniqueKeyFallsBackToUpdateByRider() {
+        when(riderPositionService.getOne(any(Wrapper.class), anyBoolean())).thenReturn(null);
+        // 并发上传双双查空:第二个 insert 撞 rider_id 唯一索引
+        when(riderPositionService.saveOrUpdate(any(RiderPosition.class)))
+                .thenThrow(new DuplicateKeyException("uk_rider_id"));
+        when(riderPositionService.update(any(RiderPosition.class), any(Wrapper.class))).thenReturn(true);
+
+        AjaxResult result = controller.setRiderposition(JwtUtil.token("9", "user"), new RiderPosition());
+
+        assertEquals(200, ((Number) result.get(AjaxResult.CODE_TAG)).intValue());
+        // 兜底转按 rider_id 更新已有记录,请求不 500
+        verify(riderPositionService).update(any(RiderPosition.class), any(Wrapper.class));
+    }
+
+    @Test
+    void firstUploadInsertsNewRecordWithRiderId() {
+        when(riderPositionService.getOne(any(Wrapper.class), anyBoolean())).thenReturn(null);
+        when(riderPositionService.saveOrUpdate(any(RiderPosition.class))).thenReturn(true);
+
+        AjaxResult result = controller.setRiderposition(JwtUtil.token("9", "user"), new RiderPosition());
+
+        assertEquals(200, ((Number) result.get(AjaxResult.CODE_TAG)).intValue());
+        ArgumentCaptor<RiderPosition> captor = ArgumentCaptor.forClass(RiderPosition.class);
+        verify(riderPositionService).saveOrUpdate(captor.capture());
+        assertEquals(9L, captor.getValue().getRiderId());
+        verify(riderPositionService, never()).update(any(RiderPosition.class), any(Wrapper.class));
+    }
+
+    @Test
+    void secondUploadUpdatesExistingRecordById() {
+        RiderPosition existing = new RiderPosition();
+        existing.setId(7L);
+        when(riderPositionService.getOne(any(Wrapper.class), anyBoolean())).thenReturn(existing);
+        when(riderPositionService.saveOrUpdate(any(RiderPosition.class))).thenReturn(true);
+
+        AjaxResult result = controller.setRiderposition(JwtUtil.token("9", "user"), new RiderPosition());
+
+        assertEquals(200, ((Number) result.get(AjaxResult.CODE_TAG)).intValue());
+        verify(riderPositionService, times(1)).saveOrUpdate(any(RiderPosition.class));
+        verify(riderPositionService, never()).update(any(RiderPosition.class), any(Wrapper.class));
+    }
+
+    @Test
+    void getRiderpositionReturnsPositionByRiderId() {
+        RiderPosition position = new RiderPosition();
+        position.setId(7L);
+        position.setRiderId(9L);
+        position.setLatitude(new BigDecimal("10.770"));
+        position.setLongitude(new BigDecimal("106.700"));
+        when(riderPositionService.getOne(any(Wrapper.class), eq(false))).thenReturn(position);
+
+        AjaxResult result = controller.getRiderposition(JwtUtil.token("9", "user"), 9L);
+
+        assertEquals(200, ((Number) result.get(AjaxResult.CODE_TAG)).intValue());
+        assertSame(position, result.get(AjaxResult.DATA_TAG));
+    }
+
+    @Test
+    void getRiderpositionWithoutRecordReturnsNullData() {
+        when(riderPositionService.getOne(any(Wrapper.class), eq(false))).thenReturn(null);
+
+        AjaxResult result = controller.getRiderposition(JwtUtil.token("9", "user"), 404L);
+
+        assertEquals(200, ((Number) result.get(AjaxResult.CODE_TAG)).intValue());
+        assertNull(result.get(AjaxResult.DATA_TAG));
+    }
+
+    @Test
+    void getRiderpositionDeclaresExplicitAnnotationsWithoutPermissionCheck() throws Exception {
+        Method method = RiderPositionController.class.getMethod("getRiderposition", String.class, Long.class);
+        assertArrayEquals(new String[]{"/getRiderposition"}, method.getAnnotation(GetMapping.class).value());
+        assertNotNull(method.getAnnotation(Anonymous.class));
+        assertNotNull(method.getAnnotation(Auth.class));
+        assertNull(method.getAnnotation(PreAuthorize.class));
+        Parameter[] parameters = method.getParameters();
+        assertNotNull(parameters[0].getAnnotation(RequestHeader.class));
+        RequestParam riderId = parameters[1].getAnnotation(RequestParam.class);
+        assertNotNull(riderId);
+    }
+}

+ 137 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/user/service/DeviceTrustServiceTest.java

@@ -0,0 +1,137 @@
+package com.ruoyi.app.user.service;
+
+import com.ruoyi.system.domain.InfoUserDevice;
+import com.ruoyi.system.mapper.InfoUserDeviceMapper;
+import org.junit.jupiter.api.Test;
+
+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.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+
+/**
+ * DeviceTrustService 单元测试(029-device-trust-phone-bind,TDD 先行)。
+ */
+class DeviceTrustServiceTest {
+
+    private final InfoUserDeviceMapper mapper = mock(InfoUserDeviceMapper.class);
+    private final DeviceTrustService service = new DeviceTrustService(mapper);
+
+    // ---------- isValidDeviceId ----------
+
+    @Test
+    void nullOrEmptyOrOversizeDeviceIdIsInvalid() {
+        assertFalse(DeviceTrustService.isValidDeviceId(null));
+        assertFalse(DeviceTrustService.isValidDeviceId(""));
+        assertFalse(DeviceTrustService.isValidDeviceId("x".repeat(65)));
+        assertTrue(DeviceTrustService.isValidDeviceId("x".repeat(64)));
+        assertTrue(DeviceTrustService.isValidDeviceId("dev-uuid-001"));
+    }
+
+    // ---------- recordLogin:首次 insert ----------
+
+    @Test
+    void firstVerifiedLoginInsertsNewTrustRowWithBothTimestamps() {
+        when(mapper.selectById("dev-1")).thenReturn(null);
+
+        service.recordLogin("dev-1", "0987654321", 100L);
+
+        verify(mapper).insert(any(InfoUserDevice.class));
+        verify(mapper, never()).updateById(any(InfoUserDevice.class));
+        org.mockito.ArgumentCaptor<InfoUserDevice> captor =
+                org.mockito.ArgumentCaptor.forClass(InfoUserDevice.class);
+        verify(mapper).insert(captor.capture());
+        InfoUserDevice saved = captor.getValue();
+        assertEquals("dev-1", saved.getDeviceId());
+        assertEquals("0987654321", saved.getPhone());
+        assertEquals(100L, saved.getUserId());
+        assertTrue(saved.getCreateTime() != null && saved.getUpdateTime() != null);
+    }
+
+    // ---------- recordLogin:同设备换号覆盖 ----------
+
+    @Test
+    void laterLoginWithDifferentPhoneOverwritesTrustRow() {
+        InfoUserDevice exist = new InfoUserDevice();
+        exist.setDeviceId("dev-1");
+        exist.setPhone("0980000001");
+        exist.setUserId(100L);
+        when(mapper.selectById("dev-1")).thenReturn(exist);
+
+        service.recordLogin("dev-1", "0987654321", 200L);
+
+        verify(mapper).updateById(exist);
+        verify(mapper, never()).insert(any(InfoUserDevice.class));
+        assertEquals("0987654321", exist.getPhone());
+        assertEquals(200L, exist.getUserId());
+        assertTrue(exist.getUpdateTime() != null);
+    }
+
+    // ---------- recordLogin:非法入参静默跳过 ----------
+
+    @Test
+    void invalidDeviceIdOrMissingPhoneOrUserSkipsSilently() {
+        service.recordLogin(null, "0987654321", 100L);
+        service.recordLogin("", "0987654321", 100L);
+        service.recordLogin("x".repeat(65), "0987654321", 100L);
+        service.recordLogin("dev-1", null, 100L);
+        service.recordLogin("dev-1", "0987654321", null);
+
+        verifyNoInteractions(mapper);
+    }
+
+    // ---------- getTrust / getTrustedPhone ----------
+
+    @Test
+    void getTrustReturnsNullForInvalidDeviceIdWithoutQuery() {
+        assertNull(service.getTrust(null));
+        assertNull(service.getTrust(""));
+        verifyNoInteractions(mapper);
+    }
+
+    @Test
+    void getTrustedPhoneReturnsRowPhoneOrNull() {
+        InfoUserDevice row = new InfoUserDevice();
+        row.setDeviceId("dev-1");
+        row.setPhone("0987654321");
+        when(mapper.selectById("dev-1")).thenReturn(row);
+        when(mapper.selectById("dev-2")).thenReturn(null);
+
+        assertEquals("0987654321", service.getTrustedPhone("dev-1"));
+        assertNull(service.getTrustedPhone("dev-2"));
+        verify(mapper).selectById("dev-1");
+        verify(mapper).selectById("dev-2");
+    }
+
+    // ---------- maskPhone(与现有日志脱敏规则一致:前3后4) ----------
+
+    @Test
+    void maskPhoneKeepsHead3Tail4AndMasksMiddle() {
+        assertEquals("098****4321", DeviceTrustService.maskPhone("0987654321"));
+    }
+
+    @Test
+    void maskPhoneReturnsShortValueAsIs() {
+        assertNull(DeviceTrustService.maskPhone(null));
+        assertEquals("1234567", DeviceTrustService.maskPhone("1234567"));
+    }
+
+    // ---------- 辅助:确认测试桩行为不受 anyString 干扰 ----------
+
+    @Test
+    void recordLoginSameDeviceTwiceOnlyQueriesByDeviceId() {
+        when(mapper.selectById(anyString())).thenReturn(null);
+        service.recordLogin("dev-1", "0987654321", 100L);
+        service.recordLogin("dev-1", "0987654321", 100L);
+        verify(mapper, org.mockito.Mockito.times(2)).selectById("dev-1");
+        assertSame("0987654321", "0987654321");
+    }
+}

+ 43 - 0
ruoyi-system/src/main/java/com/ruoyi/system/domain/InfoUserDevice.java

@@ -0,0 +1,43 @@
+package com.ruoyi.system.domain;
+
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import com.fasterxml.jackson.annotation.JsonFormat;
+import lombok.Data;
+
+import java.util.Date;
+
+/**
+ * 设备-手机号信任表 info_user_device。
+ * <p>记录"某台设备最近一次验证通过的手机号"(029-device-trust-phone-bind)。设备唯一标识
+ * 由 App 首启生成并持久化;凡手机号验证通过的登录成功(短信登录/三方短信绑定/三方已绑定直登/
+ * 一键确认登录)都会 upsert 本表,同设备以最近一次为准。之后该设备上三方登录遇未绑定身份时,
+ * 凭本表记录免短信一键确认绑定。</p>
+ *
+ * @author foodie
+ * @date 2026-09-20
+ */
+@Data
+@TableName("info_user_device")
+public class InfoUserDevice {
+
+    private static final long serialVersionUID = 1L;
+
+    /** 设备唯一标识(App 首启生成持久化,≤64),一台设备一行 */
+    @TableId
+    private String deviceId;
+
+    /** 本设备最近一次验证通过的手机号 */
+    private String phone;
+
+    /** 关联 info_user.user_id */
+    private Long userId;
+
+    /** 首次建立信任时间 */
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private Date createTime;
+
+    /** 最近一次登录刷新时间 */
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private Date updateTime;
+}

+ 2 - 0
ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/UserDTO.java

@@ -17,4 +17,6 @@ public class UserDTO {
     private String deviceToken;
     /** iOS VoIP 来电推送 token */
     private String voIPToken;
+    /** 设备唯一标识(App 首启生成持久化,可选,≤64;029 设备信任免绑) */
+    private String deviceId;
 }

+ 13 - 0
ruoyi-system/src/main/java/com/ruoyi/system/mapper/InfoUserDeviceMapper.java

@@ -0,0 +1,13 @@
+package com.ruoyi.system.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.ruoyi.system.domain.InfoUserDevice;
+
+/**
+ * 设备-手机号信任 Mapper(仅用 MyBatis-Plus CRUD,无自定义查询、无 XML)。
+ *
+ * @author foodie
+ * @date 2026-09-20
+ */
+public interface InfoUserDeviceMapper extends BaseMapper<InfoUserDevice> {
+}

+ 2 - 0
specs/027-offline-transfer-payment/data-model.md

@@ -14,6 +14,8 @@
 | 5 | Apple Pay | 不变 |
 | **6** | **線下轉賬(新增语义)** | 本功能 |
 
+> **2026-09-21 变更**:现金(4)开放用户端**自取/堂食**下单(原仅商家创建订单可用,外送 type=0 维持仅商家)。pay_status 语义不变:创建时 0,商家 `confirmCashPayment` 确认后置 1;自取/堂食单商家 `completeOrder`(state 2→3)时自动置 1。详见 spec.md 变更记录。
+
 ### PosOrder.pay_status(支付状态)
 - 0=未支付,1=已支付。转账单:创建时 0,商家确认收款时 0→1(confirmTransferPayment)。
 - 自取/堂食转账单:商家完成订单时现有逻辑自动置 1(completeOrder state 2→3)。

+ 22 - 0
specs/027-offline-transfer-payment/spec.md

@@ -122,3 +122,25 @@
 - 骑手运费由骑手与商家线下交接,系统不记录、不结算。
 - 支付方式值 6 为全新值,无历史数据冲突(历史值域 1-5)。
 - 商家确认收款入口同时面向商家 App 与商家 Web 预留(本期实现接口,Web 端按钮可后续按需补)。
+
+---
+
+## 2026-09-21 变更记录:现金支付开放用户端堂食/自取下单
+
+现金(payType=4)原规则「仅商家创建订单可用,客户端下单直接拒绝」调整为:**用户端自取(type=1)/堂食(type=2)可直接以现金下单**;外送(type=0)维持仅商家创建(客户端仍报 `no.paytype.cash.merchant.only`)。
+
+**改动点**(commit `31bb384`,branch test):
+
+1. `UserOrderController.createOrder`:现金限制改为「type 非 1/2 才拒绝」
+2. `UserOrderController.orderList` active tab:线下收款分支 `payType='1'` 扩为 `IN ('1','4')`——现金自取/堂食单在商家确认收款前即可见
+3. `PosOrderShOprateController.orderList` pending tab:同上扩展——否则用户下的现金自取单(state=0)不进商家待受理列表
+4. `UnifiedOrderTab` ACTIVE(SQL 条件与 matches 内存判定同源扩展,常量 `OFFLINE_PAYABLE_PAY_TYPES=['1','4']`)
+5. 测试同步:`OrderListPaymentFilterTest`、`UnifiedOrderTabTest` 补 payType=4 断言(62/62 绿)
+
+**不变的语义**:
+
+- `confirmCashPayment`(商家确认现金收款,payStatus 0→1)流程不变;商家创建现金单路径不变
+- 待付款 tab 仍仅在线支付未付(现金/到付不进待付款)
+- `completeOrder`(自取/堂食完成)自动 payStatus 0→1 不变
+- 用户取消仅限 state=0;现金单未支付、无退款路径
+- LinePayOrderGuard 只拦 LINE Pay 未付单,现金单受理/出餐/完成不受影响

+ 2 - 0
specs/027-offline-transfer-payment/tasks.md

@@ -59,3 +59,5 @@ T007,T010 ──> T011 ──> T012
 ## MVP
 
 US2(T001+T004+T005+T006):确认收款 + 骑手门禁是资金闸门核心;US1 银行信息接口紧随其后(App 展示依赖),US3 标签纯展示可最后。
+
+- [x] T013(2026-09-21 追加)现金支付开放用户端堂食/自取下单:createOrder 放开 type=1/2、用户/商家/统一列表三处 tab 条件扩到 payType 1/4、测试补断言(commit 31bb384,62/62 绿)

+ 35 - 0
specs/029-device-trust-phone-bind/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) — 注:tempKey/state/info_user_oauth 等为现有产品契约标识与项目既有概念,沿用 017-oauth-login 规格的行文惯例,非新引入的实现细节
+- [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 — 设计决策已在头脑风暴阶段与用户逐一确认(痛点场景、设备标识来源、一键确认交互、LINE state 方案)
+- [x] Requirements are testable and unambiguous
+- [x] Success criteria are measurable
+- [x] Success criteria are technology-agnostic (no implementation details) — SC-003 以"与现有接口同一量级"表述,避免绝对数值
+- [x] All acceptance scenarios are defined
+- [x] Edge cases are identified(老版本兼容/无信任/tempKey 过期/不匹配/账号停用/已绑定优先/共用设备/异常值)
+- [x] Scope is clearly bounded(不含商户/骑手端、不含前端实施)
+- [x] Dependencies and assumptions identified
+
+## Feature Readiness
+
+- [x] All functional requirements have clear acceptance criteria
+- [x] User scenarios cover primary flows(三条用户故事覆盖:免绑主流程、信任建立、LINE 回调链路)
+- [x] Feature meets measurable outcomes defined in Success Criteria
+- [x] No implementation details leak into specification
+
+## Notes
+
+- 所有条目通过,无需迭代。规格可直接进入 `/speckit-plan`。
+- FR-009/FR-010 为项目流程性要求(数据库变更管理、前端契约交付),对应 CLAUDE.md 规范。

+ 90 - 0
specs/029-device-trust-phone-bind/contracts/api-contract.md

@@ -0,0 +1,90 @@
+# 前端接口契约:设备信任免绑手机号(uni-app 用户端)
+
+**Date**: 2026-09-20 | **Spec**: [spec.md](../spec.md) | **后端分支**: `029-device-trust-phone-bind`
+
+> 本文档是后端对前端的唯一契约。前端按此实施,无需口头追问。所有接口均为现有接口的**增量**,不传新字段时行为与现网完全一致(老版本 App 无感)。
+
+## 0. 设备唯一标识 deviceId(前端生成)
+
+- **定义**:App 首次启动时生成的 UUID 字符串,**≤64 字符**,持久化保存,之后每次登录请求都带上。
+- **持久化建议**:
+  - **iOS**:存 Keychain(卸载重装不丢,有现成 uni-app 插件);退化用 storage 也可,只是重装后要重新绑一次。
+  - **Android**:优先取 `ANDROID_ID`(同设备+同签名跨重装稳定、免权限);退化用 storage 存自生成 UUID。
+- **传参规则**:字符串、可选。空串/超长后端按未传处理。**不要**在未登录的其它业务接口上报。
+
+## 1. 入参变更(三个现有接口加可选字段 `deviceId`)
+
+| 接口 | 位置 | 变更 |
+|---|---|---|
+| `POST /infouser/user/lodeing` | UserDTO | + `deviceId`(登录成功后端记录设备信任) |
+| `POST /infouser/user/oauthLogin` | OAuthLoginDto | + `deviceId`(未绑定时影响返回分支,见 §2) |
+| `POST /infouser/user/oauthBindPhone` | OAuthBindDto | + `deviceId`(绑定成功后端记录设备信任) |
+
+## 2. `oauthLogin` 返回新增分支 `deviceConfirm`
+
+现有返回不变,新增一种:
+
+| 返回(AjaxResult data) | 条件 | App 行为 |
+|---|---|---|
+| `token` + 用户信息 | 三方身份已绑定 | 直接进 App(现状不变) |
+| `{status:"needPhone", tempKey}` | 未绑定,且设备无信任记录 | 现有输手机号+短信流程(不变) |
+| `{status:"deviceConfirm", tempKey, maskedPhone}` **新增** | 未绑定,且 deviceId 有信任记录 | 弹一键确认框,见 §3 |
+
+- `maskedPhone`:脱敏手机号,格式 `前3位****后4位`(如 `098****4321`),**只读展示,不可编辑、不提供换号输入**。
+- tempKey 有效期 **5 分钟**,过期后确认接口返回 `no.oauth.tempkey.expired`,需重新点三方登录拿新的。
+
+## 3. 确认弹窗规范
+
+- 文案示意:`将绑定 {maskedPhone} 并登录`;按钮:`确认` / `取消`。
+- **确认** → 调 §4 新接口;**取消** → 关闭弹窗回登录页,不产生任何绑定(用户仍可用手机号+短信登录,或换其它三方登录)。
+- 弹窗内不出现验证码输入框、不出现可编辑手机号。
+
+## 4. 新接口:`POST /infouser/user/oauthDeviceConfirm`
+
+**用途**:设备确认分支的一键绑定登录(免短信)。
+
+```json
+// 请求体(OAuthDeviceConfirmDto)
+{ "tempKey": "abc123...", "deviceId": "uuid-..." }
+```
+
+**成功**:响应结构与 `oauthBindPhone` 成功完全一致(msg=登录成功、`user` 用户信息、`token`),直接进 App。
+
+**失败**(AjaxResult error,msg 为国际化文案):
+
+| 场景 | 错误 key(参考) | App 处理建议 |
+|---|---|---|
+| tempKey 缺失 | `no.oauth.tempkey.missing` | 提示后回登录页 |
+| tempKey 过期/不存在 | `no.oauth.tempkey.expired` | 提示"请重新登录",回登录页 |
+| deviceId 缺失 / tempKey 不是设备确认凭证 / deviceId 不匹配 / 设备无信任记录 | `no.oauth.device.mismatch` | 提示后回登录页(可回落手动绑定流程) |
+| 信任手机号账号已停用 | `no.user.stop` | 提示账号停用,回登录页 |
+| 期间该三方身份已被其它端绑定 | 无错误——直接返回 token 登录成功(容错直登,与 oauthBindPhone 一致) | 直接进 App |
+
+## 5. LINE 登录(网页回调链路)
+
+**authorize URL 变更(唯一的必改点)**:`state` 参数从随机串改为传 `deviceId`:
+
+```
+https://access.line.me/oauth2/v2.1/authorize?response_type=code&client_id=...&redirect_uri=...&scope=profile%20openid&state={deviceId}
+```
+
+**302 回跳 App(`com.twanmsdyh.app://oauthLogin`)分支变化**:
+
+| 回跳参数 | 条件 | App 行为 |
+|---|---|---|
+| `?token=xxx` | 已绑定 | 进 App(现状不变) |
+| `?needPhone=1&tempKey=xxx` | 未绑定、无设备信任 | 现有手机号+短信流程(不变) |
+| `?deviceConfirm=1&tempKey=xxx&maskedPhone=098****4321` **新增** | 未绑定、state 携带的 deviceId 有信任记录 | 弹 §3 同款确认框,确认 → §4 接口 |
+| `?error=xxx` | 异常 | 现状不变 |
+
+- `maskedPhone` 已做 URL 编码,App 解码后展示。
+- 走 `POST /oauthLogin`(前端自拿 code 的场景,如 H5)时同样受益:body 里带 `deviceId` 即可,返回分支同 §2。
+
+## 6. 联调自测清单
+
+1. 手机号+短信登录(带 deviceId)→ 卸载重装前,用 Apple 新身份登录 → 应弹 deviceConfirm(maskedPhone = 刚登录的号)。
+2. deviceConfirm 确认 → 返回 token,进 App 后"我的"页手机号 = maskedPhone 对应账号。
+3. deviceConfirm 取消 → 再用 Google 新身份登录 → 仍弹 deviceConfirm(未产生绑定)。
+4. 不带 deviceId(模拟老版本)→ 三方新身份登录 → 走 needPhone 原流程。
+5. 等 tempKey 过期(>5 分钟)后点确认 → 收到 `no.oauth.tempkey.expired`。
+6. LINE:authorize state=deviceId → 已信任设备走 deviceConfirm 回跳分支。

+ 68 - 0
specs/029-device-trust-phone-bind/data-model.md

@@ -0,0 +1,68 @@
+# Data Model: 设备信任免绑手机号
+
+**Date**: 2026-09-20 | **Spec**: [spec.md](spec.md)
+
+## 新增实体:InfoUserDevice(表 `info_user_device`)
+
+一台设备一行,`device_id` 主键;记录该设备**最近一次**手机号验证通过的登录。
+
+| 字段 | 类型 | 约束 | 说明 |
+|---|---|---|---|
+| device_id | VARCHAR(64) | PK,NOT NULL | 设备唯一标识(App 首启生成持久化,≤64) |
+| phone | VARCHAR(20) | NOT NULL | 最近一次验证通过的手机号(账号主键,越南 +84) |
+| user_id | BIGINT | NOT NULL | phone 对应的 info_user.user_id |
+| create_time | DATETIME | NULL | 首次建立信任时间 |
+| update_time | DATETIME | NULL | 最近一次登录刷新时间 |
+
+**DDL(写入 `updatesql/sql.md`,人工执行):**
+
+```sql
+-- 2026-09-20 设备绑定手机号信任表(同设备三方登录免绑,029-device-trust-phone-bind)
+CREATE TABLE info_user_device (
+  device_id   VARCHAR(64) NOT NULL COMMENT '设备唯一标识(App首启生成持久化)',
+  phone       VARCHAR(20) NOT NULL COMMENT '本设备最近一次验证通过的手机号',
+  user_id     BIGINT NOT NULL COMMENT '对应用户ID(info_user.user_id)',
+  create_time DATETIME DEFAULT NULL COMMENT '首次建立信任时间',
+  update_time DATETIME DEFAULT NULL COMMENT '最近一次登录刷新时间',
+  PRIMARY KEY (device_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='设备-手机号信任表(029)';
+```
+
+### 校验与写入规则
+
+- 写入前置校验(Service 层):deviceId 非空且长度 ≤64,否则跳过记录(不报错,静默降级);phone、userId 必须来自**已验证通过的登录成功路径**。
+- **Upsert 语义**:`selectById(deviceId)` 命中 → 更新 phone/user_id/update_time(共用设备换号 = 最近一次覆盖);未命中 → insert,create_time = update_time = now。
+- 删除:本期不提供解除信任接口(YAGNI);误绑可通过确认框取消避免,后续需要再加。
+
+### 状态迁移(单行生命周期)
+
+```text
+[不存在] --首次验证登录(带deviceId)--> [信任 phone_A]
+[信任 phone_A] --phone_B 在本设备登录成功--> [信任 phone_B](覆盖)
+[信任 phone_X] --同号再次登录--> [信任 phone_X](仅刷新 update_time)
+```
+
+## 复用/扩展:临时凭证(Redis)
+
+| Key | 值 | TTL | 写入点 | 消费点 |
+|---|---|---|---|---|
+| `oauth:bind:{tempKey}` | `provider@providerUid`(**现状不变**) | 5 min | oauthLogin / LINE 回调 未绑定分支 | oauthBindPhone / oauthDeviceConfirm |
+| `oauth:bind:dev:{tempKey}`(**新增**) | `deviceId`(仅 deviceConfirm 分支写入) | 5 min | oauthLogin / LINE 回调 未绑定且设备有信任 | oauthDeviceConfirm |
+
+- 两个 key 同 TTL 写入;`oauthDeviceConfirm` 消费时要求两键齐全,且 `dev` 值与入参 deviceId 相等,否则按"凭证与设备不匹配"拒绝。
+- 确认成功后两个 key 一并删除(与 oauthBindPhone 删除现有 key 一致)。
+
+## 复用不变:InfoUserOauth(表 `info_user_oauth`)
+
+`UNIQUE(provider, provider_uid)` 语义不变。一键确认 = 向此表新增记录(provider + providerUid → 设备信任手机号对应 userId),插入逻辑与 `oauthBindPhone` 相同。
+
+## 脱敏规则(maskedPhone)
+
+复用现有 `maskPhone`:长度 ≥8 时返回 `前3位 + "****" + 后4位`(如 `098****4321`);长度不足 8 原样返回。DeviceTrustService 提供该方法,InfoUserController 现有私有实现改调共用(行为不变)。
+
+## 新增消息 key(i18n 资源文件,多语言)
+
+| key | 用途 |
+|---|---|
+| `no.oauth.device.confirm` | deviceConfirm 分支的成功提示语 |
+| `no.oauth.device.mismatch` | 确认接口:tempKey 非设备确认凭证 / dev 键缺失 / deviceId 不匹配 / 设备信任记录不存在 |

+ 103 - 0
specs/029-device-trust-phone-bind/frontend-guide.md

@@ -0,0 +1,103 @@
+# 前端接口调整说明:同设备三方登录免绑手机号(029)
+
+**后端分支**: `test`(`cb8721b` 起) | **日期**: 2026-09-20
+
+> 一句话:登录请求多传一个可选字段 `deviceId`;三方登录多一种返回分支 `deviceConfirm`,弹只读确认框后调一个新接口即可,全程免短信。
+
+## deviceId 字段说明
+
+- 字符串,**可选**,长度 ≤64,所有接口通用同一个值;
+- 前端自行生成并持久化(建议:首启生成 UUID 长期保存,尽量卸载重装后不变,如 iOS 存 Keychain / Android 用 ANDROID_ID——具体实现前端自定);
+- **不传 = 与现在完全一致**,老版本 App 无影响。
+
+---
+
+## 一、调整的 3 个现有接口(各加 1 个可选参数)
+
+### 1. `POST /infouser/user/lodeing`(手机号+验证码登录)
+
+```json
+{ "phone": "0987654321", "code": "123456", "deviceId": "a3f8c2d4..." }
+```
+
+返回不变。传了 `deviceId` 后,后端会记住"这台设备验证过这个手机号",之后三方登录才能免绑。
+
+### 2. `POST /infouser/user/oauthLogin`(三方登录)
+
+```json
+{ "provider": "apple", "credential": "<idToken>", "deviceId": "a3f8c2d4..." }
+```
+
+**返回由 2 种变 3 种**(`data` 内容判断):
+
+| `data.status` | 含义 | 前端处理 |
+|---|---|---|
+| 无(直接返回 `token`+用户) | 三方身份已绑定 | 现状不变,直接登录 |
+| `"needPhone"` + `tempKey` | 未绑定,设备无信任 | 现状不变:弹手机号+短信绑定 UI → `oauthBindPhone` |
+| `"deviceConfirm"` + `tempKey` + `maskedPhone` **新增** | 未绑定,但本设备验证过手机号 | 弹确认框(见下)→ 确认后调新接口 `oauthDeviceConfirm` |
+
+`maskedPhone` 为脱敏手机号(如 `098****4321`),**只读展示用,不要提交**。
+
+### 3. `POST /infouser/user/oauthBindPhone`(三方绑手机号)
+
+```json
+{ "tempKey": "xxx", "phone": "0987654321", "code": "123456", "deviceId": "a3f8c2d4..." }
+```
+
+返回不变,传 `deviceId` 只是为了让后端记录设备信任。
+
+---
+
+## 二、新增 1 个接口:`POST /infouser/user/oauthDeviceConfirm`
+
+用于 `deviceConfirm` 分支的一键确认(免短信)。
+
+**入参**:
+
+```json
+{ "tempKey": "oauthLogin 返回的 tempKey", "deviceId": "本机 deviceId" }
+```
+
+**成功**:返回结构与 `oauthBindPhone` 成功完全一致(`msg` + `user` + `token`),按现有登录成功逻辑处理。特殊:若确认期间该三方身份已被其它端绑定,**不报错**,直接返回 token 登录成功。
+
+**失败**(`code=500`,`msg` 可直接 toast):
+
+| msg 含义 | 处理 |
+|---|---|
+| 登录凭证已过期(tempKey 5 分钟有效) | 提示后回登录页,重新点三方登录 |
+| 设备确认无效(tempKey 不是 deviceConfirm 凭证 / deviceId 不匹配 / 设备无信任) | 提示后回登录页 |
+| 账号已停用 | 提示账号停用,回登录页 |
+
+**确认弹窗规范**:正文"将绑定 {maskedPhone} 并登录",手机号**只读不可编辑**,弹窗内无手机号/验证码输入框;点"取消"直接关闭,无任何副作用。
+
+---
+
+## 三、LINE 登录(网页回调链路):`state` 传参调整
+
+**唯一改动**:打开 LINE 授权 URL 时,`state` 参数从随机串改为传 `deviceId`:
+
+```
+https://access.line.me/oauth2/v2.1/authorize?...&state={deviceId}
+```
+
+**302 回跳参数**(`com.twanmsdyh.app://oauthLogin?...`)由 3 种变 4 种:
+
+| 参数 | 处理 |
+|---|---|
+| `token=xxx` | 现状不变 |
+| `needPhone=1&tempKey=xxx` | 现状不变 |
+| `deviceConfirm=1&tempKey=xxx&maskedPhone=098****4321` **新增** | 弹同款确认框(`maskedPhone` 记得 URL 解码)→ 调 `oauthDeviceConfirm` |
+| `error=xxx` | 现状不变 |
+
+前端自拿 code 走 `POST /oauthLogin` 的场景:body 带 `deviceId` 即自动生效,分支同上表。
+
+---
+
+## 四、改动清单(速查)
+
+1. `lodeing` / `oauthLogin` / `oauthBindPhone` 请求体 + `deviceId`(可选)
+2. `oauthLogin` 返回新增 `deviceConfirm` 分支 → 新确认弹窗
+3. 新接口 `oauthDeviceConfirm {tempKey, deviceId}`
+4. LINE authorize 的 `state` = `deviceId`,回跳新增 `deviceConfirm=1` 分支
+
+字段与错误码细节见 [contracts/api-contract.md](contracts/api-contract.md)。

+ 98 - 0
specs/029-device-trust-phone-bind/plan.md

@@ -0,0 +1,98 @@
+# Implementation Plan: 设备信任免绑手机号
+
+**Branch**: `029-device-trust-phone-bind` | **Date**: 2026-09-20 | **Spec**: [spec.md](spec.md)
+
+**Input**: Feature specification from `/specs/029-device-trust-phone-bind/spec.md`
+
+## Summary
+
+同一台设备只要验证过一次手机号(短信登录 / 三方短信绑定 / 三方已绑定直登),即建立"设备→手机号"信任;之后该设备上任何未绑定三方身份登录时,返回脱敏手机号 + tempKey 让用户一键确认(免短信),确认后按现有绑定规则完成绑定并签发 token。LINE 网页回调链路通过 `state` 参数携带设备标识获得同等能力。只改后端,产出前端契约文档。
+
+技术路径:新表 `info_user_device`(MyBatis-Plus BaseMapper,无 XML)+ 新服务 `DeviceTrustService`(upsert/查询/脱敏)+ `InfoUserController` 三接口加 `deviceId` 入参与 `deviceConfirm` 分支 + 新接口 `oauthDeviceConfirm` + `LineCallbackController` state 解析。临时凭证沿用 `oauth:bind:` 前缀,新增正交 key `oauth:bind:dev:{tempKey}` 绑定设备标识,现有解析逻辑零改动。
+
+## Technical Context
+
+**Language/Version**: Java 21(Maven 多模块:ruoyi-admin / ruoyi-system / ruoyi-common)
+
+**Primary Dependencies**: Spring Boot、MyBatis + MyBatis-Plus(baomidou)、Lombok、RedisCache(Spring Redis)、自写 JwtUtil(HS256 + Redis 会话)、MessageUtils(i18n messages)
+
+**Storage**: MySQL(新表 `info_user_device`,DDL 只写 `updatesql/sql.md` 人工执行);Redis(临时凭证 `oauth:bind:*`,TTL 5 分钟)
+
+**Testing**: JUnit 5 + Mockito,测试位于 `ruoyi-admin/src/test/java/com/ruoyi/app/user/`(沿用 flashdelivery 测试组织方式)。构建命令:临时 `JAVA_HOME=%USERPROFILE%\.jdks\graalvm-jdk-21.0.12+7.1`,`mvn -pl ruoyi-admin -am test -Dtest=XxxTest -DfailIfNoSpecifiedTests=false`
+
+**Target Platform**: Linux 服务器部署的 Spring Boot Web 服务(越南用户端 uni-app 的后端)
+
+**Project Type**: web-service(单体多模块 Maven)
+
+**Performance Goals**: 与现有登录接口同量级(服务端处理 < 200ms),仅新增 1 次主键查询/写入,无新增外部调用
+
+**Constraints**: Controller 参数规范(@RequestBody DTO、DTO 无 Bean Validation 注解、校验放 Controller 用 MessageUtils);模块依赖 ruoyi-admin → ruoyi-system;SQL 不直接执行;保留文件原有 CRLF 换行风格
+
+**Scale/Scope**: 改 3 个 Controller/DTO 既有文件 + 新增 3 个 Java 文件 + 1 个实体/Mapper + SQL + 测试 + 前端契约文档;前端 uni-app 不在范围
+
+## Constitution Check
+
+*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
+
+`.specify/memory/constitution.md` 为未填写的空模板,无项目级 gates。以下以 CLAUDE.md 项目规范替代核对:
+
+| 约束 | 结果 |
+|---|---|
+| Controller 请求参数规范(DTO + @RequestBody,无 Map 入参,无 Bean Validation 注解) | PASS — 新接口用 `OAuthDeviceConfirmDto`,deviceId 校验在 Controller 内 |
+| 模块依赖方向 ruoyi-admin → ruoyi-system | PASS — 实体/Mapper 放 ruoyi-system,服务/编排放 ruoyi-admin |
+| 数据库变更写入 updatesql/sql.md 不直接执行 | PASS — 见 tasks |
+| Java 注释安全(块注释无 `*/` 序列) | PASS |
+| 已废弃代码清单(不参考不增量修改) | PASS — 不触碰 PayController/ZaloPay 等废弃代码 |
+| 后端 i18n 错误消息走 MessageUtils | PASS — 新增消息 key 加到 i18n 资源文件 |
+
+Phase 1 设计后复核:全部 PASS,无 Complexity Tracking 需要填写的违规项。
+
+## Project Structure
+
+### Documentation (this feature)
+
+```text
+specs/029-device-trust-phone-bind/
+├── plan.md              # This file (/speckit-plan command output)
+├── research.md          # Phase 0 output (/speckit-plan command)
+├── data-model.md        # Phase 1 output (/speckit-plan command)
+├── quickstart.md        # Phase 1 output (/speckit-plan command)
+├── contracts/
+│   └── api-contract.md  # 前端(uni-app)接口契约
+└── tasks.md             # Phase 2 output (/speckit-tasks command - NOT created by /speckit-plan)
+```
+
+### Source Code (repository root)
+
+```text
+ruoyi-admin/src/main/java/com/ruoyi/app/user/
+├── InfoUserController.java          # lodeing/oauthLogin/oauthBindPhone 记录设备信任;oauthLogin 新增 deviceConfirm 分支;新接口 oauthDeviceConfirm
+├── LineCallbackController.java      # state 解析为 deviceId;未绑定分支查设备信任回跳 deviceConfirm;已绑定路径记录信任
+├── dto/
+│   ├── OAuthLoginDto.java           # +deviceId
+│   ├── OAuthBindDto.java            # +deviceId
+│   └── OAuthDeviceConfirmDto.java   # 新增 {tempKey, deviceId}
+└── service/
+    └── DeviceTrustService.java      # 新增:recordLogin(upsert)/getTrust/maskPhone
+
+ruoyi-admin/src/test/java/com/ruoyi/app/user/
+└── service/
+    └── DeviceTrustServiceTest.java  # 设备记录/覆盖/脱敏规则
+└── DeviceTrustFlowTest.java         # oauthLogin 分支判定 + oauthDeviceConfirm 成功/过期/不匹配/停用路径
+
+ruoyi-system/src/main/java/com/ruoyi/system/
+├── domain/InfoUserDevice.java       # 新增实体(@TableName info_user_device)
+├── domain/vo/UserDTO.java           # +deviceId
+└── mapper/InfoUserDeviceMapper.java # 新增(MP BaseMapper,无 XML,同 InfoUserOauthMapper 模式)
+
+ruoyi-system/src/main/resources/i18n/                                 # 新增消息 key(多语言)
+updatesql/sql.md                                                      # 建表 DDL 追加(带日期注释)
+```
+
+**Structure Decision**: 单体多模块 Maven。数据层(实体/Mapper)按既有模式放 ruoyi-system;业务编排与接口改造全部在 ruoyi-admin 的 app.user 包(登录逻辑现状集中在 InfoUserController,保持就近、surgical);新增 DeviceTrustService 承载可单测的纯逻辑(upsert/查询/脱敏),Controller 只做参数校验与编排。
+
+## Complexity Tracking
+
+> **Fill ONLY if Constitution Check has violations that must be justified**
+
+无违规项。

+ 82 - 0
specs/029-device-trust-phone-bind/quickstart.md

@@ -0,0 +1,82 @@
+# Quickstart: 设备信任免绑手机号
+
+**Date**: 2026-09-20 | **Spec**: [spec.md](spec.md) | **契约**: [contracts/api-contract.md](contracts/api-contract.md)
+
+## 前置条件
+
+1. **数据库**:`updatesql/sql.md` 中"2026-09-20 设备绑定手机号信任表"DDL 已由开发者手动执行(本任务只追加脚本不执行)。
+2. **Redis**:本地 Redis 可用(临时凭证依赖)。
+3. **JDK**:临时设置(不改全局配置,Git Bash 示例):
+   ```bash
+   export JAVA_HOME="$HOME/.jdks/graalvm-jdk-21.0.12+7.1"
+   export PATH="$JAVA_HOME/bin:$PATH"
+   ```
+4. **短信万能码**:沿用现网 `8888`(测试不必真实收码)。
+
+## 自动化验证(测试命令)
+
+```bash
+# 全量跑本 feature 测试(DeviceTrust + 分支/确认流程)
+mvn -pl ruoyi-admin -am test -Dtest='DeviceTrust*Test' -Dsurefire.failIfNoSpecifiedTests=false
+
+# 编译校验(含 ruoyi-system)
+mvn -pl ruoyi-admin -am compile
+```
+
+预期:全部通过。已知基线红测试与本 feature 无关(flash payType 白名单,见项目记忆)。
+
+## 手动端到端场景(curl 模拟,配合契约文档 §6)
+
+服务起在本地(默认 8080,按实际部署调整)。`DEV=dev-uuid-001` 为模拟设备标识。
+
+### 场景 A:建立信任 → 三方免绑
+
+```bash
+# 1. 手机号短信登录(万能码 8888),带 deviceId —— 建立/刷新设备信任
+curl -X POST http://localhost:8080/infouser/user/lodeing \
+  -H 'Content-Type: application/json' \
+  -d '{"phone":"0987654321","code":"8888","deviceId":"dev-uuid-001"}'
+# 预期:登录成功 token;DB: info_user_device 出现 device_id=dev-uuid-001, phone=0987654321
+
+# 2. 查库确认(人工)
+#    SELECT * FROM info_user_device WHERE device_id='dev-uuid-001';
+
+# 3. 用"新的"三方身份登录(credential 用测试渠道或 mock)
+curl -X POST http://localhost:8080/infouser/user/oauthLogin \
+  -H 'Content-Type: application/json' \
+  -d '{"provider":"google","credential":"<test-idtoken>","deviceId":"dev-uuid-001"}'
+# 预期:data = {status:"deviceConfirm", tempKey:"...", maskedPhone:"098****4321"}
+
+# 4. 一键确认(免短信)
+curl -X POST http://localhost:8080/infouser/user/oauthDeviceConfirm \
+  -H 'Content-Type: application/json' \
+  -d "{\"tempKey\":\"<第3步tempKey>\",\"deviceId\":\"dev-uuid-001\"}"
+# 预期:返回 token + user(即 0987654321 的账号);info_user_oauth 新增 google 绑定行
+```
+
+### 场景 B:兼容性回归(必须零差异)
+
+```bash
+# 不带 deviceId 的老版本行为
+curl -X POST http://localhost:8080/infouser/user/oauthLogin \
+  -H 'Content-Type: application/json' \
+  -d '{"provider":"google","credential":"<另一个新身份>"}'
+# 预期:{status:"needPhone", tempKey}(原样)
+```
+
+### 场景 C:异常路径
+
+- tempKey 过期(>5 分钟再确认)→ `no.oauth.tempkey.expired`
+- 确认时换 deviceId(`dev-uuid-999`)→ `no.oauth.device.mismatch`,不产生绑定
+- 信任手机号账号停用(先手动 UPDATE status)→ `no.user.stop`
+
+### 场景 D:LINE 回调
+
+浏览器打开 authorize URL(`state=dev-uuid-001`)→ 走完 LINE 同意 → 观察 302 回跳参数:
+- 已信任:`deviceConfirm=1&tempKey=...&maskedPhone=098****4321`
+- 无信任(state 换成新值):`needPhone=1&tempKey=...`
+
+## 通过标准
+
+- 自动化测试全绿;场景 A/B/C/D 行为与 [contracts/api-contract.md](contracts/api-contract.md) 逐条一致。
+- 老版本兼容:所有不带 deviceId 的请求与改造前行为完全一致。

+ 59 - 0
specs/029-device-trust-phone-bind/research.md

@@ -0,0 +1,59 @@
+# Research: 设备信任免绑手机号
+
+**Date**: 2026-09-20 | **Status**: 全部决策已与用户确认,无未解决 NEEDS CLARIFICATION
+
+本 feature 的关键决策均在头脑风暴阶段与用户逐条确认,来源为对现有代码(017-oauth-login 实现即 `InfoUserController` / `LineCallbackController` / `OAuthVerifyService`)的实地调查。以下记录决策、理由与被否方案。
+
+## D1: 设备信任的存储——独立新表 `info_user_device`
+
+- **Decision**: 新建独立表,`device_id` 为主键,一台设备一行(device_id → phone → user_id → 时间戳)。
+- **Rationale**: 设备与用户是一对多(共用设备换号登录),挂 `info_user` 字段表达不了"设备维度最近一次验证的手机号";独立表 upsert 语义简单(按 PK insert-or-update),不动用户大表。
+- **Alternatives**: ① 在 `info_user` 上加 `device_id` 字段——只能记"该用户最后登录的设备",语义相反,共用设备会互相覆盖且查不出"设备信任谁";② 复用 `info_user_oauth` 加 provider='device'——语义混淆,破坏 UNIQUE(provider, provider_uid) 的三方账号语义。均否。
+
+## D2: 免绑交互——免短信一键确认(脱敏手机号只读)
+
+- **Decision**: `oauthLogin` 未绑定时若设备有信任记录,返回 `{status:"deviceConfirm", tempKey, maskedPhone}`;App 弹"将绑定 098\*\*\*\*789 并登录"确认框,手机号**只读不可改**,确认后调新接口 `oauthDeviceConfirm` 完成绑定登录,不发短信。
+- **Rationale**: 用户拍板选"一键确认"而非"静默绑定":共用设备场景下用户能看到将绑定给谁,防止无声进错账号;同时相比短信验证仍零输入。只读是用户的明确要求——换号走取消后手动绑定。
+- **Alternatives**: ① 静默绑定(零点击)——被否,共用设备误绑风险;② 确认框里允许改手机号——被用户明确否决(只能看不能改)。
+
+## D3: 设备标识与临时凭证的绑定方式——正交 Redis key
+
+- **Decision**: 沿用 `oauth:bind:{tempKey}` = `provider@providerUid`(**值格式不变**),deviceConfirm 分支额外写 `oauth:bind:dev:{tempKey}` = `deviceId`(同 TTL 5 分钟)。`oauthDeviceConfirm` 校验两键齐全且 dev 值与入参 deviceId 相等。
+- **Rationale**: 现有 `oauthBindPhone` 用 `indexOf('@')` 解析值,若把 deviceId 拼进值里会产生解析歧义与跨流程误用(deviceConfirm 的 tempKey 被喂给 oauthBindPhone 会拼出错误的 providerUid)。正交 key 让现有代码零改动、两种 tempKey 天然隔离。
+- **Alternatives**: ① 值扩为 `provider@uid@deviceId`——解析歧义,需同时改两处解析,否;② tempKey 存 JSON——现有值为纯字符串,引入序列化不一致,否。
+
+## D4: LINE 回调携带设备标识——复用 `state` 参数
+
+- **Decision**: 前端打开 LINE 授权 URL 时把 deviceId 放进 `state`(现状为前端随机串,后端不校验);`LineCallbackController.callback` 把 `state` 当 deviceId 用。未绑定分支:设备有信任 → 302 回跳 `deviceConfirm=1&tempKey=xxx&maskedPhone=xxx`;无信任 → 维持 `needPhone=1&tempKey=xxx`。已绑定路径也记录设备信任。
+- **Rationale**: LINE 官方 authorize 支持 `state` 且原样回传,现网已在传(仅作 CSRF 占位,后端未校验);复用它零流程变更,还顺带解决了 017 遗留问题"回调链路没有 App 上下文"。
+- **Alternatives**: ① 回调前先让 App 调一次后端预登记(额外接口 + 时序耦合)——否;② LINE 分支不做免绑(用户明确要求做)——否。
+
+## D5: 设备标识来源与契约——后端只收字符串,生成归前端
+
+- **Decision**: 后端约定 `deviceId`:可选、string、≤64;缺省/空串/超长一律按未传处理,走原流程。生成与持久化(iOS 建议 Keychain、Android 建议 ANDROID_ID,均跨卸载重装)由 uni-app 前端负责,写入前端契约文档。
+- **Rationale**: 后端不关心标识怎么来;可选参数保证老版本 App 100% 兼容。
+- **Alternatives**: ① 复用现有 `cid`(uni 推送客户端 ID)——重装即变且 cidType 现状传空,稳定性不满足"绑定一次"预期,否;② 后端采集 IP/UA 指纹——不可靠且涉及隐私,否。
+
+## D6: 信任记录的写入点——四种登录成功点收敛到一个服务方法
+
+- **Decision**: `DeviceTrustService.recordLogin(deviceId, phone, userId)`(按 PK upsert,最近一次覆盖),在四处调用:`lodeing` 成功、`oauthLogin` 已绑定直登成功、`oauthBindPhone` 成功、`oauthDeviceConfirm` 成功;LINE 回调已绑定路径同样调用。
+- **Rationale**: 规则一句话"手机号验证通过 + 登录成功 + 带了 deviceId → 记录";收敛到一处便于单测与后续扩展。共用设备语义 = 最近一次登录的手机号(D2 的确认框可让用户看见当前绑谁)。
+- **Alternatives**: 在每处 Controller 里各自写 mapper 调用——重复且易漏,否。
+
+## D7: 数据访问模式——MyBatis-Plus BaseMapper,无 XML
+
+- **Decision**: `InfoUserDeviceMapper extends BaseMapper<InfoUserOauth 同款>`,实体 `@TableName("info_user_device")`,不用 XML resultMap。
+- **Rationale**: 仓库先例 `InfoUserOauthMapper` 注释明确"仅用 MP CRUD,无自定义查询、无 XML";本 feature 只有按 PK 的 select/insert/update,MP 原生覆盖。
+- **Alternatives**: 手写 XML mapper——项目规范"全栈字段清单"针对业务查询字段,纯 PK CRUD 用 XML 是过度工程,否。
+
+## D8: 脱敏手机号格式——复用现有 `maskPhone` 规则
+
+- **Decision**: maskedPhone 复用 `InfoUserController.maskPhone`(前 3 + `****` + 后 4),抽到 `DeviceTrustService` 共用。
+- **Rationale**: 与现有日志脱敏风格一致,避免两套规则。
+- **Alternatives**: 前 3 后 3——与现网工具不一致,否。
+
+## D9: 账号停用/删除处理——与现有绑定流程同一套校验
+
+- **Decision**: `oauthDeviceConfirm` 复用 `oauthBindPhone` 的账号校验:`status != 0` 停用 → `no.user.stop` 错误;get-or-create(`getuser(phone)`,不存在则按现有规则新建 + 建钱包);期间已被绑定的容错直登也保持一致。
+- **Rationale**: 一键确认是绑定流程的"免短信变体",账号语义必须与短信绑定完全一致,防止绕过停用。
+- **Alternatives**: 无(一致性要求)。

+ 110 - 0
specs/029-device-trust-phone-bind/spec.md

@@ -0,0 +1,110 @@
+# Feature Specification: 设备信任免绑手机号(同设备三方登录免重复绑定)
+
+**Feature Branch**: `029-device-trust-phone-bind`
+
+**Created**: 2026-09-20
+
+**Status**: Draft
+
+**Input**: User description: "登录时记录设备唯一标识 deviceId(App 首启生成持久化传入)。同一设备任一登录流程验证过手机号(短信登录/三方绑号/三方已绑定直登)即记录 deviceId→手机号信任;之后该设备上三方登录遇未绑定身份时返回脱敏手机号一键确认(只读、免短信),确认后直接绑定并签发 token。只改后端并输出前端契约文档;SQL 写入 updatesql/sql.md。"
+
+## User Scenarios & Testing *(mandatory)*
+
+### User Story 1 - 同设备三方登录免短信一键绑定 (Priority: P1)
+
+用户一直在某台手机上用"手机号+短信验证码"登录 App。某天他在这台手机上点了 Apple / Google / LINE 登录(该三方身份从未绑定过)。按现状他必须重新输手机号、收短信、输验证码。本功能后:App 弹出"将绑定 098\*\*\*\*789 并登录"的一键确认框(**手机号只读、不可修改**),点"确认"即完成三方身份与该手机号账号的绑定并直接登录,全程无需短信。
+
+**Why this priority**: 这是本功能的核心价值——消除"同一台设备重复绑手机号"的摩擦;没有它,其余部分没有意义。
+
+**Independent Test**: 已用手机号登录过的设备上,用一个全新三方身份登录,验证只弹一次确认框、不发短信、确认后拿到 token 进 App。
+
+**Acceptance Scenarios**:
+
+1. **Given** 设备存在信任记录(本机验证过手机号),**When** 用户以未绑定的三方身份发起三方登录且请求携带该设备标识,**Then** 返回"设备确认"状态,含临时凭证 tempKey 与脱敏手机号(隐藏中间 4 位,如 098\*\*\*\*789)
+2. **Given** 用户在确认框点击"确认",**When** 提交确认请求(tempKey + 设备标识),**Then** 三方身份绑定到信任手机号对应的账号(账号不存在则按现有规则自动创建),返回登录 token,响应结构与现有绑定接口一致
+3. **Given** 用户在确认框点击"取消",**When** 返回登录页,**Then** 不产生任何绑定,用户仍可用手机号+短信登录,现有流程完全不受影响
+
+---
+
+### User Story 2 - 设备信任的建立与更新 (Priority: P2)
+
+凡是手机号验证通过的登录,都建立/刷新"这台设备 ↔ 这个手机号"的信任关系:手机号短信登录成功、三方登录走短信绑定成功、三方已绑定身份直接登录成功、一键确认登录成功——四种成功路径都要记录。同一台设备先后登录过不同手机号(如家人共用)时,以**最近一次**为准覆盖。
+
+**Why this priority**: 信任记录是 Story 1 的数据基础;没有记录就没有免绑。
+
+**Independent Test**: 分别走四种登录路径,验证设备信任表被写入;换手机号再登录,验证记录被覆盖为新手机号。
+
+**Acceptance Scenarios**:
+
+1. **Given** 任意用户在设备上完成手机号+短信登录,**When** 登录成功且请求携带设备标识,**Then** 设备信任关系被记录(设备标识→手机号→用户)
+2. **Given** 设备已有信任记录(手机号 A),**When** 手机号 B 在该设备再次登录成功,**Then** 记录更新为手机号 B
+3. **Given** 三方身份已绑定(现有绑定记录存在),**When** 该用户在设备上三方登录成功,**Then** 设备信任关系同样被记录(手机号取该绑定用户)
+
+---
+
+### User Story 3 - LINE 网页回调流程同样免绑 (Priority: P3)
+
+LINE 登录走"唤起系统浏览器 → LINE 回调后端 → 302 跳回 App"的链路,此链路后端原本拿不到设备信息。现约定:App 打开 LINE 授权 URL 时把设备标识放入 `state` 参数带回。回调端对未绑定身份同样按设备信任情况分支:有信任 → 回跳 App 时带"设备确认"参数(tempKey + 脱敏手机号),App 弹同一套确认框;无信任 → 维持现状回跳 needPhone。
+
+**Why this priority**: LINE 回调是三方登录的一条独立链路,不覆盖会导致 LINE 用户独享不到免绑;但它是增量,不阻塞主流程。
+
+**Independent Test**: 已信任设备上走 LINE 授权回调,验证 302 回跳参数为 deviceConfirm 分支;新设备走同一回调,验证回跳参数仍为 needPhone。
+
+**Acceptance Scenarios**:
+
+1. **Given** 设备有信任记录,**When** LINE 回调收到 code 且 state 携带该设备标识、LINE 身份未绑定,**Then** 302 回跳 App 携带 deviceConfirm 标记、tempKey 与脱敏手机号
+2. **Given** 设备无信任记录(或 state 未携带设备标识),**When** 同一回调发生,**Then** 回跳参数与现状完全一致(needPhone=1&tempKey)
+3. **Given** LINE 身份已绑定,**When** 回调发生,**Then** 直接回跳 token(现状不变),且同时记录设备信任
+
+---
+
+### Edge Cases
+
+- **老版本 App 不传 deviceId**:所有接口行为与现状逐字节一致(needPhone 流程),不报错、不强制。
+- **deviceId 传了但设备无信任记录**:走原 needPhone 手动绑定流程。
+- **一键确认时 tempKey 过期**(有效期与现状一致,5 分钟):确认接口返回明确过期错误,用户重新发起三方登录获取新 tempKey。
+- **tempKey 与 deviceId 不匹配**(换设备重放/伪造):确认接口拒绝,不产生绑定。
+- **信任手机号对应的账号已被停用或删除**:确认接口返回错误提示,不自动绑定;用户回落手动绑定流程。
+- **三方身份其实已有绑定记录**:直接签发 token(现状分支优先),不进入设备确认分支。
+- **家人共用设备**:信任手机号以最近一次登录为准;一键确认框展示脱敏手机号,用户看得到将绑定给谁、可取消。
+- **deviceId 异常值**(超长/空串):按未传处理,走原流程。
+
+## Requirements *(mandatory)*
+
+### Functional Requirements
+
+- **FR-001**: 手机号短信登录、三方登录、三方绑定手机号等登录接口 MUST 接受可选的设备唯一标识参数(字符串,≤64);参数缺省或非法时,接口行为与现状完全一致。
+- **FR-002**: 凡手机号验证通过的登录成功点(短信登录、三方短信绑定、三方已绑定直登、一键确认登录)MUST 记录或更新设备信任关系(设备标识→手机号→用户ID),同设备以最近一次为准。
+- **FR-003**: 三方登录遇到未绑定身份时,若请求携带设备标识且该设备存在信任记录,系统 MUST 返回"设备确认"状态:临时凭证 tempKey + 脱敏手机号(隐藏中间 4 位);否则维持现状返回 needPhone + tempKey。
+- **FR-004**: 系统 MUST 提供免短信设备确认接口:入参为 tempKey + 设备标识;校验通过后按现有绑定规则将三方身份绑定到信任手机号账号(账号不存在则自动创建),签发登录凭证;响应结构与现有"三方绑定手机号"接口一致。
+- **FR-005**: 确认弹窗展示的手机号 MUST 为只读脱敏展示,用户不可编辑;接口侧不提供"换号"能力,更换手机号走取消后手动绑定。
+- **FR-006**: LINE 服务端回调 MUST 支持从 state 参数解析设备标识,未绑定分支按设备信任情况返回 deviceConfirm(含 tempKey 与脱敏手机号)或现状 needPhone 回跳;已绑定分支签发 token 并同时记录设备信任。
+- **FR-007**: 设备确认临时凭证有效期 MUST 与现状一致(5 分钟),过期后确认接口返回明确错误码。
+- **FR-008**: 确认接口 MUST 校验临时凭证与设备标识的匹配关系,不匹配时拒绝且不产生绑定;信任手机号账号已停用/删除时同样拒绝并给出可读错误。
+- **FR-009**: 数据库变更 MUST 按《数据库变更管理》规范写入 `updatesql/sql.md`(标注日期与用途),不直接执行。
+- **FR-010**: 本功能 MUST 产出一份前端接口契约文档(放 specs/029 下),覆盖:新增/变更入参、oauthLogin 新返回分支、新确认接口、LINE 授权 URL state 约定与回跳分支,交由 uni-app 前端团队实施。
+- **FR-011**: 设备信任数据 MUST 仅存储必要字段(设备标识、手机号、用户ID、时间戳),不采集其它设备信息;商户端/骑手端登录不在本功能范围。
+
+### Key Entities *(include if feature involves data)*
+
+- **设备信任记录(新)**:设备唯一标识(一台设备一条,主键)→ 最近一次验证通过的手机号 → 对应用户ID → 创建/更新时间。生命周期:随每次符合 FR-002 的登录成功 upsert。
+- **三方绑定关系(现有 info_user_oauth,复用不改)**:三方身份(provider + providerUid)↔ 用户ID。一键确认即向此关系新增记录。
+- **临时凭证(现有 tempKey 机制,扩展)**:短期(5 分钟)凭据,值从"provider@providerUid"扩展为可携带设备标识,供绑定/确认接口消费。
+
+## Success Criteria *(mandatory)*
+
+### Measurable Outcomes
+
+- **SC-001**: 已信任设备上,用户从"点三方登录"到"进入 App"无需短信验证码,操作不超过 2 步(点三方登录 + 点确认)。
+- **SC-002**: 未携带设备标识、携带但无信任记录、以及旧版本 App 的所有请求,行为与改造前完全一致(回归零差异)。
+- **SC-003**: 设备确认接口的响应耗时与现有登录/绑定接口处于同一量级,不引入新的明显延迟。
+- **SC-004**: 前端契约文档交付且覆盖全部涉及接口、返回分支与 LINE 回跳参数,前端团队无需口头追问即可开工。
+- **SC-005**: 服务层自动化测试覆盖:设备信任记录的建立/覆盖、免绑分支判定、确认接口的成功/过期/不匹配/账号停用路径。
+
+## Assumptions
+
+- 设备唯一标识由 App 端生成并持久化(iOS 建议 Keychain、Android 建议 ANDROID_ID,均跨卸载重装稳定),其稳定性由前端保证;后端只接收并存储字符串(≤64),不关心生成方式。
+- 本功能只改后端;用户端 uni-app 由前端同事按契约文档另行实施(不在本 spec 的交付物内,契约文档在内)。
+- 范围不含商户端(shanglodeing)与骑手端(syslodeing)登录。
+- 沿用 017-oauth-login 的既有决策:手机号始终是账号主键,InfoUser 上不落三方 ID;万能验证码 8888 等现有测试便利不改动。
+- 设备确认弹窗的 UI 文案与样式由前端实现,后端只提供脱敏手机号数据。

+ 174 - 0
specs/029-device-trust-phone-bind/tasks.md

@@ -0,0 +1,174 @@
+# Tasks: 设备信任免绑手机号
+
+**Input**: Design documents from `/specs/029-device-trust-phone-bind/`(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**: 本 feature 的 spec(SC-005)明确要求服务层自动化测试,且采用 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 白名单(createRejectsUnsupportedPayType)为预期失败,与本 feature 无关,不修。
+
+---
+
+## Phase 1: Setup (Shared Infrastructure)
+
+**Purpose**: 确认基线干净,避免把存量问题带进本 feature
+
+- [x] T001 基线验证:按上方命令跑 `mvn -pl ruoyi-admin -am compile`,确认编译通过;工作区无本 feature 之外的意外改动(`git status`)
+
+---
+
+## Phase 2: Foundational (Blocking Prerequisites)
+
+**Purpose**: 设备信任的数据层与共享构件——所有用户故事都依赖
+
+**⚠️ CRITICAL**: US1/US2/US3 的实现任务不得在本阶段完成前开始
+
+- [x] T002 按 data-model.md 的 DDL 在 `updatesql/sql.md` 末尾追加 `info_user_device` 建表语句(带 `-- 2026-09-20 ...` 日期与用途注释;**不执行**,由开发者手动执行)
+- [x] T003 [P] 新建实体 `ruoyi-system/src/main/java/com/ruoyi/system/domain/InfoUserDevice.java`:`@TableName("info_user_device")`,字段 deviceId/phone/userId/createTime/updateTime,风格参照 `InfoUserOauth.java`(Lombok、注释规范、无 `*/` 序列)
+- [x] T004 [P] 新建 `ruoyi-system/src/main/java/com/ruoyi/system/mapper/InfoUserDeviceMapper.java`:`extends BaseMapper<InfoUserDevice>`,注释同 `InfoUserOauthMapper` 模式(仅 MP CRUD,无 XML)
+- [x] T005 [P] 新增 i18n 消息 key:先 `grep -rn "no.oauth.tempkey.expired" ruoyi-system/src/main/resources` 定位 messages 资源文件,把 `no.oauth.device.confirm`(deviceConfirm 分支提示语)与 `no.oauth.device.mismatch`(凭证/设备不匹配)加到**所有语言变体文件**,文案四语对齐
+- [x] T006 [P] DTO 变更:`ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/UserDTO.java`、`ruoyi-admin/src/main/java/com/ruoyi/app/user/dto/OAuthLoginDto.java`、`OAuthBindDto.java` 各加 `private String deviceId;`(注释:设备唯一标识,App 首启生成,可选 ≤64);新建 `ruoyi-admin/src/main/java/com/ruoyi/app/user/dto/OAuthDeviceConfirmDto.java`(字段 tempKey + deviceId,Lombok @Data,无校验注解)
+- [x] T007 TDD 先红:新建 `ruoyi-admin/src/test/java/com/ruoyi/app/user/service/DeviceTrustServiceTest.java`,Mockito 单测:① `isValidDeviceId`(null/空/64 位/65 位)② `recordLogin` 首次 insert(create_time=update_time=now)③ 再次同号登录仅刷新 update_time ④ 换号登录覆盖 phone/user_id ⑤ 非法 deviceId 静默跳程不调 mapper ⑥ `maskPhone`(正常 10 位、短号原样返回)。跑测试确认**编译通过但断言红**(service 未实现)
+- [x] T008 新建 `ruoyi-admin/src/main/java/com/ruoyi/app/user/service/DeviceTrustService.java` 实现 `isValidDeviceId/recordLogin/getTrustedPhone(deviceId)/maskPhone(phone)`(recordLogin=selectById 命中则 update phone/user_id/update_time,否则 insert;依赖注入 `InfoUserDeviceMapper`),使 T007 转绿
+
+**Checkpoint**: 数据层+服务就绪,`DeviceTrustServiceTest` 全绿;US1/US2/US3 可并行开始
+
+---
+
+## Phase 3: User Story 1 - 同设备三方登录免短信一键绑定 (Priority: P1) 🎯 MVP
+
+**Goal**: `oauthLogin` 未绑定时按设备信任返回 `deviceConfirm` 分支;新接口 `oauthDeviceConfirm` 免短信完成绑定登录
+
+**Independent Test**: 测试内直接向 `info_user_device` 造一条信任记录(不依赖 US2 的记录点),断言 oauthLogin 返回 deviceConfirm、oauthDeviceConfirm 返回 token
+
+### Tests for User Story 1(先写、先红)
+
+- [x] T009 TDD 先红:新建 `ruoyi-admin/src/test/java/com/ruoyi/app/user/DeviceTrustFlowTest.java`(参考 `FlashDeliveryControllerContractTest` 的 Mockito 实例化 Controller 方式),覆盖:① oauthLogin 未绑定+deviceId 有信任 → data 含 `status="deviceConfirm"`、tempKey、`maskedPhone="098****4321"`,且 Redis 写入 `oauth:bind:{tempKey}` 与 `oauth:bind:dev:{tempKey}` 两键 ② oauthLogin 未绑定+无信任/无 deviceId → 仍是 `needPhone`(回归) ③ oauthDeviceConfirm 成功:token 返回 + `info_user_oauth` insert + 两个 Redis key 删除 + 已有用户关联/新用户创建(含 createUserWallet) ④ tempKey 过期 → `no.oauth.tempkey.expired` ⑤ dev 键缺失或 deviceId 不等 → `no.oauth.device.mismatch` 且无绑定 ⑥ 信任手机号账号 status≠0 → `no.user.stop` ⑦ 期间已被绑定 → 容错直登返回 token
+
+### Implementation for User Story 1
+
+- [x] T010 改 `ruoyi-admin/src/main/java/com/ruoyi/app/user/InfoUserController.java` 的 `oauthLogin`:未绑定分支内,`dto.getDeviceId()` 合法且 `deviceTrustService.getTrustedPhone` 命中时,同时写 `oauth:bind:{tempKey}=provider@providerUid`(现状不变)与 `oauth:bind:dev:{tempKey}=deviceId`(同 5 分钟 TTL),返回 `{status:"deviceConfirm", tempKey, maskedPhone}`(提示语 `no.oauth.device.confirm`);否则维持现状 needPhone
+- [x] T011 在 `InfoUserController.java` 新增 `@Anonymous @PostMapping("/oauthDeviceConfirm")`,入参 `@RequestBody OAuthDeviceConfirmDto`:校验 tempKey/deviceId 非空(`no.oauth.tempkey.missing`)→ 读 `oauth:bind:{tempKey}`(空=`no.oauth.tempkey.expired`)→ 读 `oauth:bind:dev:{tempKey}` 并与入参 deviceId 相等、且设备信任记录仍存在(否则 `no.oauth.device.mismatch`)→ 复用 `oauthBindPhone` 的"期间已绑定容错直登/get-or-create 用户(停用拒绑 no.user.stop)/insert info_user_oauth"逻辑 → 删两个 Redis key → `deviceTrustService.recordLogin` → `issueOauthToken`;日志风格对齐现有 `[OAuth]` 前缀
+- [x] T012 转绿与回归:`DeviceTrustFlowTest` 全绿;`mvn -pl ruoyi-admin -am test -Dtest='DeviceTrust*'` 通过
+
+**Checkpoint**: US1 独立可演示(手工插信任记录 + curl 按 quickstart 场景 A 验证)
+
+---
+
+## Phase 4: User Story 2 - 设备信任的建立与更新 (Priority: P2)
+
+**Goal**: 四种登录成功点自动 upsert 设备信任;共用设备以最近一次为准
+
+**Independent Test**: mock 断言各登录成功路径调用了 `recordLogin`(deviceId 缺省时不调用)
+
+### Tests for User Story 2(先写、先红)
+
+- [x] T013 TDD 先红:在 `DeviceTrustFlowTest` 追加用例:① `lodeing` 短信登录成功且带 deviceId → `recordLogin(deviceId, phone, userId)` 被调用 ② 不带 deviceId → 不调用 ③ `oauthBindPhone` 绑定成功且带 deviceId → 被调用 ④ `oauthLogin` 已绑定直登且带 deviceId → 被调用(手机号取绑定用户)
+
+### Implementation for User Story 2
+
+- [x] T014 `InfoUserController.lodeing`(约 :941)登录成功 return 前加 `deviceTrustService.recordLogin(dto.getDeviceId(), phone, user.getUserId())`
+- [x] T015 `InfoUserController.oauthBindPhone`(约 :1096)`issueOauthToken` 前加同款调用(dto.getDeviceId())
+- [x] T016 `InfoUserController.oauthLogin`(约 :1063 已绑定分支)`issueOauthToken` 前加同款调用(手机号取 `u.getPhone()`)
+- [x] T017 转绿:`DeviceTrust*Test` 全绿;重复登录换号场景按 data-model.md 状态迁移人工核验
+
+**Checkpoint**: 真实链路下"手机号登录一次 → 三方免绑"端到端成立(quickstart 场景 A 完整闭环)
+
+---
+
+## Phase 5: User Story 3 - LINE 回调免绑 (Priority: P3)
+
+**Goal**: LINE 网页回调链路通过 `state=deviceId` 获得同等的 deviceConfirm 能力
+
+**Independent Test**: mock OAuthVerifyService/RedisCache 断言回调 302 的 Location 参数分支
+
+### Tests for User Story 3(先写、先红)
+
+- [x] T018 TDD 先红:新建 `ruoyi-admin/src/test/java/com/ruoyi/app/user/LineCallbackDeviceTrustTest.java`:① 未绑定+state 为有信任的 deviceId → Location 含 `deviceConfirm=1`、tempKey、URL 编码的 maskedPhone,且两个 Redis key 写入 ② 未绑定+state 为陌生值/空 → Location 仍为 `needPhone=1&tempKey`(回归) ③ 已绑定+state=deviceId → Location 含 token 且 `recordLogin` 被调用
+
+### Implementation for User Story 3
+
+- [x] T019 改 `ruoyi-admin/src/main/java/com/ruoyi/app/user/LineCallbackController.java`:`state` 语义变为 deviceId(注释说明前端契约,保留原参数名);未绑定分支查 `deviceTrustService.getTrustedPhone(state)`,命中 → 写两键 + 302 `appRedirect + "?deviceConfirm=1&tempKey=..&maskedPhone=.."`(enc 编码),未命中 → 现状 needPhone;已绑定分支发 token 前加 `recordLogin(state, u.getPhone(), u.getUserId())`(deviceId 合法才调)
+- [x] T020 转绿:`LineCallbackDeviceTrustTest` 全绿;`mvn -pl ruoyi-admin -am test -Dtest='DeviceTrust*,LineCallback*'` 通过
+
+**Checkpoint**: 三条链路(oauthLogin 直连 / LINE 回调 / 短信绑定)全部具备设备信任能力
+
+---
+
+## Phase 6: Polish & Cross-Cutting Concerns
+
+- [x] T021 契约一致性核对:逐条比对 `specs/029-device-trust-phone-bind/contracts/api-contract.md`(§1-§6)与实际实现——入参字段、返回分支、错误 key、LINE 回跳参数,发现偏差改实现或改契约并在契约中注明
+- [x] T022 全量验证:`mvn -pl ruoyi-admin -am test -Dtest='DeviceTrust*,LineCallback*'` 全绿 + `mvn -pl ruoyi-admin -am compile` 通过 + quickstart.md 场景 B(不带 deviceId 零差异回归)手工过一遍;确认未触碰 CLAUDE.md 废弃代码清单中的文件
+- [x] T023 收尾:`git status` 核对本 feature 改动清单与 plan.md Project Structure 一致;把需要人工执行的事项(`updatesql/sql.md` 新增 DDL、前端契约移交 uni-app 团队)在交付说明中列出
+
+---
+
+## Dependencies & Execution Order
+
+### Phase Dependencies
+
+- **Setup (Phase 1)**: 无依赖,立即开始
+- **Foundational (Phase 2)**: 依赖 Phase 1;**阻塞所有用户故事**
+- **US1 (Phase 3) / US2 (Phase 4) / US3 (Phase 5)**: 均依赖 Phase 2 完成;彼此可并行(不同方法/文件区域),单人则按 P1→P2→P3
+- **Polish (Phase 6)**: 依赖全部所需故事完成
+
+### User Story Dependencies
+
+- **US1**: 独立可测(测试直接造 info_user_device 数据,不依赖 US2)
+- **US2**: 独立可测(mock 断言 recordLogin 调用);真实端到端闭环需 US1+US2 都完成
+- **US3**: 独立可测;依赖 Phase 2 的 DeviceTrustService(所有故事共享)
+
+### Within Each User Story
+
+- 测试任务先写并确认红 → 实现转绿(TDD)
+- 同一 Controller 文件的任务(T010/T011、T014-T016)不做并行标记,避免编辑冲突
+
+### Parallel Opportunities
+
+- Phase 2 的 T003/T004/T005/T006 互不相关可并行(不同文件)
+- US1 的测试任务与 US2 的测试任务分属不同测试文件可并行
+- 多人时 US1/US3 可分人(InfoUserController vs LineCallbackController),US2 与 US1 同文件建议同人
+
+---
+
+## Implementation Strategy
+
+### MVP First (Phase 1-3 + 手工造数据)
+
+1. Phase 1 基线 → Phase 2 数据层 → Phase 3 US1
+2. 手工向 `info_user_device` 插一行即可端到端演示"同设备三方免绑"
+3. US2 完成后无需手工造数据,真实登录即建立信任
+
+### Incremental Delivery
+
+1. Setup + Foundational → 数据层就绪(可先交付 SQL 给 DBA 排期)
+2. +US1 → 免绑核心(MVP)
+3. +US2 → 信任自动建立(完整闭环)
+4. +US3 → LINE 链路补齐
+5. Polish → 契约核对 + 回归 + 交付说明
+
+---
+
+## Notes
+
+- 所有 Java/资源文件保留原有 CRLF 换行风格,禁止全文件重排
+- Controller 校验一律 `MessageUtils.message(...)`,禁止硬编码中文错误
+- `oauth:bind:` 现有值格式**不得改动**(`oauthBindPhone` 用 `indexOf('@')` 解析)
+- 不触碰 CLAUDE.md 废弃代码清单中的任何文件

+ 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 阶段落实。

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

@@ -0,0 +1,87 @@
+# 前端接口契约:统一订单列表(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/dinein/pickup/flash,默认 all
+          takeaway=仅外送单(type=0);dinein=堂食(type=2);pickup=自取(type=1);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`(店铺名称,按门店查 pos_store,非商家昵称)、`storeAvatar`(店铺头像 logo)、`goodsCount`(商品件数,food 快照 number 求和)、`collectPayment`
+
+### flash 渲染块(对齐现有闪送列表卡片)
+
+`status`(WAITING_ACCEPTANCE/ACCEPTED/PICKED_UP/DELIVERED/COMPLETED/CANCELLED)、`orderVersion`(闪送乐观锁版本,用户编辑待接单订单须回传)、`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. **堂食/自取筛选**:`source=dinein/pickup` 下卡片仍是 `sourceType=takeaway` 外卖样式,堂食/自取身份按卡片的 `type` 字段(2/1)渲染文案。
+7. 旧的 `/system/userOrder/orderList` 与 `/system/flashDelivery/orders` 不下线,其它页面继续用。
+
+## 6. 联调自测清单
+
+1. 同一账号混有外卖+闪送单 → 全部 tab 按时间倒序混排,两种卡片正确渲染。
+2. 连续滚动到底 → 与该账号实际订单总数一致,无重复无遗漏。
+3. 6 个 tab × 5 个来源筛选逐一核对归类(重点:待付款不含货到付款;售后单只在退款/售后 tab;takeaway/dinein/pickup 三筛选取舍:外送单不出现在堂食/自取筛选,反之亦然)。
+4. A 寄给 B 的闪送单:A、B 各自列表都出现,角色分别为寄件/收件;自己寄给自己只出现一条。
+5. 非法 cursor(手改字符串)→ 报错提示,重置回首页可用。
+6. 旧外卖列表页/闪送列表页(若有保留入口)行为不变。

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

@@ -0,0 +1,112 @@
+# 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`(按 mdId 批量查 pos_store.posName,店铺已删回退订单快照 posName)、`storeAvatar`(pos_store.logo,同回退)、`goodsCount`(food 快照数组 number 求和)、`collectPayment`(到付标记)——数值原值透传,文案由前端渲染。
+
+### 闪送渲染块(flash,对齐 FlashDeliveryUserOrderListView 字段子集)
+
+`status`(六态枚举原值)、`orderVersion`(乐观锁版本,编辑待接单订单须回传)、`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(`dinein` < `flash` < `pickup` < `takeaway`)
+  3. 同刻同源:`orderId DESC`
+- **来源编码**:混排查询(source=all)的行与游标只有 `takeaway`/`flash`;`dinein`/`pickup` 仅在来源单选查询的游标中出现(堂食/自取是 pos_order.type 子集,单选时同源翻页同秒 id 决胜照常生效)。
+- **推进条件**(每源 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 IN ('1','4') AND type IN (1,2)))`(2026-09-21:payType 扩入现金 4,随堂食/自取现金下单开放) |
+| 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 <来源type条件> 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
+```
+
+- 来源 type 条件(2026-09-21 堂食/自取筛选):`source=takeaway → type=0`(仅外送)、`dinein → type=2`、`pickup → type=1`;`source=all` 不加 type 条件(堂食/自取照常混排)。dinein/pickup 是外卖路子集,跳过闪送路。
+- 游标推进中的"同刻同源 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/dinein/pickup/flash,2026-09-21 扩堂食/自取:takeaway=type0 仅外送、dinein=type2、pickup=type1)+ 游标滚动分页(严格 keyset,同秒跨源决胜:source 字典序 + 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/dinein/pickup/flash 枚举(字典序决胜依据;dinein/pickup 为 pos_order type 子集)
+├── 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**
+
+无违规项。

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

@@ -0,0 +1,56 @@
+# 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'                        # 仅外送单(type=0,不含堂食/自取)
+curl '.../list?source=dinein'                          # 仅堂食(type=2,卡片 sourceType 仍为 takeaway)
+curl '.../list?source=pickup'                          # 仅自取(type=1)
+
+# 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)
+
+用户打开订单页,看到的不再需要区分"外卖订单""闪送订单"两个入口,而是一个按下单时间倒序混排的统一列表:外卖单和闪送单交替出现,各自用现有的卡片样式渲染(外卖卡显示店铺/商品/配送信息,闪送卡显示取收地址/物品/小费)。列表支持无限滚动加载;顶部支持来源筛选(全部/外卖/堂食/自取/闪送,2026-09-21 扩堂食自取:外卖=仅外送单)。点击任意一单跳转到该单现有的详情页。
+
+**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/dinein/pickup/flash 五值;2026-09-21 扩堂食/自取:takeaway=仅外送单 type=0、dinein=堂食 type=2、pickup=自取 type=1、all 不限 type 混排),与 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);将来若演进为订单搜索服务,仅替换读实现,接口契约不变。

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

@@ -0,0 +1,163 @@
+# 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 追加)
+
+---
+
+## Phase 7: 追加需求(2026-09-21)——外卖卡片店铺信息修正
+
+- [x] T021 外卖 `storeName` 修正为店铺名称:`UnifiedOrderQueryService` 批量回填由「shId 查 InfoUser 昵称」改为「mdId 查 PosStore.posName」,店铺已删/缺失时回退订单快照 posName/logo;新增 `storeAvatar`(PosStore.logo,同回退)、`goodsCount`(food 快照数组 number 求和,空/非法为 null);DTO/测试/api-contract/data-model 同步
+- [x] T022 ACTIVE 谓词线下收款分支扩入现金:`UnifiedOrderTab` `payType='1'` → `IN ('1','4')`(SQL 与 matches 同源,常量改 `OFFLINE_PAYABLE_PAY_TYPES`),配合堂食/自取开放用户现金下单(027 spec 2026-09-21 变更记录,commit 31bb384);测试补 payType=4 判定与 wrapper 断言
+
+---
+
+## Phase 8: 追加需求(2026-09-21)——来源筛选扩堂食/自取
+
+- [x] T023 `source` 扩枚举 `dinein`(堂食 type=2)/`pickup`(自取 type=1),`takeaway` 收窄为仅外送单(type=0),`all` 不限 type 照常混排:`OrderSource` 增枚举值+`posOrderType`;`UnifiedOrderQueryService` 外卖路按查询源加 type 过滤、游标推进源随查询源(dinein/pickup 单选翻页同秒 id 决胜)、单选跳过闪送路;游标编码单选时为 dinein/pickup,混排仍只产生 takeaway/flash;测试补 type 谓词/路由/游标推进;api-contract/data-model/quickstart 同步
+- [x] T024 闪送条目返回 `orderVersion`(乐观锁版本,取 `flash_delivery_order.version`,语义同 FlashDeliveryUserOrderListView):DTO flash 块加字段、`flashItem` 赋值;测试补 orderVersion 断言;api-contract/data-model 同步
+
+---
+
+## 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 条件的括号层级

+ 33 - 0
updatesql/sql.md

@@ -1719,3 +1719,36 @@ WHERE NOT EXISTS (
 -- 2026-09-18 闪送订单骑手评分:寄件人对已完成订单评价骑手(1-5,仅一次),NULL=未评价
 ALTER TABLE flash_delivery_order ADD COLUMN rider_stars DOUBLE DEFAULT NULL COMMENT '寄件人对骑手评分1-5,NULL=未评价';
 ```
+
+## 2026-09-20 设备绑定手机号信任表(同设备三方登录免绑)
+
+```sql
+-- 2026-09-20 设备绑定手机号信任表(029-device-trust-phone-bind):同一设备验证过手机号后,后续三方登录免短信一键确认绑定
+CREATE TABLE info_user_device (
+  device_id   VARCHAR(64) NOT NULL COMMENT '设备唯一标识(App首启生成持久化)',
+  phone       VARCHAR(20) NOT NULL COMMENT '本设备最近一次验证通过的手机号',
+  user_id     BIGINT NOT NULL COMMENT '对应用户ID(info_user.user_id)',
+  create_time DATETIME DEFAULT NULL COMMENT '首次建立信任时间',
+  update_time DATETIME DEFAULT NULL COMMENT '最近一次登录刷新时间',
+  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);
+```
+
+## 2026-09-21 骑手定位一人一条(rider_position 唯一索引)
+
+```sql
+-- 2026-09-21 骑手定位唯一索引:每个骑手只允许一条定位记录
+-- 前置:先清理历史重复数据(每个 rider_id 只保留一条,建议保留最新一条),否则建索引会失败
+-- 清理参考:DELETE p1 FROM rider_position p1 INNER JOIN rider_position p2 ON p1.rider_id = p2.rider_id AND p1.id < p2.id;
+ALTER TABLE rider_position ADD UNIQUE INDEX uk_rider_id (rider_id);
+```