qmj 1 день назад
Родитель
Сommit
27615dd632
89 измененных файлов с 6642 добавлено и 254 удалено
  1. 1 1
      .specify/feature.json
  2. 3 3
      CLAUDE.md
  3. 54 0
      docs/issue-pay-method-switch.md
  4. 50 0
      docs/issue-single-device-login.md
  5. 33 5
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryApplicationService.java
  6. 7 7
      ruoyi-admin/src/main/java/com/ruoyi/app/mendian/FoodSpecController.java
  7. 2 2
      ruoyi-admin/src/main/java/com/ruoyi/app/mendian/PosFenleiController.java
  8. 5 5
      ruoyi-admin/src/main/java/com/ruoyi/app/mendian/PosFoodController.java
  9. 6 6
      ruoyi-admin/src/main/java/com/ruoyi/app/mendian/PosStoreController.java
  10. 58 0
      ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentNotifyService.java
  11. 1 1
      ruoyi-admin/src/main/java/com/ruoyi/app/order/OrderLifecycleService.java
  12. 3 3
      ruoyi-admin/src/main/java/com/ruoyi/app/order/PosOrderController.java
  13. 13 13
      ruoyi-admin/src/main/java/com/ruoyi/app/order/PosOrderShOprateController.java
  14. 5 4
      ruoyi-admin/src/main/java/com/ruoyi/app/order/UserOrderController.java
  15. 64 0
      ruoyi-admin/src/main/java/com/ruoyi/app/pay/PayMethodSwitchController.java
  16. 169 0
      ruoyi-admin/src/main/java/com/ruoyi/app/pay/PayMethodSwitchService.java
  17. 30 0
      ruoyi-admin/src/main/java/com/ruoyi/app/pay/dto/PayMethodSwitchRequest.java
  18. 56 0
      ruoyi-admin/src/main/java/com/ruoyi/app/unifiedorder/OrderSource.java
  19. 132 0
      ruoyi-admin/src/main/java/com/ruoyi/app/unifiedorder/UnifiedCursor.java
  20. 73 0
      ruoyi-admin/src/main/java/com/ruoyi/app/unifiedorder/UnifiedOrderQueryController.java
  21. 261 0
      ruoyi-admin/src/main/java/com/ruoyi/app/unifiedorder/UnifiedOrderQueryService.java
  22. 194 0
      ruoyi-admin/src/main/java/com/ruoyi/app/unifiedorder/UnifiedOrderTab.java
  23. 89 0
      ruoyi-admin/src/main/java/com/ruoyi/app/unifiedorder/dto/UnifiedOrderListItem.java
  24. 17 0
      ruoyi-admin/src/main/java/com/ruoyi/app/unifiedorder/dto/UnifiedOrderPageView.java
  25. 136 10
      ruoyi-admin/src/main/java/com/ruoyi/app/user/InfoUserController.java
  26. 17 0
      ruoyi-admin/src/main/java/com/ruoyi/app/user/LineCallbackController.java
  27. 5 5
      ruoyi-admin/src/main/java/com/ruoyi/app/user/MerchantSubaccountController.java
  28. 29 2
      ruoyi-admin/src/main/java/com/ruoyi/app/user/RiderPositionController.java
  29. 3 0
      ruoyi-admin/src/main/java/com/ruoyi/app/user/dto/OAuthBindDto.java
  30. 21 0
      ruoyi-admin/src/main/java/com/ruoyi/app/user/dto/OAuthDeviceConfirmDto.java
  31. 3 0
      ruoyi-admin/src/main/java/com/ruoyi/app/user/dto/OAuthLoginDto.java
  32. 77 0
      ruoyi-admin/src/main/java/com/ruoyi/app/user/service/DeviceTrustService.java
  33. 27 0
      ruoyi-admin/src/main/java/com/ruoyi/app/utils/event/PushEventListener.java
  34. 49 0
      ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/PushMessageController.java
  35. 14 3
      ruoyi-admin/src/main/resources/i18n/messages.properties
  36. 12 1
      ruoyi-admin/src/main/resources/i18n/messages_en_US.properties
  37. 12 1
      ruoyi-admin/src/main/resources/i18n/messages_th_TH.properties
  38. 12 1
      ruoyi-admin/src/main/resources/i18n/messages_vi.properties
  39. 20 9
      ruoyi-admin/src/main/resources/i18n/messages_zh_CN.properties
  40. 18 7
      ruoyi-admin/src/main/resources/i18n/messages_zh_TW.properties
  41. 68 0
      ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryApplicationServiceTest.java
  42. 70 0
      ruoyi-admin/src/test/java/com/ruoyi/app/omgpay/OmgPaymentNotifyServiceTest.java
  43. 4 2
      ruoyi-admin/src/test/java/com/ruoyi/app/order/OrderListPaymentFilterTest.java
  44. 237 0
      ruoyi-admin/src/test/java/com/ruoyi/app/pay/PayMethodSwitchServiceTest.java
  45. 142 0
      ruoyi-admin/src/test/java/com/ruoyi/app/unifiedorder/UnifiedCursorTest.java
  46. 153 0
      ruoyi-admin/src/test/java/com/ruoyi/app/unifiedorder/UnifiedOrderQueryControllerTest.java
  47. 451 0
      ruoyi-admin/src/test/java/com/ruoyi/app/unifiedorder/UnifiedOrderQueryServiceTest.java
  48. 284 0
      ruoyi-admin/src/test/java/com/ruoyi/app/unifiedorder/UnifiedOrderTabTest.java
  49. 393 0
      ruoyi-admin/src/test/java/com/ruoyi/app/user/DeviceTrustFlowTest.java
  50. 161 0
      ruoyi-admin/src/test/java/com/ruoyi/app/user/LineCallbackDeviceTrustTest.java
  51. 143 16
      ruoyi-admin/src/test/java/com/ruoyi/app/user/MerchantSessionAuthAnnotationTest.java
  52. 181 0
      ruoyi-admin/src/test/java/com/ruoyi/app/user/RiderPositionControllerTest.java
  53. 137 0
      ruoyi-admin/src/test/java/com/ruoyi/app/user/service/DeviceTrustServiceTest.java
  54. 70 0
      ruoyi-admin/src/test/java/com/ruoyi/app/utils/event/PushEventListenerTest.java
  55. 160 0
      ruoyi-admin/src/test/java/com/ruoyi/web/controller/system/PushMessageControllerTest.java
  56. 43 0
      ruoyi-system/src/main/java/com/ruoyi/system/domain/InfoUserDevice.java
  57. 28 0
      ruoyi-system/src/main/java/com/ruoyi/system/domain/PushMessage.java
  58. 1 1
      ruoyi-system/src/main/java/com/ruoyi/system/domain/flash/FlashDeliveryOrder.java
  59. 2 0
      ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/UserDTO.java
  60. 13 0
      ruoyi-system/src/main/java/com/ruoyi/system/mapper/InfoUserDeviceMapper.java
  61. 7 0
      ruoyi-system/src/main/java/com/ruoyi/system/service/IPushMessageService.java
  62. 188 0
      ruoyi-system/src/main/java/com/ruoyi/system/service/impl/PushMessageServiceImpl.java
  63. 0 7
      ruoyi-system/src/main/java/com/ruoyi/system/utils/Auth.java
  64. 13 29
      ruoyi-system/src/main/java/com/ruoyi/system/utils/AuthAspect.java
  65. 14 1
      ruoyi-system/src/main/resources/mapper/system/PushMessageMapper.xml
  66. 218 0
      ruoyi-system/src/test/java/com/ruoyi/system/service/impl/PushMessageServiceImplTest.java
  67. 46 109
      ruoyi-system/src/test/java/com/ruoyi/system/utils/AuthAspectTest.java
  68. 8 0
      specs/024-flash-delivery/spec.md
  69. 2 0
      specs/027-offline-transfer-payment/data-model.md
  70. 22 0
      specs/027-offline-transfer-payment/spec.md
  71. 2 0
      specs/027-offline-transfer-payment/tasks.md
  72. 35 0
      specs/029-device-trust-phone-bind/checklists/requirements.md
  73. 90 0
      specs/029-device-trust-phone-bind/contracts/api-contract.md
  74. 68 0
      specs/029-device-trust-phone-bind/data-model.md
  75. 103 0
      specs/029-device-trust-phone-bind/frontend-guide.md
  76. 98 0
      specs/029-device-trust-phone-bind/plan.md
  77. 82 0
      specs/029-device-trust-phone-bind/quickstart.md
  78. 59 0
      specs/029-device-trust-phone-bind/research.md
  79. 110 0
      specs/029-device-trust-phone-bind/spec.md
  80. 174 0
      specs/029-device-trust-phone-bind/tasks.md
  81. 35 0
      specs/030-unified-order-list/checklists/requirements.md
  82. 87 0
      specs/030-unified-order-list/contracts/api-contract.md
  83. 112 0
      specs/030-unified-order-list/data-model.md
  84. 92 0
      specs/030-unified-order-list/plan.md
  85. 56 0
      specs/030-unified-order-list/quickstart.md
  86. 61 0
      specs/030-unified-order-list/research.md
  87. 116 0
      specs/030-unified-order-list/spec.md
  88. 163 0
      specs/030-unified-order-list/tasks.md
  89. 59 0
      updatesql/sql.md

+ 1 - 1
.specify/feature.json

@@ -1,3 +1,3 @@
 {
-  "feature_directory": "specs/028-flash-delivery-push"
+  "feature_directory": "specs/030-unified-order-list"
 }

+ 3 - 3
CLAUDE.md

@@ -1,6 +1,6 @@
-# foodie_server Development Guidelines
+# foodie_server Development Guidelines
 
-Auto-generated from all feature plans. Last updated: 2026-08-18
+Auto-generated from all feature plans. Last updated: 2026-09-20
 
 ## Tech Stack
 
@@ -204,5 +204,5 @@ Strong success criteria let you loop independently. Weak criteria ("make it work
 
 <!-- SPECKIT START -->
 For additional context about technologies to be used, project structure,
-shell commands, and other important information, read the current plan: `specs/028-flash-delivery-push/plan.md`
+shell commands, and other important information, read the current plan: `specs/030-unified-order-list/plan.md`
 <!-- SPECKIT END -->

+ 54 - 0
docs/issue-pay-method-switch.md

@@ -0,0 +1,54 @@
+# #661 待支付订单无法更换支付方式
+
+> 来源:Trello/Bug 单 #661(台湾外卖 ChaChaEat,代码错误,优先级 3,2026-09-18 创建)
+> 记录时间:2026-09-22
+> 状态:后端已实现(`POST /pay/switch`),待用户端 App 接入
+
+## 需求
+
+用户选了一个支付方式,支付不成功,返回订单是待支付状态,点击付款按钮时无法更换支付方式。期望:用户可更换支付方式。
+
+## 根因
+
+订单 `payType` 在下单时写死(`UserOrderController` 创建时 `setPayType`),之后没有任何接口能修改;而两个支付渠道的创建校验各自按 `payType` 把关:
+
+- OMG(信用卡 2 / Apple Pay 5):`OmgPaymentCreateService` 校验 `OrderLifecycleService.isCardWalletPayType(order.getPayType())`
+- LINE Pay(3):`LinePayOrderGuard` / `LinePayService` 校验 `payType == 3`,且 `/pay/line/create` 只收 `ddId` 无方式参数
+
+因此支付失败后只能原路重试。唯一已支持的切换是卡钱包组内 Credit↔ApplePay(`/pay/omg/retry` 已支持传新方式,但不回写 `payType`)。
+
+## 方案
+
+新增统一入口 `POST /pay/switch`(`PayMethodSwitchController` → `PayMethodSwitchService`),入参 `orderId + paymentMethod`:
+
+1. 校验:订单属于当前用户、主订单(非多门店子单)、`state 0-2`、`payStatus=0`、金额有效
+2. 方式取值限 `1/2/3/4/5`(6=线下转账不开放,商家侧流程);到付(1)/现金(4) 仅自取(type=1)/堂食(type=2)订单可切(与下单现金闸门同规则)
+3. **先回写 `order.payType`**(同方式跳过回写),写订单日志「用户更换支付方式:X → Y」
+4. 路由拉起支付:2/5 → `OmgPaymentCreateService.create`;3 → `LinePayService.create`;1/4 → 无支付环节直接成功
+5. 响应:`data.payParams` 携带渠道支付参数(线下方式为 null,App 刷新订单即可)
+
+**顺序约束**:必须先回写再拉起——两渠道创建校验都以 `order.payType` 为闸门。
+
+**已知边界(有意为之,非遗漏)**:旧渠道未完结的支付尝试不做主动跨渠道关闭,依赖其回调处的 `payStatus` 守卫(双通道回调只有一个能落账)与既有补偿任务(OMG `OmgPaymentAutoCompensationTask`、LINE Pay `LinePayCancellationCompensationService`)收口;极端情形(旧渠道回调晚到且已付款)走对账/退款流程。后续如需主动关闭,可在回写前接入两渠道的取消/作废能力。
+
+## 改动文件
+
+| 文件 | 改动 |
+|------|------|
+| `com.ruoyi.app.pay.PayMethodSwitchController` | 新增 `/pay/switch` 入口 |
+| `com.ruoyi.app.pay.PayMethodSwitchService` | 新增切换/校验/路由逻辑 |
+| `com.ruoyi.app.pay.dto.PayMethodSwitchRequest` | 新增请求 DTO |
+| i18n ×6(messages / vi / en_US / th_TH / zh_CN / zh_TW) | 新增 `no.pay.switch.*` 5 个 key |
+| `PayMethodSwitchServiceTest` | 新增 8 个用例 |
+
+原渠道接口(`/pay/omg/create`、`/pay/omg/retry`、`/pay/line/create`)与下单流程零改动。
+
+## 验证
+
+- `PayMethodSwitchServiceTest`:线下切换回写+日志+跳过支付、外送单拒线下、跨渠道先回写后拉起(InOrder)、同方式不重复回写、已支付/非法方式/非本人单/多门店子单拒绝
+- 全量回归:除既有已知红测试 `createRejectsUnsupportedPayType`(payType 白名单停用基线,另行处理)外全部通过
+
+## 待办
+
+- [ ] 用户端 App:付款按钮增加「更换支付方式」选择,调 `POST /pay/switch`
+- [ ] 上线后观察双渠道回调日志,确认无重复落账

+ 50 - 0
docs/issue-single-device-login.md

@@ -0,0 +1,50 @@
+# 用户/骑手/商家 App 单设备登录
+
+> 来源:需求讨论(参照 cte_server 已有实现),2026-09-22
+> 状态:后端已实现
+> 记录人:开发
+
+## 需求
+
+用户、骑手、商家三个 App,一个账号只能在一台设备登录;新设备登录后旧设备被踢下线。
+
+## cte_server 参考实现(两个半环)
+
+1. **登录删旧会话**:三端登录时 `deleteKeys(tokenKey + userId + ":*")`;商家按 User-Agent 分 APP/PC 两个 key,只踢 APP(PC 独立并存,即商家 = 1 App + 1 PC)。
+2. **鉴权强制验会话**(`AuthAspect`):JWT 验签后,jti 含冒号(新格式会话 token)→ Redis 必须存在该键,否则 401;无冒号的历史 token 跳过(兼容)。
+
+## foodie-server 改造前现状
+
+- 登录删旧会话:**已齐全**(与 cte 同构代码):`shanglodeing`(APP 踢 APP/PC 独立)、`syslodeing` 踢 QS、`lodeing` 踢 USER、oauthLogin/LineCallback/注册路径均踢。
+- 鉴权侧:`AuthAspect` 被改造为 `@Auth(session=true)` 才校验,且仅对商家账号(userType 1/3/4/5)拒绝;**用户(0)/骑手(2) 显式豁免**——登录虽删了旧会话,但旧设备 token(15 天内)在所有接口照常放行,单设备实际不生效(半成品)。
+
+## 本次改动
+
+| 文件 | 改动 |
+|------|------|
+| `Auth.java` | 删除 `session()` 属性(全局校验后无存在意义;8 个商家控制器 40 处 `@Auth(session = true)` 同步改为 `@Auth`) |
+| `AuthAspect.java` | 改为 cte 同款全局规则:jti 含冒号 → `hasKey(jti)` 必须为真否则返回 401(与 token 过期同响应,App 走既有登出路径);无冒号 → 放行。删除 `isConsumerUser` 豁免及每次请求的用户表查询 |
+| `MerchantSessionAuthAnnotationTest` | 重写:商家端点 @Auth 声明断言(不含 session)+ 切面行为用例(会话被删 → 401、会话存活 → 放行并写入可信身份、无冒号旧 token → 放行且不查 Redis) |
+
+商家行为对账:改前 session=true 路径就是 hasKey 校验,改后语义一致;变化仅在用户/骑手从"豁免"变"校验",即本次需求本身。
+
+## 风险评估(已逐项核对)
+
+1. **存量 token 兼容**:全部 7 处 token 签发都走写会话的 `JwtUtil.setToken(tokenKey, dto)`,15 天内的 token 会话都在 Redis,开启校验不掉线;无冒号历史 token 走兼容分支放行。
+2. **App 端无需升级**:被踢返回与 token 过期相同的 401 响应,App 走既有处理。
+3. **商家被踢体验变化**:改前商家会话失效抛 ServiceException(500 语义),改后统一 401——更一致,商家 App 按 401 重新登录。
+4. **共享账号互顶**:单设备需求本身预期内。
+5. **推送 cid**:登录覆盖 cid,单设备后与设备一一对应,推送不再串设备(改善)。
+6. **029 设备信任**:DeviceTrustService 信任记录与会话删除独立,无冲突。
+7. **管理后台**:ruoyi-framework 独立 TokenService,不受影响。
+8. **Redis 故障**:会话丢失 → 全端重登(cte 线上同款风险,可接受)。
+9. **骑手在线标识**:被踢旧设备的 offline 标志保持到下次登录,推送 cid 已切到新设备,接单不受影响。
+
+## 验证
+
+- `MerchantSessionAuthAnnotationTest`:注解声明 + 切面行为(401/放行/可信身份写入)全绿
+- 全量回归:除既有已知红测试 `createRejectsUnsupportedPayType` 外全部通过
+
+## 待办
+
+- [ ] 观察上线后 401 量与登录量变化,确认无异常掉线投诉

+ 33 - 5
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryApplicationService.java

@@ -32,6 +32,7 @@ import java.time.Instant;
 import java.time.ZoneId;
 import java.time.format.DateTimeFormatter;
 import java.util.*;
+import java.util.concurrent.atomic.AtomicLong;
 import java.util.function.Function;
 
 import static com.ruoyi.system.domain.flash.FlashDeliveryStatus.*;
@@ -63,6 +64,8 @@ public class FlashDeliveryApplicationService {
     private static final long SCHEDULE_SLOT_MILLIS = 30L * 60L * 1000L;
     private static final long MAX_SCHEDULE_DELAY_MILLIS = 3L * 24L * 60L * 60L * 1000L;
     private static final SecureRandom PIN_RANDOM = new SecureRandom();
+    /** 订单号同毫秒递增序列(01-99 循环):同一毫秒内的并发由原子递增区分,避免同号。 */
+    private static final AtomicLong ORDER_NO_SEQ = new AtomicLong();
     private final FlashDeliveryOrderMapper orderMapper;
     private final FlashDeliveryPricingMapper pricingMapper;
     private final FlashDeliveryOrderImageMapper imageMapper;
@@ -172,7 +175,7 @@ public class FlashDeliveryApplicationService {
         String payType = normalizePayType(request.getPayType());
         List<String> senderImages = validateProofUrls(request.getSenderImageUrls(), false);
         FlashDeliveryOrder order = new FlashDeliveryOrder();
-        order.setOrderNo("FD" + UUID.randomUUID().toString().replace("-", "").substring(0, 24).toUpperCase(Locale.ROOT));
+        order.setOrderNo(generateOrderNo());
         order.setClientRequestId(requestId);
         order.setUserId(userId);
         order.setServiceType(request.getServiceType());
@@ -220,10 +223,17 @@ public class FlashDeliveryApplicationService {
         try {
             orderMapper.insert(order);
         } catch (DataIntegrityViolationException duplicate) {
-            // 并发重复请求可能同时通过前置查询,最终由数据库唯一键完成幂等收口。
-            FlashDeliveryOrder concurrent = orderMapper.selectByUserRequestId(userId, requestId);
-            if (concurrent != null) return participantDetail(concurrent, true, false);
-            throw duplicate;
+            // order_no 撞号兜底(时钟回拨/多实例等理论情形):唯一索引 uk_flash_order_no 拒绝后换号重插一次。
+            if (isOrderNoConflict(duplicate)) {
+                order.setId(null);
+                order.setOrderNo(generateOrderNo());
+                orderMapper.insert(order);
+            } else {
+                // 并发重复请求可能同时通过前置查询,最终由 userId+clientRequestId 唯一键完成幂等收口。
+                FlashDeliveryOrder concurrent = orderMapper.selectByUserRequestId(userId, requestId);
+                if (concurrent != null) return participantDetail(concurrent, true, false);
+                throw duplicate;
+            }
         }
         saveImages(order.getId(), "SENDER", "USER", userId, senderImages, now);
         writeLog(order.getId(), null, WAITING_ACCEPTANCE, "USER", userId, null, now);
@@ -233,6 +243,24 @@ public class FlashDeliveryApplicationService {
         return participantDetail(order, true, false);
     }
 
+    /**
+     * 闪送订单号:98 + 13 位毫秒时间戳 + 2 位同毫秒递增序列,共 17 位纯数字(2026-09-22 起)。
+     * 与外卖订单(99 + 毫秒时间戳)风格同构,便于电话报单与按时间排查;
+     * 同毫秒并发由 JVM 内原子递增序列区分,时钟回拨/多实例等理论撞号由
+     * order_no 唯一索引(uk_flash_order_no)兜底,插入冲突换号重试。
+     * 2026-09-22 之前的存量单为「FD + UUID 前 24 位大写」旧格式,保持不变。
+     */
+    static String generateOrderNo() {
+        long seq = ORDER_NO_SEQ.updateAndGet(current -> (current + 1) % 100);
+        return "98" + System.currentTimeMillis() + String.format(Locale.ROOT, "%02d", seq);
+    }
+
+    /** 判断唯一键冲突是否来自订单号(区别于 userId+clientRequestId 幂等键)。 */
+    private static boolean isOrderNoConflict(DataIntegrityViolationException exception) {
+        String message = exception.getMostSpecificCause().getMessage();
+        return message != null && message.contains("uk_flash_order_no");
+    }
+
     /** 分页查询当前用户参与的订单卡片;role=sender 按寄件人、receiver 按收件人过滤。 */
     public IPage<FlashDeliveryUserOrderListView> userOrders(Long userId, int pageNum, int pageSize, String role) {
         String selectedRole = hasText(role) ? role.trim().toLowerCase(Locale.ROOT) : "sender";

+ 7 - 7
ruoyi-admin/src/main/java/com/ruoyi/app/mendian/FoodSpecController.java

@@ -45,7 +45,7 @@ public class FoodSpecController extends BaseController
      * 规格组分页列表(每条带 foodSpecsItems)
      */
     @Anonymous
-    @Auth(session = true)
+    @Auth
     @GetMapping("/foodSpecPageList")
     public AjaxResult foodSpecPageList(@RequestHeader String token,
                                        @RequestParam(defaultValue = "1") Integer pageNum,
@@ -73,7 +73,7 @@ public class FoodSpecController extends BaseController
      * 保存规格组(含级联规格值):id<=0 新增,否则更新
      */
     @Anonymous
-    @Auth(session = true)
+    @Auth
     @PostMapping("/saveFoodSpec")
     @Transactional(rollbackFor = Exception.class)
     public AjaxResult saveFoodSpec(@RequestHeader String token, @RequestBody List<FoodSpecs> list) {
@@ -160,7 +160,7 @@ public class FoodSpecController extends BaseController
      * 规格详情(带全部规格值)
      */
     @Anonymous
-    @Auth(session = true)
+    @Auth
     @GetMapping("/getSpecs")
     public AjaxResult getSpecs(@RequestHeader String token, @RequestParam Long id) {
         requireSpecsAccess(AuthContext.requireUserId(), id);
@@ -177,7 +177,7 @@ public class FoodSpecController extends BaseController
      * 软删除规格组(is_delete=1)
      */
     @Anonymous
-    @Auth(session = true)
+    @Auth
     @GetMapping("/deleteFoodSpec")
     public AjaxResult deleteFoodSpec(@RequestHeader String token, @RequestParam Long id) {
         requireSpecsAccess(AuthContext.requireUserId(), id);
@@ -191,7 +191,7 @@ public class FoodSpecController extends BaseController
      * 商品编辑页:本门店可用规格(is_open=1 & is_delete=0,仅含 is_open=1 的规格值)
      */
     @Anonymous
-    @Auth(session = true)
+    @Auth
     @GetMapping("/getAvailableSpecsList")
     public AjaxResult getAvailableSpecsList(@RequestHeader String token, @RequestParam Long mdId,
                                             @RequestParam(required = false) String language) {
@@ -214,7 +214,7 @@ public class FoodSpecController extends BaseController
      * 启停规格组
      */
     @Anonymous
-    @Auth(session = true)
+    @Auth
     @GetMapping("/changeOpen")
     public AjaxResult changeOpen(@RequestHeader String token,
                                  @RequestParam Long id, @RequestParam Boolean isOpen) {
@@ -229,7 +229,7 @@ public class FoodSpecController extends BaseController
      * 启停规格值
      */
     @Anonymous
-    @Auth(session = true)
+    @Auth
     @GetMapping("/changeSpecValueOpen")
     public AjaxResult changeSpecValueOpen(@RequestHeader String token,
                                           @RequestParam Long id, @RequestParam Boolean isOpen) {

+ 2 - 2
ruoyi-admin/src/main/java/com/ruoyi/app/mendian/PosFenleiController.java

@@ -47,7 +47,7 @@ public class PosFenleiController extends BaseController
      * 删除商品分类
      */
     @Anonymous
-    @Auth(session = true)
+    @Auth
     @GetMapping("/delefenlei")
     public AjaxResult delefenlei(@RequestHeader String token, @RequestParam String id){
         PosFenlei existing = posFenleiService.getById(Long.valueOf(id));
@@ -69,7 +69,7 @@ public class PosFenleiController extends BaseController
      * 添加或修改商品分类
      */
     @Anonymous
-    @Auth(session = true)
+    @Auth
     @PostMapping("/addfenlei")
     public AjaxResult addfenlei(@RequestHeader String token, @RequestBody PosFenlei posFenlei){
         Long userId = AuthContext.requireUserId();

+ 5 - 5
ruoyi-admin/src/main/java/com/ruoyi/app/mendian/PosFoodController.java

@@ -79,7 +79,7 @@ public class PosFoodController extends BaseController {
     private MerchantStoreAccessService merchantStoreAccessService;
     //删除商品
     @Anonymous
-    @Auth(session = true)
+    @Auth
     @GetMapping("/delefood")
     public AjaxResult delefood(@RequestHeader String token, @RequestParam String id) {
         merchantStoreAccessService.requireFoodAccess(
@@ -89,7 +89,7 @@ public class PosFoodController extends BaseController {
 
     //推荐商品
     @Anonymous
-    @Auth(session = true)
+    @Auth
     @PutMapping("/changerecommend")
     public AjaxResult changerecommend(@RequestHeader String token, @RequestBody PosFood posFood) {
         PosFood existing = merchantStoreAccessService.requireFoodAccess(
@@ -102,7 +102,7 @@ public class PosFoodController extends BaseController {
      * 添加或修改商品
      */
     @Anonymous
-    @Auth(session = true)
+    @Auth
     @PostMapping("/setposfood")
     public AjaxResult setposfood(@RequestHeader String token, @RequestBody PosFood posFood) {
         Long userId = AuthContext.requireUserId();
@@ -170,7 +170,7 @@ public class PosFoodController extends BaseController {
      * 通过分类查询商品列表
      */
     @Anonymous
-    @Auth(session = true)
+    @Auth
     @GetMapping("/getidlist")
     public AjaxResult getidlist(@RequestHeader String token, @RequestParam Integer id, @RequestParam(defaultValue = "") String language, @RequestParam(required = false) String stackingUp, @RequestParam(required = false) String name) {
         Long userId = AuthContext.requireUserId();
@@ -443,7 +443,7 @@ public class PosFoodController extends BaseController {
 
 
     @Anonymous
-    @Auth(session = true)
+    @Auth
     @GetMapping("/getFoodPageList")
     public AjaxResult getFoodPageList(@RequestHeader String token,
                                       @RequestParam Integer page,

+ 6 - 6
ruoyi-admin/src/main/java/com/ruoyi/app/mendian/PosStoreController.java

@@ -123,7 +123,7 @@ public class PosStoreController extends BaseController {
 
     //删除我的门店
     @Anonymous
-    @Auth(session = true)
+    @Auth
     @Transactional(rollbackFor = Exception.class)
     @GetMapping("/delemendian")
     public AjaxResult delemendian(@RequestHeader String token, @RequestParam String id) {
@@ -148,7 +148,7 @@ public class PosStoreController extends BaseController {
      * H5端门店列表
      */
     @Anonymous
-    @Auth(session = true)
+    @Auth
     @GetMapping("/storelistlist")
     public AjaxResult storelistlist(@RequestHeader String token,
                                     @RequestParam Integer page,
@@ -191,7 +191,7 @@ public class PosStoreController extends BaseController {
 
     //查询我的门店列表
     @Anonymous
-    @Auth(session = true)
+    @Auth
     @GetMapping("/getmystorelist")
     public AjaxResult getmystorelist(@RequestHeader String token) {
         Long id = AuthContext.requireUserId();
@@ -309,7 +309,7 @@ public class PosStoreController extends BaseController {
 
     //添加门店
     @Anonymous
-    @Auth(session = true)
+    @Auth
     @PostMapping("/addmendian")
     @Transactional
     public AjaxResult addmendian(@RequestHeader String token, @RequestBody PosStore posStore) {
@@ -403,7 +403,7 @@ public class PosStoreController extends BaseController {
      * @return
      */
     @Anonymous
-    @Auth(session = true)
+    @Auth
     @GetMapping("/getMdBusinessHours")
     public AjaxResult getMdBusinessHours(@RequestHeader String token, @RequestParam Long mdId){
         Long userId = AuthContext.requireUserId();
@@ -424,7 +424,7 @@ public class PosStoreController extends BaseController {
      * @return
      */
     @Anonymous
-    @Auth(session = true)
+    @Auth
     @PostMapping("/saveMdBusinessHours")
     public AjaxResult saveMdBusinessHours(@RequestHeader String token,
                                           @RequestBody List<StoreOperatOutput> businessHours) {

+ 58 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentNotifyService.java

@@ -5,16 +5,24 @@ import com.ruoyi.app.order.DeliveryOrderNotificationService;
 import com.ruoyi.app.order.MerchantNotificationRouter;
 import com.ruoyi.app.order.dto.OrderPushBodyDto;
 import com.ruoyi.app.omgpay.dto.OmgNotifyRequest;
+import com.ruoyi.app.utils.PayPush;
+import com.ruoyi.app.utils.event.PushEventService;
+import com.ruoyi.common.utils.LocaleUtils;
+import com.ruoyi.common.utils.MessageUtils;
+import com.ruoyi.system.domain.InfoUser;
 import com.ruoyi.system.domain.PosOrder;
 import com.ruoyi.system.omgpay.domain.OmgPaymentAttempt;
 import com.ruoyi.system.omgpay.domain.OmgPaymentOrderSnapshot;
 import com.ruoyi.system.omgpay.service.IOmgPaymentAttemptService;
+import com.ruoyi.system.service.IInfoUserService;
 import com.ruoyi.system.service.IPosOrderService;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
+import org.springframework.transaction.support.TransactionSynchronization;
+import org.springframework.transaction.support.TransactionSynchronizationManager;
 
 import java.math.BigDecimal;
 import java.nio.charset.StandardCharsets;
@@ -25,6 +33,7 @@ import java.time.format.DateTimeFormatter;
 import java.time.format.DateTimeParseException;
 import java.util.Date;
 import java.util.List;
+import java.util.Locale;
 
 /** Verifies and applies one final OMG payment notification transactionally. */
 @Service
@@ -47,6 +56,10 @@ public class OmgPaymentNotifyService {
     private DeliveryOrderNotificationService deliveryOrderNotificationService;
     @Autowired(required = false)
     private MerchantNotificationRouter merchantNotificationRouter;
+    @Autowired(required = false)
+    private IInfoUserService infoUserService;
+    @Autowired(required = false)
+    private PushEventService pushEventService;
 
     public OmgPaymentNotifyService(IOmgPaymentAttemptService attempts, OmgCheckMacSigner signer) {
         this.attempts = attempts;
@@ -156,6 +169,7 @@ public class OmgPaymentNotifyService {
         if (order.getPayStatus() == null || order.getPayStatus() != 1L) {
             requireSingleUpdate(attempts.markOrderPaid(attempt.getDdId()), "mark order paid");
             openDeliveryOrderToRiders(attempt.getDdId(), order.getState());
+            pushPaymentSuccessToUser(order);
         }
         int otherPaid = attempts.countOtherPaidAttempts(attempt.getDdId(), attempt.getId());
         if (order.getState() != null && order.getState() == 4L) {
@@ -195,6 +209,50 @@ public class OmgPaymentNotifyService {
         }
     }
 
+    /**
+     * 首次支付成功后给下单用户推"支付成功"(对齐 LINE Pay 链路);重复回调/补单查询
+     * 已支付的订单不会重复推(payStatus 窗口保证)。先入消息中心库再发 iOS 推送,
+     * 推送外呼失败不影响消息中心落库。
+     */
+    private void pushPaymentSuccessToUser(OmgPaymentOrderSnapshot order) {
+        if (infoUserService == null || pushEventService == null
+                || order == null || order.getUserId() == null) {
+            return;
+        }
+        runAfterCommit(() -> {
+            try {
+                InfoUser user = infoUserService.getById(order.getUserId());
+                if (user == null) {
+                    return;
+                }
+                // 回调线程无请求级 locale,必须按收件人用户语言解析(同 LINE Pay 链路)
+                Locale userLocale = LocaleUtils.getUserLocale(user.getUserId());
+                String title = MessageUtils.message("no.message.push.message", userLocale);
+                String content = MessageUtils.message("no.message.push.payment.success", userLocale);
+                String body = OrderPushBodyDto.getJson(order.getDdId(), String.valueOf(order.getState()), 0);
+                pushEventService.PublisherEvent(user.getUserId(), title, content, body);
+                if (user.getCid() != null && !user.getCid().trim().isEmpty()) {
+                    new PayPush().apppush(user.getCid(), title, content, body);
+                }
+            } catch (Exception exception) {
+                log.error("OMG payment success push failed, ddId={}", order.getDdId(), exception);
+            }
+        });
+    }
+
+    private static void runAfterCommit(Runnable action) {
+        if (!TransactionSynchronizationManager.isSynchronizationActive()) {
+            action.run();
+            return;
+        }
+        TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
+            @Override
+            public void afterCommit() {
+                action.run();
+            }
+        });
+    }
+
     private boolean verifyTrust(OmgNotifyRequest request, OmgPaymentAttempt attempt) {
         if (!request.value("MerchantID").equals(attempt.getMerchantId())) {
             return false;

+ 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";

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

@@ -228,7 +228,7 @@ public class PosOrderController extends BaseController {
 
     //修改订单
     @Anonymous
-    @Auth(session = true)
+    @Auth
     @RepeatSubmit(interval = 1000, message = "请求过于频繁")
     @PostMapping("/setorderuzt")
     @Transactional(rollbackFor = Exception.class)
@@ -698,7 +698,7 @@ public class PosOrderController extends BaseController {
      */
     //查询订单详情
     @Anonymous
-    @Auth(session = true)
+    @Auth
     @GetMapping("/getorderxq")
     public AjaxResult getorderxq(@RequestHeader String token, @RequestParam String ddid) {
         QueryWrapper<PosOrder> queryWrapper = new QueryWrapper<>();
@@ -877,7 +877,7 @@ public class PosOrderController extends BaseController {
 
     //查询商家的订单
     @Anonymous
-    @Auth(session = true)
+    @Auth
     @GetMapping("/getstoreorderlist")
     public AjaxResult getstoreorderlist(@RequestHeader String token, @RequestParam Integer page, @RequestParam Integer size, @RequestParam String mdId, @RequestParam(defaultValue = "") String state, @RequestParam(defaultValue = "") String diningStatus,@RequestParam(defaultValue = "")String type) {
         Long loginUserId = AuthContext.requireUserId();

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

@@ -83,7 +83,7 @@ public class PosOrderShOprateController extends BaseController {
      */
 
     @GetMapping("/getInvoice")
-    @Auth(session = true)
+    @Auth
     @Anonymous
     public AjaxResult getInvoice(@RequestHeader String token, @RequestParam Long orderId) {
         MerchantAccessContext context = requireManagedOrder(orderId);
@@ -95,7 +95,7 @@ public class PosOrderShOprateController extends BaseController {
      */
 
     @PutMapping("/invalidInvoice/{orderId}")
-    @Auth(session = true)
+    @Auth
     @Anonymous
     public AjaxResult invalidInvoice(@RequestHeader String token, @PathVariable Long orderId,
                                      @RequestBody(required = false) InvalidInvoiceDto dto) {
@@ -108,7 +108,7 @@ public class PosOrderShOprateController extends BaseController {
      * 商家端下单
      */
     @PostMapping("/createOrder")
-    @Auth(session = true)
+    @Auth
     @Anonymous
     @Transactional(rollbackFor = Exception.class)
     public AjaxResult createOrder(@RequestHeader String token, @RequestBody OrderCreateInput input)
@@ -299,7 +299,7 @@ public class PosOrderShOprateController extends BaseController {
      * 商家接单:state 从 0 改为 1
      */
     @Anonymous
-    @Auth(session = true)
+    @Auth
     @GetMapping("/acceptOrder")
     public AjaxResult acceptOrder(@RequestHeader String token, @RequestParam Long id) {
         PosOrder order = posOrderService.getOne(new LambdaQueryWrapper<PosOrder>().eq(PosOrder::getId, id));
@@ -324,7 +324,7 @@ public class PosOrderShOprateController extends BaseController {
      * 外送订单保持 deliveryStatus=1(骑手已接单)
      */
     @Anonymous
-    @Auth(session = true)
+    @Auth
     @GetMapping("/dispatchOrder")
     public AjaxResult dispatchOrder(@RequestHeader String token, @RequestParam Long id) {
         PosOrder order = posOrderService.getOne(new LambdaQueryWrapper<PosOrder>().eq(PosOrder::getId, id));
@@ -355,7 +355,7 @@ public class PosOrderShOprateController extends BaseController {
      * 商家完成(自取/堂食):state 从 2 改为 3,payStatus 从 0 改为 1
      */
     @Anonymous
-    @Auth(session = true)
+    @Auth
     @Transactional(rollbackFor = Exception.class)
     @GetMapping("/completeOrder")
     public AjaxResult completeOrder(@RequestHeader String token, @RequestParam Long id) {
@@ -393,7 +393,7 @@ public class PosOrderShOprateController extends BaseController {
      * 商家确认现金收款:payType=4 的订单 payStatus 0→1(现金单创建时未支付,商家收到现金后在App触发)
      */
     @Anonymous
-    @Auth(session = true)
+    @Auth
     @GetMapping("/confirmCashPayment")
     public AjaxResult confirmCashPayment(@RequestHeader String token, @RequestParam Long id) {
         PosOrder order = posOrderService.getOne(new LambdaQueryWrapper<PosOrder>().eq(PosOrder::getId, id));
@@ -426,7 +426,7 @@ public class PosOrderShOprateController extends BaseController {
      * 外送单确认后推送附近骑手开放接单;与现金确认(confirmCashPayment)相互独立
      */
     @Anonymous
-    @Auth(session = true)
+    @Auth
     @GetMapping("/confirmTransferPayment")
     public AjaxResult confirmTransferPayment(@RequestHeader String token, @RequestParam Long id) {
         PosOrder order = posOrderService.getOne(new LambdaQueryWrapper<PosOrder>().eq(PosOrder::getId, id));
@@ -462,7 +462,7 @@ public class PosOrderShOprateController extends BaseController {
      * 商家取消订单:校验 state IN (0,1),设 state=4
      */
     @Anonymous
-    @Auth(session = true)
+    @Auth
     @GetMapping("/cancelOrder")
     public AjaxResult cancelOrder(@RequestHeader String token, @RequestParam Long id) {
         PosOrder order = posOrderService.getOne(new LambdaQueryWrapper<PosOrder>().eq(PosOrder::getId, id));
@@ -597,7 +597,7 @@ public class PosOrderShOprateController extends BaseController {
      * ta
      */
     @Anonymous
-    @Auth(session = true)
+    @Auth
     @GetMapping("/orderList")
     public AjaxResult orderList(@RequestHeader String token,
                                 @RequestParam(defaultValue = "1") int page,
@@ -644,9 +644,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":
@@ -677,7 +677,7 @@ public class PosOrderShOprateController extends BaseController {
     }
 
     @Anonymous
-    @Auth(session = true)
+    @Auth
     @GetMapping("/orderDetail")
     public AjaxResult orderDetail(@RequestHeader String token, @RequestParam Long id) {
         requireManagedOrder(id);

+ 5 - 4
ruoyi-admin/src/main/java/com/ruoyi/app/order/UserOrderController.java

@@ -162,8 +162,9 @@ public class UserOrderController extends BaseController {
             }
             shdz= JSON.toJSONString(usadd);
         }
-        // 现金支付(4)仅商家创建订单可用,客户端下单直接拒绝
-        if (OrderLifecycleService.PAY_TYPE_CASH.equals(input.getPaymentMethod())) {
+        // 现金支付(4):堂食/自取用户可直接下单线下收款;外送仍仅商家创建订单可用
+        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)===
@@ -563,9 +564,9 @@ public class UserOrderController extends BaseController {
             case "active":
                 wrapper.in(PosOrder::getState, 0L, 1L, 2L)
                        .eq(PosOrder::getAfterSaleStatus, 0L)
-                       // 现金自取/堂食可以线下收款;OMG、LINE Pay 等在线订单必须已付款才能履约。
+                       // 到付/现金自取/堂食可以线下收款;OMG、LINE Pay 等在线订单必须已付款才能履约。
                        .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 "completed":

+ 64 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/pay/PayMethodSwitchController.java

@@ -0,0 +1,64 @@
+package com.ruoyi.app.pay;
+
+import com.ruoyi.app.pay.dto.PayMethodSwitchRequest;
+import com.ruoyi.common.annotation.Anonymous;
+import com.ruoyi.common.core.domain.AjaxResult;
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.common.utils.MessageUtils;
+import com.ruoyi.system.utils.Auth;
+import com.ruoyi.system.utils.JwtUtil;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestHeader;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+/**
+ * 待支付订单更换支付方式入口(#661,2026-09-22)。
+ *
+ * <p>只服务「换方式」场景:原方式重试仍走 /pay/omg/retry 与 /pay/line/create,行为不变。
+ * 业务校验在 {@link PayMethodSwitchService},本控制器只做入参兜底与异常翻译。
+ */
+@RestController
+@RequestMapping("/pay")
+public class PayMethodSwitchController {
+
+    private static final Logger logger = LoggerFactory.getLogger(PayMethodSwitchController.class);
+
+    private final PayMethodSwitchService switchService;
+
+    public PayMethodSwitchController(PayMethodSwitchService switchService) {
+        this.switchService = switchService;
+    }
+
+    /** 用户对待支付订单更换支付方式:回写 payType 并按新方式拉起支付(到付/现金直接成功)。 */
+    @Anonymous
+    @Auth
+    @PostMapping("/switch")
+    public AjaxResult switchMethod(@RequestHeader String token,
+                                   @RequestBody(required = false) PayMethodSwitchRequest request) {
+        if (request == null || request.getOrderId() == null || request.getOrderId().trim().isEmpty()) {
+            return AjaxResult.error(MessageUtils.message("no.pay.switch.order.invalid"));
+        }
+        try {
+            Long userId = Long.valueOf(new JwtUtil().getusid(token));
+            return switchService.switchMethod(userId, request);
+        } catch (ServiceException exception) {
+            // 业务拒绝(订单不可支付/方式非法/闸门限制)携带国际化文案原样返回
+            return AjaxResult.error(exception.getMessage());
+        } catch (com.ruoyi.app.omgpay.OmgPaymentBusinessException exception) {
+            // 渠道业务拒绝(如已有激活尝试)按 OMG 既有约定翻译,不再落通用文案误导排障
+            logger.warn("pay switch rejected by channel orderId={}, code={}",
+                    request == null ? null : request.getOrderId(), exception.getCode());
+            return AjaxResult.error(MessageUtils.message(exception.getMessageKey()),
+                    new com.ruoyi.app.omgpay.dto.OmgPaymentErrorResponse(exception.getCode().name()));
+        } catch (Exception exception) {
+            // 兜底前必须留痕:真实异常(网关调用/NPE/SQL)只有这里能看到,不打日志等于盲修
+            logger.error("pay switch failed orderId={}",
+                    request == null ? null : request.getOrderId(), exception);
+            return AjaxResult.error(MessageUtils.message("no.pay.switch.failed"));
+        }
+    }
+}

+ 169 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/pay/PayMethodSwitchService.java

@@ -0,0 +1,169 @@
+package com.ruoyi.app.pay;
+
+import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
+import com.ruoyi.app.omgpay.OmgPaymentCreateService;
+import com.ruoyi.app.omgpay.OmgPaymentRetryService;
+import com.ruoyi.app.omgpay.OmgPaymentMethod;
+import com.ruoyi.app.order.OrderLifecycleService;
+import com.ruoyi.app.pay.dto.PayMethodSwitchRequest;
+import com.ruoyi.common.core.domain.AjaxResult;
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.common.utils.MessageUtils;
+import com.ruoyi.system.domain.InfoUser;
+import com.ruoyi.system.domain.PosOrder;
+import com.ruoyi.system.service.IInfoUserService;
+import com.ruoyi.system.service.IPosOrderService;
+import com.ruoyi.system.utils.OrderLogHelper;
+import org.springframework.stereotype.Service;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+/**
+ * 待支付订单更换支付方式(#661,2026-09-22)。
+ *
+ * <p><b>背景</b>:订单 payType 在下单时写死,OMG 与 LINE Pay 两侧的创建校验各自按
+ * payType 把关(OMG 仅 2/5、LINE Pay 仅 3),支付失败后用户只能原路重试,无法换方式。
+ *
+ * <p><b>职责</b>:校验订单可切换 → 回写 order.payType → 路由到对应渠道拉起支付;
+ * 换成到付(1)/现金(4) 时无支付环节,回写后直接成功。原渠道的重试(不换方式)
+ * 仍走各自原有接口(/pay/omg/retry、/pay/line/create),行为不变。
+ *
+ * <p><b>顺序约束</b>:必须先回写 payType 再拉起新渠道——两个渠道的创建校验
+ * (OmgPaymentCreateService/LinePayOrderGuard)都以 order.payType 为闸门,顺序颠倒会被拒。
+ * 旧渠道未完结的支付尝试不做主动关闭,依赖其回调处的 payStatus 守卫与既有补偿任务收口;
+ * 回调晚到的极端情形由对账/退款流程兜底。
+ */
+@Service
+public class PayMethodSwitchService {
+
+    /** LINE Pay 的 payType(与 LinePayService.PAY_TYPE_LINE 一致;OrderLifecycleService 未定义该常量) */
+    private static final String PAY_TYPE_LINE_PAY = "3";
+
+    private final IPosOrderService orderService;
+    private final OmgPaymentRetryService omgPaymentRetryService;
+    private final LinePayService linePayService;
+    private final OrderLogHelper orderLogHelper;
+    private final IInfoUserService infoUserService;
+
+    public PayMethodSwitchService(IPosOrderService orderService,
+                                  OmgPaymentRetryService omgPaymentRetryService,
+                                  LinePayService linePayService,
+                                  OrderLogHelper orderLogHelper,
+                                  IInfoUserService infoUserService) {
+        this.orderService = orderService;
+        this.omgPaymentRetryService = omgPaymentRetryService;
+        this.linePayService = linePayService;
+        this.orderLogHelper = orderLogHelper;
+        this.infoUserService = infoUserService;
+    }
+
+    /**
+     * 切换待支付订单的支付方式并拉起新渠道支付。
+     *
+     * @return data.ddId=订单号、data.payType=新方式、data.payParams=渠道支付参数
+     *         (信用卡/Apple Pay 为 OMG 表单参数、LINE Pay 为跳转结果;到付/现金为 null,App 刷新订单即可)
+     */
+    public AjaxResult switchMethod(Long userId, PayMethodSwitchRequest request) {
+        String orderId = normalizeOrderId(request == null ? null : request.getOrderId());
+        String method = normalizeMethod(request == null ? null : request.getPaymentMethod());
+        PosOrder order = requireSwitchableOrder(userId, orderId);
+        // 到付/现金是线下收款,仅自取/堂食可用(与下单现金闸门同规则,外送单不放行)
+        requireOfflineAllowed(method, order);
+
+        String previous = order.getPayType();
+        if (!method.equals(previous)) {
+            // 先回写再拉起:两个渠道的创建校验都按 order.payType 把关,顺序颠倒会被拒
+            order.setPayType(method);
+            if (!orderService.updateById(order)) {
+                throw new ServiceException(MessageUtils.message("no.pay.switch.failed"));
+            }
+            writeSwitchLog(orderId, userId, previous, method);
+        }
+
+        Map<String, Object> data = new LinkedHashMap<>();
+        data.put("ddId", orderId);
+        data.put("payType", method);
+        data.put("payParams", createPayment(userId, orderId, method));
+        return AjaxResult.success(data);
+    }
+
+    /** 按新方式路由到对应渠道拉起支付;到付/现金无支付环节返回 null。 */
+    private Object createPayment(Long userId, String orderId, String method) {
+        if (OrderLifecycleService.isCardWalletPayType(method)) {
+            // 信用卡(2)/Apple Pay(5) 同组同商(023),都走 OMG;走 retry 路径而非裸 create:
+            // 组内切换(如 5->2)时旧尝试仍激活,retry 会先向网关核实未付再替换重建,
+            // 避免 PAYMENT_ATTEMPT_EXISTS 拒绝(payType 已回写,组内校验自然通过)
+            OmgPaymentMethod omgMethod = OrderLifecycleService.PAY_TYPE_APPLE_PAY.equals(method)
+                    ? OmgPaymentMethod.APPLE_PAY : OmgPaymentMethod.CREDIT;
+            return omgPaymentRetryService.retry(userId, orderId, omgMethod).response();
+        }
+        if (PAY_TYPE_LINE_PAY.equals(method)) {
+            return linePayService.create(userId, orderId);
+        }
+        // 到付(1)/现金(4):线下收款,无支付尝试
+        return null;
+    }
+
+    /** 订单存在、属于当前用户、主订单(非多门店子单)且处于可支付状态。 */
+    private PosOrder requireSwitchableOrder(Long userId, String orderId) {
+        PosOrder order = orderService.getOne(new QueryWrapper<PosOrder>().eq("dd_id", orderId));
+        if (order == null || order.getUserId() == null || !order.getUserId().equals(userId)) {
+            throw new ServiceException(MessageUtils.message("no.pay.switch.order.invalid"));
+        }
+        // 多门店子单本身不发起在线支付(与 OMG 创建口径一致),不开放切换
+        if (order.getParentDdId() == null || !orderId.equals(order.getParentDdId())) {
+            throw new ServiceException(MessageUtils.message("no.pay.switch.order.invalid"));
+        }
+        if (order.getState() == null || order.getState() < 0 || order.getState() > 2) {
+            throw new ServiceException(MessageUtils.message("no.pay.switch.order.invalid"));
+        }
+        if (order.getPayStatus() == null || order.getPayStatus() != 0L) {
+            throw new ServiceException(MessageUtils.message("no.pay.switch.already.paid"));
+        }
+        if (order.getAmount() == null || order.getAmount() <= 0) {
+            throw new ServiceException(MessageUtils.message("no.pay.switch.order.invalid"));
+        }
+        return order;
+    }
+
+    /** 到付(1)/现金(4) 仅自取(type=1)/堂食(type=2)订单可切换,外送单保持仅商家创建时可用。 */
+    private void requireOfflineAllowed(String method, PosOrder order) {
+        boolean offline = OrderLifecycleService.PAY_TYPE_OFFLINE.equals(method)
+                || OrderLifecycleService.PAY_TYPE_CASH.equals(method);
+        if (offline && (order.getType() == null || (order.getType() != 1L && order.getType() != 2L))) {
+            throw new ServiceException(MessageUtils.message("no.pay.switch.offline.restricted"));
+        }
+    }
+
+    /** 校验目标方式取值:1/2/3/4/5 之外(含 6=线下转账)一律拒绝。 */
+    private String normalizeMethod(String paymentMethod) {
+        String method = paymentMethod == null ? "" : paymentMethod.trim();
+        boolean known = OrderLifecycleService.PAY_TYPE_OFFLINE.equals(method)
+                || OrderLifecycleService.PAY_TYPE_OMG.equals(method)
+                || PAY_TYPE_LINE_PAY.equals(method)
+                || OrderLifecycleService.PAY_TYPE_CASH.equals(method)
+                || OrderLifecycleService.PAY_TYPE_APPLE_PAY.equals(method);
+        if (!known) {
+            throw new ServiceException(MessageUtils.message("no.pay.switch.method.invalid"));
+        }
+        return method;
+    }
+
+    private String normalizeOrderId(String orderId) {
+        if (orderId == null || orderId.trim().isEmpty()) {
+            throw new ServiceException(MessageUtils.message("no.pay.switch.order.invalid"));
+        }
+        return orderId.trim();
+    }
+
+    /** 记录切换轨迹(operatorType=4 用户,与创建订单日志一致)。 */
+    private void writeSwitchLog(String orderId, Long userId, String previous, String method) {
+        String name = String.valueOf(userId);
+        InfoUser user = infoUserService.getById(userId);
+        if (user != null && user.getNickName() != null && !user.getNickName().isBlank()) {
+            name = user.getNickName();
+        }
+        orderLogHelper.log(orderId, 4, userId, name, "用户更换支付方式:" + previous + " → " + method);
+    }
+}

+ 30 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/pay/dto/PayMethodSwitchRequest.java

@@ -0,0 +1,30 @@
+package com.ruoyi.app.pay.dto;
+
+/**
+ * 待支付订单更换支付方式请求(#661 待支付订单无法更换支付方式)。
+ *
+ * <p>paymentMethod 取值与下单时的 {@code OrderCreateInput.paymentMethod} 同域:
+ * 1=货到付款、2=信用卡、3=LINE Pay、4=现金、5=Apple Pay;6=线下转账不开放切换(商家侧流程)。
+ */
+public class PayMethodSwitchRequest {
+    /** 订单号(主订单 ddId;多门店子单不支持在线支付,不开放切换) */
+    private String orderId;
+    /** 目标支付方式 */
+    private String paymentMethod;
+
+    public String getOrderId() {
+        return orderId;
+    }
+
+    public void setOrderId(String orderId) {
+        this.orderId = orderId;
+    }
+
+    public String getPaymentMethod() {
+        return paymentMethod;
+    }
+
+    public void setPaymentMethod(String paymentMethod) {
+        this.paymentMethod = paymentMethod;
+    }
+}

+ 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;
+}

+ 136 - 10
ruoyi-admin/src/main/java/com/ruoyi/app/user/InfoUserController.java

@@ -33,19 +33,17 @@ import com.ruoyi.system.mapper.InfoUserMapper;
 import com.ruoyi.system.mapper.PosOrderMapper;
 import com.ruoyi.system.mapper.PosStoreMapper;
 import com.ruoyi.common.core.domain.entity.SysDictData;
-import com.ruoyi.system.service.IInfoUserService;
-import com.ruoyi.system.service.ISysDictDataService;
+import com.ruoyi.system.service.*;
 
-import com.ruoyi.system.service.IPosOrderService;
-import com.ruoyi.system.service.IUserWalletService;
-import com.ruoyi.system.service.IVipUserService;
-import com.ruoyi.system.service.MerchantStoreAccessService;
 import com.ruoyi.system.utils.Auth;
 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.user.service.DeviceTrustService;
+import com.ruoyi.system.domain.InfoUserDevice;
 import com.ruoyi.app.utils.oauth.OAuthVerifyService;
 import com.ruoyi.system.mapper.InfoUserOauthMapper;
 
@@ -113,8 +111,14 @@ public class InfoUserController extends BaseController {
     private MerchantTokenSessionService merchantTokenSessionService;
     @Autowired
     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
@@ -490,14 +494,14 @@ public class InfoUserController extends BaseController {
                 info.setCidType(userDTO.getCidType());
                 info.setDeviceToken(userDTO.getDeviceToken());
                 info.setVoIPToken(userDTO.getVoIPToken());
-                info.setOffline("0");
+                info.setOffline("1");
                 infoUserService.saveOrUpdate(info);
                 if (!"".equals(userDTO.getCid())) {
                     user.setCid(userDTO.getCid());
                 }
                 ;
                 //返回离线标识状态为在线
-                user.setOffline("0");
+                user.setOffline("1");
                 redisCache.deleteKeys(CacheConstants.QS_TOKEN_KEY + user.getUserId() + ":" + "*");
 
                 // 补充登录用户信息
@@ -585,7 +589,7 @@ public class InfoUserController extends BaseController {
      * 商家端退出登录,仅撤销当前 JWT 对应的会话。
      */
     @Anonymous
-    @Auth(session = true)
+    @Auth
     @PostMapping("/merchantLogout")
     public AjaxResult merchantLogout(@RequestHeader String token) {
         merchantTokenSessionService.logoutCurrent(AuthContext.requireJti());
@@ -603,6 +607,35 @@ public class InfoUserController extends BaseController {
 
     }
 
+    /**
+     * 退出登录
+     *
+     * @param token
+     * @return
+     */
+    @Anonymous
+    @Auth
+    @GetMapping("/logout")
+    public AjaxResult logout(@RequestHeader String token) {
+        JwtUtil jwtUtil = new JwtUtil();
+        String id = jwtUtil.getusid(token);
+        InfoUser user = infoUserService.getOne(new LambdaQueryWrapper<InfoUser>().eq(InfoUser::getUserId, Long.valueOf(id)));
+        if (user == null) {
+            throw new ServiceException("未查询到用户信息");
+        }
+        LambdaUpdateWrapper<InfoUser> updateWrapper = new LambdaUpdateWrapper<>();
+        updateWrapper.eq(InfoUser::getUserId, user.getUserId())
+                .set(InfoUser::getOffline, "1")
+                .set(InfoUser::getCid, null)
+                .set(InfoUser::getCidType, null);
+        infoUserService.update(updateWrapper);
+        //骑手删除定位信息
+        if ("2".equals(user.getUserType())) {
+            riderPositionService.remove(new LambdaQueryWrapper<RiderPosition>().eq(RiderPosition::getRiderId, user.getUserId()));
+        }
+        return success();
+    }
+
     /**
      * 骑手切换离线状态(0:在线,1:离线)
      *
@@ -961,6 +994,7 @@ public class InfoUserController extends BaseController {
         infoUserService.saveOrUpdate(info);
         InfoUser inus = infoUserService.getuser(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();
@@ -992,6 +1026,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);
@@ -1049,14 +1084,27 @@ 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={}, providerUid={}", dto.getProvider(), providerUid);
+        // (029:同设备已验证过手机号 → 免短信一键确认 deviceConfirm 分支)
         String tempKey = UUID.randomUUID().toString().replace("-", "");
         redisCache.setCacheObject(OAUTH_TEMP_PREFIX + tempKey,
                 dto.getProvider() + "@" + providerUid, 5, TimeUnit.MINUTES);
+        String trustedPhone = deviceTrustService.getTrustedPhone(dto.getDeviceId());
+        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);
@@ -1144,6 +1192,84 @@ public class InfoUserController extends BaseController {
         log.info("[OAuth] 写入绑定 provider={}, providerUid={}, userId={}", provider, providerUid, 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);
+        log.info("[OAuth] 设备确认流程 provider={}, providerUid={}, deviceId={}", provider, providerUid, dto.getDeviceId());
+
+        // 容错:若期间已被绑定,直接登录(与 oauthBindPhone 一致)
+        InfoUserOauth exist = infoUserOauthMapper.selectOne(
+                new LambdaQueryWrapper<InfoUserOauth>()
+                        .eq(InfoUserOauth::getProvider, provider)
+                        .eq(InfoUserOauth::getProviderUid, providerUid));
+        if (exist != null) {
+            log.info("[OAuth] 设备确认期间已被绑定,直接登录 userId={}", exist.getUserId());
+            InfoUser u = infoUserService.getById(exist.getUserId());
+            return issueOauthToken(u, provider);
+        }
+
+        // 信任手机号即账号主键:已注册校验停用、未注册自动创建(与 oauthBindPhone 一致,防绕过停用)
+        String phone = trust.getPhone();
+        InfoUser user = infoUserService.getuser(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 = infoUserService.getuser(phone);
+            createUserWallet(user.getUserId());
+            log.info("[OAuth] 设备确认新建账号 userId={}", user.getUserId());
+        }
+
+        // 写三方绑定(一键确认即免短信绑定)
+        InfoUserOauth bind = new InfoUserOauth();
+        bind.setUserId(user.getUserId());
+        bind.setProvider(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);
     }
 

+ 17 - 0
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.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
+import com.ruoyi.app.user.service.DeviceTrustService;
 import com.ruoyi.app.utils.oauth.OAuthVerifyService;
 import com.ruoyi.common.annotation.Anonymous;
 import com.ruoyi.common.constant.CacheConstants;
@@ -48,6 +49,7 @@ import java.util.concurrent.TimeUnit;
  * <ul>
  *   <li>已绑定:{@code <app-redirect>?token=xxx}</li>
  *   <li>未绑定:{@code <app-redirect>?needPhone=1&tempKey=xxx}(App 弹手机号+短信码 UI 后调 /infouser/user/oauthBindPhone)</li>
+ *   <li>未绑定且设备已信任(029):{@code <app-redirect>?deviceConfirm=1&tempKey=xxx&maskedPhone=xxx}(App 弹只读确认框后调 /infouser/user/oauthDeviceConfirm)</li>
  *   <li>异常:{@code <app-redirect>?error=xxx}</li>
  * </ul>
  *
@@ -66,6 +68,8 @@ public class LineCallbackController extends BaseController {
 
     /** 与 InfoUserController.OAUTH_TEMP_PREFIX 一致:未绑定临时凭证 Redis 前缀(oauthBindPhone 消费) */
     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;
@@ -75,6 +79,8 @@ public class LineCallbackController extends BaseController {
     private InfoUserOauthMapper infoUserOauthMapper;
     @Autowired
     private RedisCache redisCache;
+    @Autowired
+    private DeviceTrustService deviceTrustService;
 
     /** 后端登录后 302 跳回 App 的 scheme(App 注册该 scheme 接收 token/tempKey/error) */
     @Value("${oauth.line.app-redirect}")
@@ -113,14 +119,25 @@ public class LineCallbackController extends BaseController {
                     return;
                 }
                 String token = buildOauthToken(u, "line");
+                deviceTrustService.recordLogin(state, u.getPhone(), u.getUserId());
                 log.info("[OAuth][LINE] callback 已绑定登录成功 userId={}", u.getUserId());
                 response.sendRedirect(appRedirect + "?token=" + enc(token));
                 return;
             }
 
             // 未绑定:缓存 {line, providerUid},返回 needPhone + tempKey(与 oauthLogin 同款,oauthBindPhone 消费)
+            // 029:state 由前端传 deviceId(契约见 specs/029/contracts/api-contract.md §5),设备已信任则走免绑分支
             String tempKey = UUID.randomUUID().toString().replace("-", "");
             redisCache.setCacheObject(OAUTH_TEMP_PREFIX + tempKey, "line@" + providerUid, 5, TimeUnit.MINUTES);
+            String trustedPhone = 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(appRedirect + "?deviceConfirm=1&tempKey=" + enc(tempKey)
+                        + "&maskedPhone=" + enc(DeviceTrustService.maskPhone(trustedPhone)));
+                return;
+            }
             log.info("[OAuth][LINE] callback 未绑定,返回 needPhone providerUid={}", providerUid);
             response.sendRedirect(appRedirect + "?needPhone=1&tempKey=" + enc(tempKey));
         } catch (Exception e) {

+ 5 - 5
ruoyi-admin/src/main/java/com/ruoyi/app/user/MerchantSubaccountController.java

@@ -29,14 +29,14 @@ public class MerchantSubaccountController extends BaseController {
     }
 
     @Anonymous
-    @Auth(session = true)
+    @Auth
     @GetMapping
     public AjaxResult list(@RequestHeader String token) {
         return success(applicationService.listForOwner(AuthContext.requireUserId()));
     }
 
     @Anonymous
-    @Auth(session = true)
+    @Auth
     @PostMapping
     public AjaxResult create(@RequestHeader String token,
                              @RequestBody MerchantSubaccountCreateRequest request) {
@@ -44,7 +44,7 @@ public class MerchantSubaccountController extends BaseController {
     }
 
     @Anonymous
-    @Auth(session = true)
+    @Auth
     @PutMapping("/{subaccountUserId}")
     public AjaxResult update(@RequestHeader String token,
                              @PathVariable Long subaccountUserId,
@@ -54,7 +54,7 @@ public class MerchantSubaccountController extends BaseController {
     }
 
     @Anonymous
-    @Auth(session = true)
+    @Auth
     @PutMapping("/{subaccountUserId}/password")
     public AjaxResult updatePassword(@RequestHeader String token,
                                      @PathVariable Long subaccountUserId,
@@ -65,7 +65,7 @@ public class MerchantSubaccountController extends BaseController {
     }
 
     @Anonymous
-    @Auth(session = true)
+    @Auth
     @PutMapping("/{subaccountUserId}/status")
     public AjaxResult updateStatus(@RequestHeader String token,
                                    @PathVariable Long subaccountUserId,

+ 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

@@ -22,4 +22,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);
+    }
+}

+ 27 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/utils/event/PushEventListener.java

@@ -1,6 +1,8 @@
 package com.ruoyi.app.utils.event;
 
 
+import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.JSONObject;
 import com.ruoyi.system.domain.PushMessage;
 import com.ruoyi.system.service.IPushMessageService;
 import org.springframework.beans.factory.annotation.Autowired;
@@ -23,7 +25,32 @@ public class PushEventListener  implements ApplicationListener<PushEvent> {
         pushMessage.setBody(event.getBody());
         pushMessage.setUserId(event.getUserId());
         pushMessage.setTime(new java.util.Date());
+        applyOrderPointer(pushMessage);
         pushMessageService.insertPushMessage(pushMessage);
 
     }
+
+    /**
+     * 从推送 body JSON(OrderPushBodyDto 结构)解析订单指针 ddId/pushType 存列,
+     * 供消息列表反查订单展示信息;非订单类推送(body 非订单 JSON)解析失败时指针留空。
+     */
+    private void applyOrderPointer(PushMessage pushMessage) {
+        String body = pushMessage.getBody();
+        if (body == null || body.isEmpty()) {
+            return;
+        }
+        try {
+            JSONObject json = JSON.parseObject(body);
+            String ddId = json.getString("ddId");
+            Integer pushType = json.getInteger("pushType");
+            if (ddId != null && !ddId.isEmpty()) {
+                pushMessage.setDdId(ddId);
+            }
+            if (pushType != null) {
+                pushMessage.setPushType(pushType);
+            }
+        } catch (Exception ignored) {
+            // body 不是订单 JSON(如视频通话推送),不阻断入库
+        }
+    }
 }

+ 49 - 0
ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/PushMessageController.java

@@ -3,6 +3,7 @@ package com.ruoyi.web.controller.system;
 import java.util.List;
 
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
 import com.baomidou.mybatisplus.core.metadata.IPage;
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.ruoyi.common.annotation.Anonymous;
@@ -47,9 +48,57 @@ public class PushMessageController extends BaseController
         LambdaQueryWrapper<PushMessage> queryWrapper = new LambdaQueryWrapper<>();
         queryWrapper.eq(PushMessage::getUserId,uid).orderByDesc(PushMessage::getTime);
         IPage<PushMessage> data = pushMessageService.page(page,queryWrapper);
+        // 按订单指针回填商家名/地址,供骑手消息卡片展示
+        pushMessageService.attachOrderInfo(data.getRecords());
         return success(data);
     }
 
+    /**
+     * 骑手、商家端获取未读消息数量(用于角标/红点)
+     */
+    @Anonymous
+    @Auth
+    @GetMapping("/getUnreadMessageCount")
+    public AjaxResult getUnreadMessageCount(@RequestHeader String token) {
+        JwtUtil jwtUtil = new JwtUtil();
+        String uid = jwtUtil.getusid(token);
+        LambdaQueryWrapper<PushMessage> queryWrapper = new LambdaQueryWrapper<>();
+        queryWrapper.eq(PushMessage::getUserId, uid).eq(PushMessage::getIsRead, 0);
+        return success(pushMessageService.count(queryWrapper));
+    }
+
+    /**
+     * 骑手、商家端单条消息置为已读
+     */
+    @Anonymous
+    @Auth
+    @GetMapping("/readMessage")
+    public AjaxResult readMessage(@RequestHeader String token, @RequestParam Long id) {
+        JwtUtil jwtUtil = new JwtUtil();
+        String uid = jwtUtil.getusid(token);
+        LambdaUpdateWrapper<PushMessage> updateWrapper = new LambdaUpdateWrapper<>();
+        updateWrapper.set(PushMessage::getIsRead, 1)
+                .eq(PushMessage::getId, id)
+                .eq(PushMessage::getUserId, uid);
+        return toAjax(pushMessageService.update(updateWrapper));
+    }
+
+    /**
+     * 骑手、商家端全部消息置为已读(清角标)
+     */
+    @Anonymous
+    @Auth
+    @GetMapping("/readAllMessage")
+    public AjaxResult readAllMessage(@RequestHeader String token) {
+        JwtUtil jwtUtil = new JwtUtil();
+        String uid = jwtUtil.getusid(token);
+        LambdaUpdateWrapper<PushMessage> updateWrapper = new LambdaUpdateWrapper<>();
+        updateWrapper.set(PushMessage::getIsRead, 1)
+                .eq(PushMessage::getUserId, uid)
+                .eq(PushMessage::getIsRead, 0);
+        return toAjax(pushMessageService.update(updateWrapper));
+    }
+
     /**
      * 查询推送消息列表
      */

+ 14 - 3
ruoyi-admin/src/main/resources/i18n/messages.properties

@@ -76,6 +76,8 @@ no.user.password.not.null=Mật khẩu không được để trống
 no.user.jcaptcha.error=CAPTCHA không đúng
 no.user.not.exist=Người dùng không tồn tại
 no.user.login.success=Đăng nhập thành công
+no.unifiedorder.param.invalid=Tham số truy vấn không hợp lệ
+no.unifiedorder.cursor.invalid=Con trỏ phân trang không hợp lệ
 no.user.password.error=Mật khẩu sai
 no.system.error=Lỗi hệ thống
 no.sms.send.success=Gửi SMS thành công
@@ -167,7 +169,7 @@ no.points.update.fail=Cập nhật ví điểm thất bại, vui lòng thử l
 no.points.use.fail=Khấu trừ điểm chưa được bật
 no.points.not.exist=Ví điểm của bạn không tồn tại
 no.message.push.merchant.ready.title=Thương gia đã chuẩn bị xong
-no.message.push.merchant.ready.content=Thương gia đã chuẩn bị xong, mã đơn hàng:
+no.message.push.merchant.ready.content=Thương gia đã chuẩn bị xong
 no.posorder.md.yh.mc.messag=优惠券:{};
 no.posorder.md.yh.jiner.messag=优惠抵扣:{};
 no.posorder.md.cx.mc.messag=促销:{};
@@ -185,6 +187,8 @@ no.oauth.token.invalid={0}登录凭证无效
 no.oauth.token.expired={0}登录凭证已过期
 no.oauth.audience.mismatch={0}凭证校验未通过
 no.oauth.verify.fail={0}登录校验失败:{1}
+no.oauth.device.confirm=请确认绑定本机已验证的手机号
+no.oauth.device.mismatch=设备确认凭证无效,请重新登录
 
 # 订单发票校验(010)
 no.invoice.choice.invalid=发票类型不合法:{0}
@@ -289,7 +293,7 @@ merchant.subaccount.access.denied=You cannot manage this subaccount
 merchant.subaccount.create.failed=Failed to create subaccount
 merchant.subaccount.update.failed=Failed to update subaccount
 merchant.subaccount.platform.managed=Subaccounts can only be managed from the dedicated merchant subaccount entry
-no.user.delivery.type.invalid=配送类型无效,仅支持美食外送或闪送
+no.user.delivery.type.invalid=配送类型无效,仅支持美食外送或快送
 no.user.delivery.type.not.enabled=当前账号未开通美食外送配送
 
 # Flash delivery
@@ -336,7 +340,7 @@ flash.delivery.tab.invalid=Invalid flash delivery list tab
 flash.delivery.coordinates.invalid=Latitude and longitude must be provided in pairs and within valid ranges
 address.access.denied=Address does not exist or is not accessible
 address.data.invalid=Contact, address, or coordinates are invalid
-flash.delivery.rider.type.not.enabled=当前账号未开通闪送配送
+flash.delivery.rider.type.not.enabled=当前账号未开通快送配送
 flash.delivery.rider.vehicle.not.match=The rider vehicle type does not match this order
 flash.delivery.rider.exclusive.conflict=The rider has an active exclusive delivery and cannot accept this order
 flash.delivery.edit.not.allowed=仅待接单且未分配骑手的订单可以修改
@@ -344,3 +348,10 @@ flash.delivery.order.version.invalid=请提供有效的订单版本
 flash.delivery.tip.additional.invalid=追加小费必须为正整数且不能超出金额范围
 flash.delivery.pay.type.invalid=Chiỉ hỗ trợ thanh toán bằng tiền mặt hoặc chuyển khoản ngoại tuyến
 flash.delivery.schedule.not.open=The scheduled pickup window has not started or has already ended
+
+##待支付订单更换支付方式(#661)
+no.pay.switch.order.invalid=Đơn hàng không tồn tại hoặc không thể thanh toán
+no.pay.switch.already.paid=Đơn hàng đã được thanh toán
+no.pay.switch.method.invalid=Phương thức thanh toán không hợp lệ
+no.pay.switch.offline.restricted=Tiền mặt / thanh toán khi nhận hàng chỉ dành cho đơn tự lấy hoặc dùng tại chỗ
+no.pay.switch.failed=Đổi phương thức thanh toán thất bại, vui lòng thử lại sau

+ 12 - 1
ruoyi-admin/src/main/resources/i18n/messages_en_US.properties

@@ -76,6 +76,8 @@ no.user.password.not.null=Password cannot be empty
 no.user.jcaptcha.error=Incorrect verification code
 no.user.not.exist=User does not exist
 no.user.login.success=Login successful
+no.unifiedorder.param.invalid=Invalid query parameters
+no.unifiedorder.cursor.invalid=Invalid pagination cursor
 no.user.password.error=Password error
 no.system.error=System error
 no.sms.send.success=SMS sent successfully
@@ -171,7 +173,7 @@ no.user.state.no.audit=Account status is not audited
 no.system.busy.try.again=System is busy, please try again later
 no.operation.interrupted.try.again=Operation was interrupted, please try again
 no.message.push.merchant.ready.title=Merchant has prepared the order
-no.message.push.merchant.ready.content=Merchant has prepared the order, Order ID:
+no.message.push.merchant.ready.content=Merchant has prepared the order
 no.posorder.md.yh.mc.messag=Coupon: {};
 no.posorder.md.yh.jiner.messag=Discount: {};
 no.posorder.md.cx.mc.messag=Promo: {};
@@ -189,6 +191,8 @@ no.oauth.token.invalid={0} login credential is invalid
 no.oauth.token.expired={0} login credential has expired
 no.oauth.audience.mismatch={0} credential verification failed
 no.oauth.verify.fail={0} login verification failed: {1}
+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.invoice.choice.invalid=Invalid invoice type: {0}
@@ -347,3 +351,10 @@ flash.delivery.order.version.invalid=Please provide a valid order version
 flash.delivery.tip.additional.invalid=Additional tip must be a positive integer within the supported amount range
 flash.delivery.pay.type.invalid=Payment method must be cash or offline transfer
 flash.delivery.schedule.not.open=The scheduled pickup window has not started or has already ended
+
+##待支付订单更换支付方式(#661)
+no.pay.switch.order.invalid=Order not found or not payable
+no.pay.switch.already.paid=Order already paid
+no.pay.switch.method.invalid=Invalid payment method
+no.pay.switch.offline.restricted=Cash / cash-on-delivery only for pickup or dine-in orders
+no.pay.switch.failed=Failed to change payment method, please try again later

+ 12 - 1
ruoyi-admin/src/main/resources/i18n/messages_th_TH.properties

@@ -76,6 +76,8 @@ no.user.password.not.null=รหัสผ่านต้องไม่ว่า
 no.user.jcaptcha.error=รหัสยืนยันไม่ถูกต้อง
 no.user.not.exist=ไม่มีผู้ใช้นี้
 no.user.login.success=เข้าสู่ระบบสำเร็จ
+no.unifiedorder.param.invalid=พາรາມິเตอร์ทີ่กາรค้นหາไม่ถูกต้อง
+no.unifiedorder.cursor.invalid=เคอร์เซอร์แบ่งหน้าไม่ถูกต้อງ
 no.user.password.error=รหัสผ่านไม่ถูกต้อง
 no.system.error=เกิดข้อผิดพลาดของระบบ
 no.sms.send.success=ส่ง SMS สำเร็จ
@@ -171,7 +173,7 @@ no.user.state.no.audit=สถานะบัญชียังไม่ได้
 no.system.busy.try.again=ระบบไม่ว่าง โปรดลองอีกครั้งภายหลัง
 no.operation.interrupted.try.again=การดำเนินการถูกขัดจังหวะ โปรดลองอีกครั้ง
 no.message.push.merchant.ready.title=ร้านค้าเตรียมอาหารเสร็จแล้ว
-no.message.push.merchant.ready.content=ร้านค้าเตรียมอาหารเสร็จแล้ว หมายเลขคำสั่งซื้อ:
+no.message.push.merchant.ready.content=ร้านค้าเตรียมอาหารเสร็จแล้ว
 no.posorder.md.yh.mc.messag=คูปอง: {};
 no.posorder.md.yh.jiner.messag=ส่วนลด: {};
 no.posorder.md.cx.mc.messag=โปรโมชัน: {};
@@ -189,6 +191,8 @@ no.oauth.token.invalid=ข้อมูลรับรองการเข้า
 no.oauth.token.expired=ข้อมูลรับรองการเข้าสู่ระบบ {0} หมดอายุแล้ว
 no.oauth.audience.mismatch=การตรวจสอบข้อมูลรับรอง {0} ไม่ผ่าน
 no.oauth.verify.fail=การตรวจสอบการเข้าสู่ระบบ {0} ไม่สำเร็จ: {1}
+no.oauth.device.confirm=โปรดยืนยันการผูกหมายเลขโทรศัพท์ที่ยืนยันบนอุปกรณ์นี้
+no.oauth.device.mismatch=การยืนยันอุปกรณ์ไม่ถูกต้อง โปรดเข้าสู่ระบบอีกครั้ง
 
 # ตรวจสอบใบกำกับภาษีของคำสั่งซื้อ (010)
 no.invoice.choice.invalid=ประเภทใบกำกับภาษีไม่ถูกต้อง: {0}
@@ -348,3 +352,10 @@ flash.delivery.order.version.invalid=กรุณาระบุเวอร์
 flash.delivery.tip.additional.invalid=ทิปเพิ่มเติมต้องเป็นจำนวนเต็มบวกและไม่เกินวงเงินที่รองรับ
 flash.delivery.pay.type.invalid=รองรับเฉพาะการชำระเงินสดหรือโอนแบบออฟไลน์
 flash.delivery.schedule.not.open=ยังไม่ถึงช่วงเวลานัดรับสินค้า หรือช่วงเวลาดังกล่าวสิ้นสุดแล้ว จึงยังรับออเดอร์นี้ไม่ได้
+
+##待支付订单更换支付方式(#661)
+no.pay.switch.order.invalid=ไม่พบคำสั่งซื้อหรือไม่สามารถชำระเงินได้
+no.pay.switch.already.paid=ชำระเงินคำสั่งซื้อแล้ว
+no.pay.switch.method.invalid=วิธีชำระเงินไม่ถูกต้อง
+no.pay.switch.offline.restricted=เงินสด / ชำระเงินปลายทางใช้ได้เฉพาะคำสั่งซื้อแบบรับสินค้าหรือทานที่ร้านเท่านั้น
+no.pay.switch.failed=เปลี่ยนวิธีชำระเงินไม่สำเร็จ กรุณาลองใหม่ภายหลัง

+ 12 - 1
ruoyi-admin/src/main/resources/i18n/messages_vi.properties

@@ -76,6 +76,8 @@ no.user.password.not.null=Mật khẩu không được để trống
 no.user.jcaptcha.error=CAPTCHA không đúng
 no.user.not.exist=Người dùng không tồn tại
 no.user.login.success=Đăng nhập thành công
+no.unifiedorder.param.invalid=Tham số truy vấn không hợp lệ
+no.unifiedorder.cursor.invalid=Con trỏ phân trang không hợp lệ
 no.user.password.error=Mật khẩu sai
 no.system.error=Lỗi hệ thống
 no.sms.send.success=Gửi SMS thành công
@@ -171,7 +173,7 @@ no.user.state.no.audit=Trạng thái tài khoản chưa được xét duyệt
 no.system.busy.try.again=Hệ thống đang bận, vui lòng thử lại sau
 no.operation.interrupted.try.again=Thao tác đã bị gián đoạn, vui lòng thử lại
 no.message.push.merchant.ready.title=Thương gia đã chuẩn bị xong
-no.message.push.merchant.ready.content=Thương gia đã chuẩn bị xong, mã đơn hàng:
+no.message.push.merchant.ready.content=Thương gia đã chuẩn bị xong
 no.posorder.md.yh.mc.messag=Phiếu giảm giá: {};
 no.posorder.md.yh.jiner.messag=Giảm giá: {};
 no.posorder.md.cx.mc.messag=Khuyến mãi: {};
@@ -189,6 +191,8 @@ no.oauth.token.invalid=Thông tin đăng nhập {0} không hợp lệ
 no.oauth.token.expired=Thông tin đăng nhập {0} đã hết hạn
 no.oauth.audience.mismatch=Xác minh thông tin {0} không thành công
 no.oauth.verify.fail=Xác minh đăng nhập {0} thất bại: {1}
+no.oauth.device.confirm=Vui lòng xác nhận liên kết số điện thoại đã xác minh trên thiết bị này
+no.oauth.device.mismatch=Xác nhận thiết bị không hợp lệ, vui lòng đăng nhập lại
 
 # Kiểm tra hóa đơn đơn hàng (010)
 no.invoice.choice.invalid=Loại hóa đơn không hợp lệ: {0}
@@ -347,3 +351,10 @@ flash.delivery.order.version.invalid=Vui lòng cung cấp phiên bản đơn hà
 flash.delivery.tip.additional.invalid=Tiền boa thêm phải là số nguyên dương trong phạm vi số tiền được hỗ trợ
 flash.delivery.pay.type.invalid=Chiỉ hỗ trợ thanh toán bằng tiền mặt hoặc chuyển khoản ngoại tuyến
 flash.delivery.schedule.not.open=Chưa đến hoặc đã hết thời gian nhận đơn theo lịch hẹn
+
+##待支付订单更换支付方式(#661)
+no.pay.switch.order.invalid=Đơn hàng không tồn tại hoặc không thể thanh toán
+no.pay.switch.already.paid=Đơn hàng đã được thanh toán
+no.pay.switch.method.invalid=Phương thức thanh toán không hợp lệ
+no.pay.switch.offline.restricted=Tiền mặt / thanh toán khi nhận hàng chỉ dành cho đơn tự lấy hoặc dùng tại chỗ
+no.pay.switch.failed=Đổi phương thức thanh toán thất bại, vui lòng thử lại sau

+ 20 - 9
ruoyi-admin/src/main/resources/i18n/messages_zh_CN.properties

@@ -76,6 +76,8 @@ no.user.password.not.null=密码不能为空
 no.user.jcaptcha.error=验证码不正确
 no.user.not.exist=用户不存在
 no.user.login.success=登录成功
+no.unifiedorder.param.invalid=查询参数不合法
+no.unifiedorder.cursor.invalid=分页游标不合法
 no.user.password.error=密码错误
 no.system.error=系统错误
 no.sms.send.success=短信发送成功
@@ -109,7 +111,7 @@ no.message.push.order.cancelled=订单已取消
 no.order.rider.accept.required=请等待骑手接单后再操作订单
 no.message.push.new.order=有新订单
 no.message.push.new.food.order=有新的外卖订单
-no.message.push.new.flash.order=有新的闪送订单
+no.message.push.new.flash.order=有新的快送订单
 no.message.push.recharge.success=充值成功
 no.message.push.payment.success=支付成功
 no.message.push.payment.fail=支付失败
@@ -171,7 +173,7 @@ no.user.state.no.audit=账号状态未审核
 no.system.busy.try.again=系统繁忙,请稍后重试
 no.operation.interrupted.try.again=操作被中断,请重试
 no.message.push.merchant.ready.title=商家已经出餐
-no.message.push.merchant.ready.content=商家已经出餐,订单号:
+no.message.push.merchant.ready.content=商家已经出餐
 no.posorder.md.yh.mc.messag=优惠券:{};
 no.posorder.md.yh.jiner.messag=优惠抵扣:{};
 no.posorder.md.cx.mc.messag=促销:{};
@@ -189,6 +191,8 @@ no.oauth.token.invalid={0}登录凭证无效
 no.oauth.token.expired={0}登录凭证已过期
 no.oauth.audience.mismatch={0}凭证校验未通过
 no.oauth.verify.fail={0}登录校验失败:{1}
+no.oauth.device.confirm=请确认绑定本机已验证的手机号
+no.oauth.device.mismatch=设备确认凭证无效,请重新登录
 
 # 订单发票校验(010)
 no.invoice.choice.invalid=发票类型不合法:{0}
@@ -293,19 +297,19 @@ merchant.subaccount.access.denied=无权管理该分管账号
 merchant.subaccount.create.failed=创建分管账号失败
 merchant.subaccount.update.failed=更新分管账号失败
 merchant.subaccount.platform.managed=分管账号只能通过专用入口管理
-no.user.delivery.type.invalid=配送类型无效,仅支持美食外送或闪送
+no.user.delivery.type.invalid=配送类型无效,仅支持美食外送或快送
 no.user.delivery.type.not.enabled=当前账号未开通美食外送配送
 
-# 闪送配送
+# 快送配送
 flash.delivery.request.required=请求数据不能为空
 flash.delivery.auth.required=请先登录
 flash.delivery.client.request.id.invalid=客户端请求号不能为空且不能超过64个字符
 flash.delivery.note.too.long=备注不能超过500个字符
-flash.delivery.service.type.invalid=不支持的闪送服务类型
-flash.delivery.service.unavailable=当前闪送服务暂不可用
+flash.delivery.service.type.invalid=不支持的快送服务类型
+flash.delivery.service.unavailable=当前快送服务暂不可用
 flash.delivery.address.invalid=联系人、地址或经纬度信息无效
 flash.delivery.address.same=取件地址和收件地址不能相同
-flash.delivery.order.not.found=闪送订单不存在或无权访问
+flash.delivery.order.not.found=快送订单不存在或无权访问
 flash.delivery.order.already.accepted=订单已被其他骑手接走
 flash.delivery.rider.required=只有骑手账号可以执行此操作
 flash.delivery.proof.required=请至少上传一张凭证图片
@@ -336,11 +340,11 @@ flash.delivery.vehicle.type.invalid=不支持的配送车型
 flash.delivery.item.invalid=物品数量、重量范围或规格信息不正确
 flash.delivery.tip.invalid=骑手小费必须是非负整数新台币金额
 flash.delivery.quote.changed=报价已变化,请重新确认最新费用
-flash.delivery.tab.invalid=闪送列表页签无效
+flash.delivery.tab.invalid=快送列表页签无效
 flash.delivery.coordinates.invalid=经纬度必须成对提供且范围有效
 address.access.denied=地址不存在或无权访问
 address.data.invalid=联系人、地址或经纬度信息无效
-flash.delivery.rider.type.not.enabled=当前账号未开通闪送配送
+flash.delivery.rider.type.not.enabled=当前账号未开通快送配送
 flash.delivery.rider.vehicle.not.match=骑手配送车型与该订单不匹配
 flash.delivery.rider.exclusive.conflict=骑手存在进行中的独占配送任务,暂时不能接此订单
 flash.delivery.edit.not.allowed=仅待接单且未分配骑手的订单可以修改
@@ -348,3 +352,10 @@ flash.delivery.order.version.invalid=请提供有效的订单版本
 flash.delivery.tip.additional.invalid=追加小费必须为正整数且不能超出金额范围
 flash.delivery.pay.type.invalid=支付方式仅支持现金或线下转账
 flash.delivery.schedule.not.open=预约取件时间未开始或已结束,暂不能接单
+
+##待支付订单更换支付方式(#661)
+no.pay.switch.order.invalid=订单不存在或当前不可支付
+no.pay.switch.already.paid=订单已支付,无法更换支付方式
+no.pay.switch.method.invalid=支付方式无效
+no.pay.switch.offline.restricted=仅自取/堂食订单可更换为现金或货到付款
+no.pay.switch.failed=更换支付方式失败,请稍后重试

+ 18 - 7
ruoyi-admin/src/main/resources/i18n/messages_zh_TW.properties

@@ -76,6 +76,8 @@ no.user.password.not.null=密碼不能為空
 no.user.jcaptcha.error=驗證碼不正確
 no.user.not.exist=用戶不存在
 no.user.login.success=登入成功
+no.unifiedorder.param.invalid=查詢參數不合法
+no.unifiedorder.cursor.invalid=分頁游標不合法
 no.user.password.error=密碼錯誤
 no.system.error=系統錯誤
 no.sms.send.success=短信發送成功
@@ -109,7 +111,7 @@ no.message.push.order.cancelled=訂單已取消
 no.order.rider.accept.required=請等待騎手接單後再操作訂單
 no.message.push.new.order=有新訂單
 no.message.push.new.food.order=有新的外送訂單
-no.message.push.new.flash.order=有新的閃送訂單
+no.message.push.new.flash.order=有新的快送訂單
 no.message.push.recharge.success=充值成功
 no.message.push.payment.success=支付成功
 no.message.push.payment.fail=支付失敗
@@ -171,7 +173,7 @@ no.user.state.no.audit=帳號狀態未審核
 no.system.busy.try.again=系統繁忙,請稍後重試
 no.operation.interrupted.try.again=操作被中斷,請重試
 no.message.push.merchant.ready.title=商家已經出餐
-no.message.push.merchant.ready.content=商家已經出餐,訂單號:
+no.message.push.merchant.ready.content=商家已經出餐
 no.posorder.md.yh.mc.messag=優惠券:{};
 no.posorder.md.yh.jiner.messag=優惠抵扣:{};
 no.posorder.md.cx.mc.messag=促銷:{};
@@ -189,6 +191,8 @@ no.oauth.token.invalid={0}登入憑證無效
 no.oauth.token.expired={0}登入憑證已過期
 no.oauth.audience.mismatch={0}憑證校驗未通過
 no.oauth.verify.fail={0}登入校驗失敗:{1}
+no.oauth.device.confirm=請確認綁定本機已驗證的手機號
+no.oauth.device.mismatch=設備確認憑證無效,請重新登入
 
 # 訂單發票校驗(010)
 no.invoice.choice.invalid=發票類型不合法:{0}
@@ -296,16 +300,16 @@ merchant.subaccount.platform.managed=分管帳號只能透過專用入口管理
 no.user.delivery.type.invalid=配送類型無效,僅支持美食外送或闁送
 no.user.delivery.type.not.enabled=當前賬號未開通美食外送配送
 
-# 閃送配送
+# 快送配送
 flash.delivery.request.required=請求資料不能為空
 flash.delivery.auth.required=請先登入
 flash.delivery.client.request.id.invalid=客戶端請求號不能為空且不能超過64個字元
 flash.delivery.note.too.long=備註不能超過500個字元
-flash.delivery.service.type.invalid=不支援的閃送服務類型
-flash.delivery.service.unavailable=目前閃送服務暫不可用
+flash.delivery.service.type.invalid=不支援的快送服務類型
+flash.delivery.service.unavailable=目前快送服務暫不可用
 flash.delivery.address.invalid=聯絡人、地址或經緯度資訊無效
 flash.delivery.address.same=取件地址和收件地址不能相同
-flash.delivery.order.not.found=閃送訂單不存在或無權存取
+flash.delivery.order.not.found=快送訂單不存在或無權存取
 flash.delivery.order.already.accepted=訂單已被其他騎手接走
 flash.delivery.rider.required=只有騎手帳號可以執行此操作
 flash.delivery.proof.required=請至少上傳一張憑證圖片
@@ -336,7 +340,7 @@ flash.delivery.vehicle.type.invalid=不支援的配送車型
 flash.delivery.item.invalid=物品數量、重量範圍或規格資訊不正確
 flash.delivery.tip.invalid=騎手小費必須是非負整數新台幣金額
 flash.delivery.quote.changed=報價已變更,請重新確認最新費用
-flash.delivery.tab.invalid=閃送列表頁籤無效
+flash.delivery.tab.invalid=快送列表頁籤無效
 flash.delivery.coordinates.invalid=經緯度必須成對提供且範圍有效
 address.access.denied=地址不存在或無權存取
 address.data.invalid=聯絡人、地址或經緯度資訊無效
@@ -348,3 +352,10 @@ flash.delivery.order.version.invalid=請提供有效的訂單版本
 flash.delivery.tip.additional.invalid=追加小費必須為正整數且不能超出金額範圍
 flash.delivery.pay.type.invalid=支付方式僅支援現金或線下轉帳
 flash.delivery.schedule.not.open=預約取件時間未開始或已結束,暫不能接單
+
+##待支付订单更换支付方式(#661)
+no.pay.switch.order.invalid=訂單不存在或當前不可支付
+no.pay.switch.already.paid=訂單已付款,無法更換支付方式
+no.pay.switch.method.invalid=支付方式無效
+no.pay.switch.offline.restricted=僅自取/堂食訂單可更換為現金或貨到付款
+no.pay.switch.failed=更換支付方式失敗,請稍後重試

+ 68 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryApplicationServiceTest.java

@@ -49,6 +49,7 @@ import java.util.Calendar;
 
 import static com.ruoyi.system.domain.flash.FlashDeliveryStatus.ACCEPTED;
 import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
 import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
 import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertNotNull;
@@ -1910,6 +1911,73 @@ class FlashDeliveryApplicationServiceTest {
         return input;
     }
 
+    @Test
+    void generateOrderNoUsesTimestampAndSequenceFormat() {
+        String first = FlashDeliveryApplicationService.generateOrderNo();
+        String second = FlashDeliveryApplicationService.generateOrderNo();
+
+        assertTrue(isFlashOrderNo(first), "应为 98 前缀 17 位纯数字,实际: " + first);
+        assertTrue(isFlashOrderNo(second), "应为 98 前缀 17 位纯数字,实际: " + second);
+        assertNotEquals(first, second, "同毫秒连续生成应由序列位区分,不得同号");
+    }
+
+    @Test
+    void createRegeneratesOrderNoWhenUniqueKeyConflicts() {
+        Fixture fixture = new Fixture();
+        when(fixture.pricingMapper.selectAtTime(anyString(), anyInt())).thenReturn(List.of(pricing()));
+        when(fixture.routeService.calculate(any(), any(), anyInt())).thenReturn(new RouteDistance(3200, 600, "ROUTE"));
+        when(fixture.userMapper.selectOrdinaryUserIdsByNormalizedPhone(anyString())).thenReturn(List.of());
+        org.springframework.dao.DataIntegrityViolationException orderNoConflict =
+                new org.springframework.dao.DataIntegrityViolationException("insert failed",
+                        new java.sql.SQLException(
+                                "Duplicate entry '98001' for key 'flash_delivery_order.uk_flash_order_no'"));
+        // 捕获器拿到的是同一对象引用,重试改单号后两次取值相同;改为每次调用时记录当时单号
+        java.util.List<String> attempted = new java.util.ArrayList<>();
+        doAnswer(invocation -> {
+            FlashDeliveryOrder order = invocation.getArgument(0);
+            attempted.add(order.getOrderNo());
+            if (attempted.size() == 1) throw orderNoConflict;
+            order.setId(99L);
+            return 1;
+        }).when(fixture.orderMapper).insert(any(FlashDeliveryOrder.class));
+
+        fixture.service.create(7L, createRequest());
+
+        verify(fixture.orderMapper, times(2)).insert(any(FlashDeliveryOrder.class));
+        assertEquals(2, attempted.size());
+        assertNotEquals(attempted.get(0), attempted.get(1), "撞号重试后应换新单号");
+        assertTrue(isFlashOrderNo(attempted.get(1)), "重试后仍应为新格式单号");
+    }
+
+    @Test
+    void createKeepsIdempotencyFallbackForNonOrderNoConflicts() {
+        Fixture fixture = new Fixture();
+        when(fixture.pricingMapper.selectAtTime(anyString(), anyInt())).thenReturn(List.of(pricing()));
+        when(fixture.routeService.calculate(any(), any(), anyInt())).thenReturn(new RouteDistance(3200, 600, "ROUTE"));
+        when(fixture.userMapper.selectOrdinaryUserIdsByNormalizedPhone(anyString())).thenReturn(List.of());
+        org.springframework.dao.DataIntegrityViolationException idempotentConflict =
+                new org.springframework.dao.DataIntegrityViolationException("insert failed",
+                        new java.sql.SQLException(
+                                "Duplicate entry '7-req-1' for key 'flash_delivery_order.uk_user_request'"));
+        doThrow(idempotentConflict).when(fixture.orderMapper).insert(any(FlashDeliveryOrder.class));
+        // 前置幂等查询第一次放行(null),插入冲突后 catch 内的复查返回并发已建订单
+        FlashDeliveryOrder concurrent = new FlashDeliveryOrder();
+        concurrent.setId(55L);
+        concurrent.setOrderNo("981789000000011");
+        when(fixture.orderMapper.selectByUserRequestId(eq(7L), anyString())).thenReturn(null, concurrent);
+
+        var detail = fixture.service.create(7L, createRequest());
+
+        assertEquals(55L, detail.getId());
+        verify(fixture.orderMapper, times(1)).insert(any(FlashDeliveryOrder.class));
+    }
+
+    /** 闪送新格式单号:98 开头、17 位纯数字。 */
+    private static boolean isFlashOrderNo(String orderNo) {
+        return orderNo != null && orderNo.startsWith("98") && orderNo.length() == 17
+                && orderNo.chars().allMatch(Character::isDigit);
+    }
+
     private static class Fixture {
         final FlashDeliveryOrderMapper orderMapper = mock(FlashDeliveryOrderMapper.class);
         final FlashDeliveryPricingMapper pricingMapper = mock(FlashDeliveryPricingMapper.class);

+ 70 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/omgpay/OmgPaymentNotifyServiceTest.java

@@ -3,31 +3,64 @@ package com.ruoyi.app.omgpay;
 import com.ruoyi.app.order.DeliveryOrderNotificationService;
 import com.ruoyi.app.omgpay.dto.OmgNotifyField;
 import com.ruoyi.app.omgpay.dto.OmgNotifyRequest;
+import com.ruoyi.app.utils.event.PushEventService;
+import com.ruoyi.common.utils.spring.SpringUtils;
+import com.ruoyi.system.domain.InfoUser;
 import com.ruoyi.system.domain.PosOrder;
 import com.ruoyi.system.omgpay.domain.OmgPaymentAttempt;
 import com.ruoyi.system.omgpay.domain.OmgPaymentOrderSnapshot;
 import com.ruoyi.system.omgpay.service.IOmgPaymentAttemptService;
+import com.ruoyi.system.service.IInfoUserService;
 import com.ruoyi.system.service.IPosOrderService;
+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.springframework.beans.factory.config.ConfigurableListableBeanFactory;
+import org.springframework.beans.factory.support.DefaultListableBeanFactory;
+import org.springframework.context.support.StaticMessageSource;
 import org.springframework.test.util.ReflectionTestUtils;
 
 import java.util.ArrayList;
 import java.math.BigDecimal;
 import java.util.LinkedHashMap;
 import java.util.List;
+import java.util.Locale;
 import java.util.Map;
 
 import static org.junit.jupiter.api.Assertions.*;
 import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
 import static org.mockito.Mockito.*;
 
 class OmgPaymentNotifyServiceTest {
     private static final String HASH_KEY = "5294y06JbISpM5x9";
     private static final String HASH_IV = "v77hoKGq4kWxNNIS";
 
+    private static ConfigurableListableBeanFactory originalBeanFactory;
+
     private IOmgPaymentAttemptService attempts;
     private OmgPaymentNotifyService service;
+
+    @BeforeAll
+    static void initializeBeanFactory() {
+        // 支付成功推送按收件人用户语言解析文案(zh_TW 为无上下文时的默认语言),纯单测下需手动挂 messageSource
+        originalBeanFactory = (ConfigurableListableBeanFactory)
+                ReflectionTestUtils.getField(SpringUtils.class, "beanFactory");
+        DefaultListableBeanFactory beanFactory = new DefaultListableBeanFactory();
+        StaticMessageSource messageSource = new StaticMessageSource();
+        Locale defaultLocale = new Locale("zh", "TW");
+        messageSource.addMessage("no.message.push.message", defaultLocale, "消息");
+        messageSource.addMessage("no.message.push.payment.success", defaultLocale, "支付成功");
+        beanFactory.registerSingleton("messageSource", messageSource);
+        new SpringUtils().postProcessBeanFactory(beanFactory);
+    }
+
+    @AfterAll
+    static void restoreBeanFactory() {
+        new SpringUtils().postProcessBeanFactory(originalBeanFactory);
+    }
     private OmgPaymentAttempt attempt;
 
     @BeforeEach
@@ -145,6 +178,43 @@ class OmgPaymentNotifyServiceTest {
         verify(notifications).notifyOrderAvailable(paidOrder);
     }
 
+    @Test
+    void firstPaidCallbackPushesPaymentSuccessToOrderUser() {
+        IInfoUserService users = mock(IInfoUserService.class);
+        PushEventService pushEvents = mock(PushEventService.class);
+        ReflectionTestUtils.setField(service, "infoUserService", users);
+        ReflectionTestUtils.setField(service, "pushEventService", pushEvents);
+        OmgPaymentOrderSnapshot order = order(0L, 0L);
+        order.setUserId(9L);
+        when(attempts.selectOrderForUpdate("DD-1")).thenReturn(order);
+        when(attempts.markPaid(any())).thenReturn(1);
+        when(attempts.markOrderPaid("DD-1")).thenReturn(1);
+        InfoUser user = new InfoUser();
+        user.setUserId(9L); // cid 为空 → 跳过 iOS 推送外呼,仅入消息中心
+        when(users.getById(9L)).thenReturn(user);
+
+        assertTrue(service.process(signedRequest(1, 0, 100)));
+
+        verify(pushEvents).PublisherEvent(eq(9L), anyString(), anyString(), anyString());
+    }
+
+    @Test
+    void duplicatePaidCallbackDoesNotPushPaymentSuccessAgain() {
+        IInfoUserService users = mock(IInfoUserService.class);
+        PushEventService pushEvents = mock(PushEventService.class);
+        ReflectionTestUtils.setField(service, "infoUserService", users);
+        ReflectionTestUtils.setField(service, "pushEventService", pushEvents);
+        attempt.setAttemptStatus(1); // 已支付 attempt:重复回调走 duplicate 分支
+        when(attempts.selectOrderForUpdate("DD-1")).thenReturn(order(0L, 1L));
+        InfoUser user = new InfoUser();
+        user.setUserId(9L);
+        when(users.getById(9L)).thenReturn(user);
+
+        assertTrue(service.process(signedRequest(1, 0, 100)));
+
+        verify(pushEvents, never()).PublisherEvent(any(), anyString(), anyString(), anyString());
+    }
+
     @Test
     void rejectsSignatureMerchantAndAmountMismatchWithoutMutation() {
         OmgNotifyRequest badSignature = signedRequest(1, 0, 100);

+ 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

+ 237 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/pay/PayMethodSwitchServiceTest.java

@@ -0,0 +1,237 @@
+package com.ruoyi.app.pay;
+
+import com.baomidou.mybatisplus.core.conditions.Wrapper;
+import com.ruoyi.app.omgpay.OmgPaymentCreateOutcome;
+import com.ruoyi.app.omgpay.OmgPaymentCreateService;
+import com.ruoyi.app.omgpay.OmgPaymentRetryService;
+import com.ruoyi.app.omgpay.OmgPaymentMethod;
+import com.ruoyi.app.omgpay.dto.OmgCreatePaymentResponse;
+import com.ruoyi.app.pay.dto.PayMethodSwitchRequest;
+import com.ruoyi.common.core.domain.AjaxResult;
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.common.utils.spring.SpringUtils;
+import com.ruoyi.system.domain.InfoUser;
+import com.ruoyi.system.domain.PosOrder;
+import com.ruoyi.system.service.IInfoUserService;
+import com.ruoyi.system.service.IPosOrderService;
+import com.ruoyi.system.utils.OrderLogHelper;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.mockito.InOrder;
+import org.springframework.beans.factory.config.ConfigurableListableBeanFactory;
+import org.springframework.context.support.StaticMessageSource;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import java.util.Map;
+
+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.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.inOrder;
+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;
+
+/**
+ * 待支付订单更换支付方式(#661)服务层测试。
+ * 覆盖:线下方式回写与日志、外送单拒线下、跨渠道委派与先回写后拉起的顺序、
+ * 同方式不重复回写、已支付/非法方式/非本人单/多门店子单拒绝。
+ */
+class PayMethodSwitchServiceTest {
+
+    private static ConfigurableListableBeanFactory originalBeanFactory;
+
+    @BeforeAll
+    static void initializeMessages() {
+        originalBeanFactory = (ConfigurableListableBeanFactory)
+                ReflectionTestUtils.getField(SpringUtils.class, "beanFactory");
+        // MessageUtils 取 key 本身,断言直接比对 i18n key
+        StaticMessageSource messageSource = new StaticMessageSource();
+        messageSource.setUseCodeAsDefaultMessage(true);
+        org.springframework.beans.factory.support.DefaultListableBeanFactory beanFactory =
+                new org.springframework.beans.factory.support.DefaultListableBeanFactory();
+        beanFactory.registerSingleton("messageSource", messageSource);
+        new SpringUtils().postProcessBeanFactory(beanFactory);
+    }
+
+    @AfterAll
+    static void restoreMessages() {
+        new SpringUtils().postProcessBeanFactory(originalBeanFactory);
+    }
+
+    /** 待支付 LINE Pay 自取单:ddId=dd-1、userId=7、type=1、payType=3 */
+    private static PosOrder baseOrder() {
+        PosOrder order = new PosOrder();
+        order.setDdId("dd-1");
+        order.setParentDdId("dd-1");
+        order.setUserId(7L);
+        order.setState(0L);
+        order.setPayStatus(0L);
+        order.setAmount(90);
+        order.setType(1L);
+        order.setPayType("3");
+        return order;
+    }
+
+    private static PayMethodSwitchRequest request(String orderId, String method) {
+        PayMethodSwitchRequest request = new PayMethodSwitchRequest();
+        request.setOrderId(orderId);
+        request.setPaymentMethod(method);
+        return request;
+    }
+
+    @Test
+    void switchLinePayOrderToOfflineUpdatesPayTypeWritesLogAndSkipsPayment() {
+        IPosOrderService orderService = mock(IPosOrderService.class);
+        OmgPaymentRetryService omgPaymentRetryService = mock(OmgPaymentRetryService.class);
+        LinePayService linePayService = mock(LinePayService.class);
+        OrderLogHelper orderLogHelper = mock(OrderLogHelper.class);
+        IInfoUserService infoUserService = mock(IInfoUserService.class);
+        InfoUser user = new InfoUser();
+        user.setUserId(7L);
+        user.setNickName("小明");
+        when(infoUserService.getById(7L)).thenReturn(user);
+        PosOrder order = baseOrder();
+        when(orderService.getOne(any(Wrapper.class))).thenReturn(order);
+        when(orderService.updateById(any(PosOrder.class))).thenReturn(true);
+        PayMethodSwitchService service = new PayMethodSwitchService(orderService,
+                omgPaymentRetryService, linePayService, orderLogHelper, infoUserService);
+
+        AjaxResult result = service.switchMethod(7L, request("dd-1", "1"));
+
+        ArgumentCaptor<PosOrder> saved = ArgumentCaptor.forClass(PosOrder.class);
+        verify(orderService).updateById(saved.capture());
+        assertEquals("1", saved.getValue().getPayType(), "应回写为目标方式货到付款");
+        verify(orderLogHelper).log(eq("dd-1"), eq(4), eq(7L), eq("小明"), anyString());
+        verify(linePayService, never()).create(any(), anyString());
+        assertEquals("1", ((Map<?, ?>) result.get("data")).get("payType"));
+        assertNull(((Map<?, ?>) result.get("data")).get("payParams"), "到付无支付环节,payParams 应为空");
+    }
+
+    @Test
+    void switchDeliveryOrderToCashIsRejected() {
+        IPosOrderService orderService = mock(IPosOrderService.class);
+        PosOrder order = baseOrder();
+        order.setType(0L);
+        when(orderService.getOne(any(Wrapper.class))).thenReturn(order);
+        PayMethodSwitchService service = new PayMethodSwitchService(orderService,
+                mock(OmgPaymentRetryService.class), mock(LinePayService.class),
+                mock(OrderLogHelper.class), mock(IInfoUserService.class));
+
+        ServiceException exception = assertThrows(ServiceException.class,
+                () -> service.switchMethod(7L, request("dd-1", "4")));
+        assertEquals("no.pay.switch.offline.restricted", exception.getMessage());
+        verify(orderService, never()).updateById(any(PosOrder.class));
+    }
+
+    @Test
+    @SuppressWarnings({"unchecked", "rawtypes"})
+    void switchToCreditCardRewritesPayTypeBeforeOmgCreate() {
+        IPosOrderService orderService = mock(IPosOrderService.class);
+        OmgPaymentRetryService omgPaymentRetryService = mock(OmgPaymentRetryService.class);
+        when(omgPaymentRetryService.retry(eq(7L), eq("dd-1"), eq(OmgPaymentMethod.CREDIT)))
+                .thenReturn(new OmgPaymentCreateOutcome(mock(OmgCreatePaymentResponse.class), 1L, "dd-1", 7L, 5L, 90, "abcde***fghi"));
+        PosOrder order = baseOrder();
+        when(orderService.getOne(any(Wrapper.class))).thenReturn(order);
+        when(orderService.updateById(any(PosOrder.class))).thenReturn(true);
+        PayMethodSwitchService service = new PayMethodSwitchService(orderService,
+                omgPaymentRetryService, mock(LinePayService.class), mock(OrderLogHelper.class),
+                mock(IInfoUserService.class));
+
+        AjaxResult result = service.switchMethod(7L, request("dd-1", "2"));
+
+        // 顺序约束:先回写 payType 再拉起渠道;走 retry 路径:旧 Apple Pay 尝试仍激活时
+        // 替换重建而非 PAYMENT_ATTEMPT_EXISTS 死锁(组内 5->2 切换线上事故场景)
+        InOrder inOrder = inOrder(orderService, omgPaymentRetryService);
+        inOrder.verify(orderService).updateById(any(PosOrder.class));
+        inOrder.verify(omgPaymentRetryService).retry(eq(7L), eq("dd-1"), eq(OmgPaymentMethod.CREDIT));
+        Map<?, ?> data = (Map<?, ?>) result.get("data");
+        assertEquals("2", data.get("payType"));
+        assertNotNull(data.get("payParams"), "在线方式应带渠道支付参数");
+    }
+
+    @Test
+    void sameMethodSkipsRewriteButStillDelegates() {
+        IPosOrderService orderService = mock(IPosOrderService.class);
+        OmgPaymentRetryService omgPaymentRetryService = mock(OmgPaymentRetryService.class);
+        when(omgPaymentRetryService.retry(any(), anyString(), any(OmgPaymentMethod.class)))
+                .thenReturn(new OmgPaymentCreateOutcome(mock(OmgCreatePaymentResponse.class), 1L, "dd-1", 7L, 5L, 90, "abcde***fghi"));
+        PosOrder order = baseOrder();
+        order.setPayType("2");
+        when(orderService.getOne(any(Wrapper.class))).thenReturn(order);
+        PayMethodSwitchService service = new PayMethodSwitchService(orderService,
+                omgPaymentRetryService, mock(LinePayService.class), mock(OrderLogHelper.class),
+                mock(IInfoUserService.class));
+
+        service.switchMethod(7L, request("dd-1", "2"));
+
+        verify(orderService, never()).updateById(any(PosOrder.class));
+        verify(omgPaymentRetryService, times(1)).retry(eq(7L), eq("dd-1"), eq(OmgPaymentMethod.CREDIT));
+    }
+
+    @Test
+    void alreadyPaidOrderIsRejected() {
+        IPosOrderService orderService = mock(IPosOrderService.class);
+        PosOrder order = baseOrder();
+        order.setPayStatus(1L);
+        when(orderService.getOne(any(Wrapper.class))).thenReturn(order);
+        PayMethodSwitchService service = new PayMethodSwitchService(orderService,
+                mock(OmgPaymentRetryService.class), mock(LinePayService.class),
+                mock(OrderLogHelper.class), mock(IInfoUserService.class));
+
+        ServiceException exception = assertThrows(ServiceException.class,
+                () -> service.switchMethod(7L, request("dd-1", "2")));
+        assertEquals("no.pay.switch.already.paid", exception.getMessage());
+    }
+
+    @Test
+    void unknownMethodIsRejectedBeforeOrderLookup() {
+        IPosOrderService orderService = mock(IPosOrderService.class);
+        PayMethodSwitchService service = new PayMethodSwitchService(orderService,
+                mock(OmgPaymentRetryService.class), mock(LinePayService.class),
+                mock(OrderLogHelper.class), mock(IInfoUserService.class));
+
+        ServiceException exception = assertThrows(ServiceException.class,
+                () -> service.switchMethod(7L, request("dd-1", "9")));
+        assertEquals("no.pay.switch.method.invalid", exception.getMessage());
+        verify(orderService, never()).getOne(any(Wrapper.class));
+    }
+
+    @Test
+    void orderOfAnotherUserIsRejected() {
+        IPosOrderService orderService = mock(IPosOrderService.class);
+        PosOrder order = baseOrder();
+        order.setUserId(8L);
+        when(orderService.getOne(any(Wrapper.class))).thenReturn(order);
+        PayMethodSwitchService service = new PayMethodSwitchService(orderService,
+                mock(OmgPaymentRetryService.class), mock(LinePayService.class),
+                mock(OrderLogHelper.class), mock(IInfoUserService.class));
+
+        ServiceException exception = assertThrows(ServiceException.class,
+                () -> service.switchMethod(7L, request("dd-1", "2")));
+        assertEquals("no.pay.switch.order.invalid", exception.getMessage());
+    }
+
+    @Test
+    void multiStoreSubOrderIsRejected() {
+        IPosOrderService orderService = mock(IPosOrderService.class);
+        PosOrder order = baseOrder();
+        order.setParentDdId("dd-parent");
+        when(orderService.getOne(any(Wrapper.class))).thenReturn(order);
+        PayMethodSwitchService service = new PayMethodSwitchService(orderService,
+                mock(OmgPaymentRetryService.class), mock(LinePayService.class),
+                mock(OrderLogHelper.class), mock(IInfoUserService.class));
+
+        ServiceException exception = assertThrows(ServiceException.class,
+                () -> service.switchMethod(7L, request("dd-1", "2")));
+        assertEquals("no.pay.switch.order.invalid", exception.getMessage());
+    }
+}

+ 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;
+    }
+}

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

@@ -0,0 +1,393 @@
+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");
+        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.getuser("0987654321")).thenReturn(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.getuser("0999888777")).thenReturn(null, 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.getuser("0987654321")).thenReturn(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.getuser("0987654321")).thenReturn(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.getuser("0987654321")).thenReturn(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.getCacheObject("0987654321")).thenReturn(null);
+        when(infoUserOauthMapper.selectOne(any())).thenReturn(null);
+        InfoUser existing = user(100L, "0987654321", "0");
+        when(infoUserService.getuser("0987654321")).thenReturn(existing);
+        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())));
+    }
+}

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

@@ -0,0 +1,161 @@
+package com.ruoyi.app.user;
+
+import com.ruoyi.app.user.service.DeviceTrustService;
+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);
+
+        RequestContextHolder.setRequestAttributes(
+                new ServletRequestAttributes(new MockHttpServletRequest()));
+
+        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, "appRedirect", "com.test.app://oauthLogin");
+
+        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("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("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.setStatus("0");
+        when(infoUserService.getOne(any())).thenReturn(u);
+        when(deviceMapper.selectById("dev-1")).thenReturn(null);
+
+        controller.callback("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));
+    }
+}

+ 143 - 16
ruoyi-admin/src/test/java/com/ruoyi/app/user/MerchantSessionAuthAnnotationTest.java

@@ -6,50 +6,177 @@ import com.ruoyi.app.mendian.PosFoodController;
 import com.ruoyi.app.mendian.PosStoreController;
 import com.ruoyi.app.order.PosOrderController;
 import com.ruoyi.app.order.PosOrderShOprateController;
+import com.ruoyi.common.constant.CacheConstants;
+import com.ruoyi.common.core.domain.AjaxResult;
+import com.ruoyi.common.core.domain.model.LoginUserDto;
+import com.ruoyi.common.core.redis.RedisCache;
 import com.ruoyi.system.utils.Auth;
+import com.ruoyi.system.utils.AuthAspect;
+import com.ruoyi.system.utils.AuthContext;
+import com.ruoyi.system.utils.JwtUtil;
+import jakarta.servlet.http.HttpServletRequest;
+import org.aspectj.lang.ProceedingJoinPoint;
+import org.aspectj.lang.reflect.MethodSignature;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
 import org.junit.jupiter.api.Test;
+import org.mockito.Mockito;
+import com.ruoyi.common.utils.spring.SpringUtils;
+import org.springframework.beans.factory.config.ConfigurableListableBeanFactory;
+import org.springframework.beans.factory.support.DefaultListableBeanFactory;
+import org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.test.util.ReflectionTestUtils;
 
 import java.lang.reflect.Method;
 import java.util.Arrays;
 
+import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertNotNull;
-import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.assertSame;
+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.when;
 
+/**
+ * 会话校验语义测试(单设备登录,2026-09-22):会话校验已全局化,
+ * 本测试守护两件事——商家关键端点仍声明 @Auth;切面对「新格式会话被删(被顶号/登出)」
+ * 的 token 返回 401、对历史无冒号 jti 的旧 token 放行。
+ */
 class MerchantSessionAuthAnnotationTest {
 
+    private static ConfigurableListableBeanFactory originalBeanFactory;
+
+    @BeforeAll
+    static void installRedisBean() {
+        originalBeanFactory = (ConfigurableListableBeanFactory)
+                ReflectionTestUtils.getField(SpringUtils.class, "beanFactory");
+        DefaultListableBeanFactory beanFactory = new DefaultListableBeanFactory();
+        beanFactory.registerSingleton("redisCache", mock(RedisCache.class));
+        new SpringUtils().postProcessBeanFactory(beanFactory);
+    }
+
+    @AfterAll
+    static void restoreBeanFactory() {
+        new SpringUtils().postProcessBeanFactory(originalBeanFactory);
+    }
+
     @Test
-    void merchantEndpointsRequireServerSessionValidation() {
-        assertSessionAuth(MerchantSubaccountController.class,
+    void merchantEndpointsDeclareAuth() {
+        assertAuth(MerchantSubaccountController.class,
                 "list", "create", "update", "updatePassword", "updateStatus");
-        assertSessionAuth(InfoUserController.class, "merchantLogout");
-        assertSessionAuth(FoodSpecController.class,
+        assertAuth(InfoUserController.class, "merchantLogout");
+        assertAuth(FoodSpecController.class,
                 "foodSpecPageList", "saveFoodSpec", "getSpecs", "deleteFoodSpec",
                 "getAvailableSpecsList", "changeOpen", "changeSpecValueOpen");
-        assertSessionAuth(PosFenleiController.class, "delefenlei", "addfenlei");
-        assertSessionAuth(PosFoodController.class,
+        assertAuth(PosFenleiController.class, "delefenlei", "addfenlei");
+        assertAuth(PosFoodController.class,
                 "delefood", "changerecommend", "setposfood", "getidlist", "getFoodPageList");
-        assertSessionAuth(PosStoreController.class,
+        assertAuth(PosStoreController.class,
                 "delemendian", "storelistlist", "getmystorelist", "addmendian",
                 "getMdBusinessHours", "saveMdBusinessHours");
-        assertSessionAuth(PosOrderController.class,
+        assertAuth(PosOrderController.class,
                 "setorderuzt", "getorderxq", "getstoreorderlist");
-        assertSessionAuth(PosOrderShOprateController.class,
+        assertAuth(PosOrderShOprateController.class,
                 "getInvoice", "invalidInvoice", "createOrder", "acceptOrder", "dispatchOrder",
                 "completeOrder", "confirmCashPayment", "cancelOrder", "orderList", "orderDetail");
     }
 
-    private void assertSessionAuth(Class<?> controllerType, String... methodNames) {
+    private void assertAuth(Class<?> controllerType, String... methodNames) {
         for (String methodName : methodNames) {
             Method method = Arrays.stream(controllerType.getDeclaredMethods())
                     .filter(candidate -> candidate.getName().equals(methodName))
                     .findFirst()
                     .orElseThrow(() -> new AssertionError(controllerType.getSimpleName()
                             + "." + methodName + " not found"));
-            Auth auth = method.getAnnotation(Auth.class);
-            assertNotNull(auth, controllerType.getSimpleName() + "." + methodName
-                    + " must declare @Auth");
-            assertTrue(auth.session(), controllerType.getSimpleName() + "." + methodName
-                    + " must validate the Redis session");
+            assertNotNull(method.getAnnotation(Auth.class), controllerType.getSimpleName()
+                    + "." + methodName + " must declare @Auth");
+        }
+    }
+
+    // ==================== AuthAspect 会话校验行为 ====================
+
+    /** 供切面反射获取 @Auth 注解的样例方法 */
+    static class DummyEndpoint {
+        @Auth
+        public AjaxResult endpoint() {
+            return AjaxResult.success("ok");
         }
     }
+
+    /** 签发新格式会话 token(jti 含冒号、已写 Redis) */
+    private String sessionToken() {
+        LoginUserDto dto = new LoginUserDto();
+        dto.setUserId(7L);
+        dto.setUserName("user7");
+        return JwtUtil.setToken(CacheConstants.USER_TOKEN_KEY, dto);
+    }
+
+    private MockHttpServletRequest requestWithToken(String token) {
+        MockHttpServletRequest request = new MockHttpServletRequest();
+        request.addHeader("token", token);
+        return request;
+    }
+
+    private AuthAspect aspectBoundTo(HttpServletRequest request, boolean sessionExists) {
+        RedisCache redisCache = mock(RedisCache.class);
+        when(redisCache.hasKey(anyString())).thenReturn(sessionExists);
+        AuthAspect aspect = new AuthAspect(redisCache);
+        ReflectionTestUtils.setField(aspect, "request", request);
+        return aspect;
+    }
+
+    private ProceedingJoinPoint joinPointOnDummy() throws Throwable {
+        ProceedingJoinPoint joinPoint = mock(ProceedingJoinPoint.class);
+        MethodSignature signature = mock(MethodSignature.class);
+        Method method = DummyEndpoint.class.getDeclaredMethod("endpoint");
+        Mockito.when(signature.getMethod()).thenReturn(method);
+        Mockito.when(joinPoint.getSignature()).thenReturn(signature);
+        return joinPoint;
+    }
+
+    @Test
+    void deletedSessionIsRejectedWith401() throws Throwable {
+        // hasKey=false:会话已被登录踢键/登出删除
+        MockHttpServletRequest request = requestWithToken(sessionToken());
+        AuthAspect aspect = aspectBoundTo(request, false);
+
+        Object result = aspect.around(joinPointOnDummy());
+
+        assertEquals(401, ((AjaxResult) result).get("code"), "会话被删(被顶号/登出)应返回 401");
+    }
+
+    @Test
+    void liveSessionProceedsAndExposesTrustedIdentity() throws Throwable {
+        MockHttpServletRequest request = requestWithToken(sessionToken());
+        AuthAspect aspect = aspectBoundTo(request, true);
+        ProceedingJoinPoint joinPoint = joinPointOnDummy();
+        Object expected = new Object();
+        Mockito.when(joinPoint.proceed()).thenReturn(expected);
+
+        Object actual = aspect.around(joinPoint);
+
+        assertSame(expected, actual);
+        assertEquals(7L, request.getAttribute(AuthContext.USER_ID_ATTRIBUTE),
+                "切面应把可信 userId 写入请求属性供 AuthContext 读取");
+        assertNotNull(request.getAttribute(AuthContext.JTI_ATTRIBUTE));
+    }
+
+    @Test
+    void legacyTokenWithoutColonJtiStillPasses() throws Throwable {
+        // 历史无会话重载签发的 token:jti 为纯 UUID,不含冒号
+        MockHttpServletRequest request = requestWithToken(JwtUtil.token("7", "user7"));
+        RedisCache redisCache = mock(RedisCache.class);
+        AuthAspect aspect = new AuthAspect(redisCache);
+        ReflectionTestUtils.setField(aspect, "request", request);
+        ProceedingJoinPoint joinPoint = joinPointOnDummy();
+        Object expected = new Object();
+        Mockito.when(joinPoint.proceed()).thenReturn(expected);
+
+        Object actual = aspect.around(joinPoint);
+
+        assertSame(expected, actual, "历史无冒号 jti 的旧 token 应放行至自然过期");
+        verify(redisCache, never()).hasKey(anyString());
+    }
 }

+ 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");
+    }
+}

+ 70 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/utils/event/PushEventListenerTest.java

@@ -0,0 +1,70 @@
+package com.ruoyi.app.utils.event;
+
+import com.ruoyi.system.domain.PushMessage;
+import com.ruoyi.system.service.IPushMessageService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+
+/**
+ * push_message 订单指针字段:listener 入库时从 body JSON(OrderPushBodyDto 结构)
+ * 解析 ddId/pushType 存列;非订单类推送(body 非订单 JSON/为空)容错存 NULL,不影响入库。
+ */
+class PushEventListenerTest {
+
+    private final IPushMessageService pushMessageService = mock(IPushMessageService.class);
+    private final PushEventListener listener = new PushEventListener();
+
+    @BeforeEach
+    void setUp() {
+        ReflectionTestUtils.setField(listener, "pushMessageService", pushMessageService);
+    }
+
+    @Test
+    void extractsDdIdAndPushTypeFromBodyJsonWhenInserting() {
+        listener.onApplicationEvent(new PushEvent(9L, "訊息", "您有新的外送訂單",
+                "{\"ddId\":\"991790045286244\",\"state\":\"0\",\"type\":-1,\"pushType\":1}"));
+
+        ArgumentCaptor<PushMessage> captor = ArgumentCaptor.forClass(PushMessage.class);
+        verify(pushMessageService).insertPushMessage(captor.capture());
+        assertEquals("991790045286244", captor.getValue().getDdId());
+        assertEquals(Integer.valueOf(1), captor.getValue().getPushType());
+    }
+
+    @Test
+    void extractsFlashOrderNoAsDdIdWithPushTypeThree() {
+        listener.onApplicationEvent(new PushEvent(9L, "訊息", "您有新的快送訂單",
+                "{\"ddId\":\"F20260924001\",\"state\":\"WAITING_ACCEPTANCE\",\"type\":0,\"pushType\":3}"));
+
+        ArgumentCaptor<PushMessage> captor = ArgumentCaptor.forClass(PushMessage.class);
+        verify(pushMessageService).insertPushMessage(captor.capture());
+        assertEquals("F20260924001", captor.getValue().getDdId());
+        assertEquals(Integer.valueOf(3), captor.getValue().getPushType());
+    }
+
+    @Test
+    void keepsNullPointersWhenBodyIsNotOrderJson() {
+        listener.onApplicationEvent(new PushEvent(9L, "視頻通話", "來電提醒", "not-a-json"));
+
+        ArgumentCaptor<PushMessage> captor = ArgumentCaptor.forClass(PushMessage.class);
+        verify(pushMessageService).insertPushMessage(captor.capture());
+        assertNull(captor.getValue().getDdId());
+        assertNull(captor.getValue().getPushType());
+    }
+
+    @Test
+    void keepsNullPointersWhenBodyIsNull() {
+        listener.onApplicationEvent(new PushEvent(9L, "訊息", "系統通知", null));
+
+        ArgumentCaptor<PushMessage> captor = ArgumentCaptor.forClass(PushMessage.class);
+        verify(pushMessageService).insertPushMessage(captor.capture());
+        assertNull(captor.getValue().getDdId());
+        assertNull(captor.getValue().getPushType());
+    }
+}

+ 160 - 0
ruoyi-admin/src/test/java/com/ruoyi/web/controller/system/PushMessageControllerTest.java

@@ -0,0 +1,160 @@
+package com.ruoyi.web.controller.system;
+
+import com.baomidou.mybatisplus.core.MybatisConfiguration;
+import com.baomidou.mybatisplus.core.conditions.Wrapper;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
+import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.ruoyi.common.core.domain.AjaxResult;
+import com.ruoyi.common.utils.spring.SpringUtils;
+import com.ruoyi.system.domain.PushMessage;
+import com.ruoyi.system.service.IPushMessageService;
+import com.ruoyi.system.utils.JwtUtil;
+import org.apache.ibatis.builder.MapperBuilderAssistant;
+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.test.util.ReflectionTestUtils;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+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.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * 推送消息已读标识:列表接口只查不改;已读由 readMessage(单条)
+ * 与 readAllMessage(全部)显式变更;未读数接口按当前用户过滤。
+ */
+class PushMessageControllerTest {
+
+    private static ConfigurableListableBeanFactory originalBeanFactory;
+
+    private final IPushMessageService pushMessageService = mock(IPushMessageService.class);
+    private final PushMessageController controller = new PushMessageController();
+
+    @BeforeAll
+    static void initializeBeanFactory() {
+        // BaseController.success 依赖 MessageUtils 取"操作成功",纯单测下需手动挂一个 messageSource
+        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);
+        // 纯单测下初始化 MP 实体元数据,否则 LambdaWrapper 解析列名时无缓存可用
+        TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), PushMessage.class);
+    }
+
+    @AfterAll
+    static void restoreBeanFactory() {
+        new SpringUtils().postProcessBeanFactory(originalBeanFactory);
+    }
+
+    @BeforeEach
+    void setUp() {
+        ReflectionTestUtils.setField(controller, "pushMessageService", pushMessageService);
+    }
+
+    @Test
+    void listEndpointReturnsPageQueryWithoutChangingReadState() {
+        Page<PushMessage> page = new Page<>(1, 10);
+        page.setRecords(new ArrayList<>());
+        when(pushMessageService.page(any(Page.class), any(Wrapper.class))).thenReturn(page);
+
+        AjaxResult result = controller.getPushMessageList(JwtUtil.token("9", "user"), 1, 10);
+
+        assertEquals(200, ((Number) result.get(AjaxResult.CODE_TAG)).intValue());
+        assertSame(page, result.get(AjaxResult.DATA_TAG));
+        @SuppressWarnings("unchecked")
+        ArgumentCaptor<Wrapper<PushMessage>> captor = ArgumentCaptor.forClass(Wrapper.class);
+        verify(pushMessageService).page(any(Page.class), captor.capture());
+        LambdaQueryWrapper<PushMessage> queryWrapper = (LambdaQueryWrapper<PushMessage>) captor.getValue();
+        assertTrue(queryWrapper.getSqlSegment().contains("user_id"));
+        // 列表接口只查不改:已读由 readMessage(单条)/ readAllMessage(全部)显式变更
+        verify(pushMessageService, never()).update(any(Wrapper.class));
+    }
+
+    @Test
+    void listEndpointEnrichesPageRecordsWithOrderInfo() {
+        Page<PushMessage> page = new Page<>(1, 10);
+        List<PushMessage> records = new ArrayList<>();
+        records.add(new PushMessage());
+        page.setRecords(records);
+        when(pushMessageService.page(any(Page.class), any(Wrapper.class))).thenReturn(page);
+
+        controller.getPushMessageList(JwtUtil.token("9", "user"), 1, 10);
+
+        // 分页查完后按订单指针回填商家名/地址,仍返回同一分页对象
+        verify(pushMessageService).attachOrderInfo(records);
+    }
+
+    @Test
+    void unreadCountEndpointCountsOnlyUnreadForTokenUser() {
+        when(pushMessageService.count(any(Wrapper.class))).thenReturn(5L);
+
+        AjaxResult result = controller.getUnreadMessageCount(JwtUtil.token("9", "user"));
+
+        assertEquals(200, ((Number) result.get(AjaxResult.CODE_TAG)).intValue());
+        assertEquals(5L, ((Number) result.get(AjaxResult.DATA_TAG)).longValue());
+        @SuppressWarnings("unchecked")
+        ArgumentCaptor<Wrapper<PushMessage>> captor = ArgumentCaptor.forClass(Wrapper.class);
+        verify(pushMessageService).count(captor.capture());
+        LambdaQueryWrapper<PushMessage> queryWrapper = (LambdaQueryWrapper<PushMessage>) captor.getValue();
+        assertTrue(queryWrapper.getSqlSegment().contains("user_id"));
+        assertTrue(queryWrapper.getSqlSegment().contains("is_read"));
+        assertTrue(queryWrapper.getParamNameValuePairs().containsValue("9"));
+        assertTrue(queryWrapper.getParamNameValuePairs().containsValue(0));
+    }
+
+    @Test
+    void readMessageEndpointMarksSingleMessageReadForTokenUserOnly() {
+        when(pushMessageService.update(any(Wrapper.class))).thenReturn(true);
+
+        AjaxResult result = controller.readMessage(JwtUtil.token("9", "user"), 5L);
+
+        assertEquals(200, ((Number) result.get(AjaxResult.CODE_TAG)).intValue());
+        @SuppressWarnings("unchecked")
+        ArgumentCaptor<Wrapper<PushMessage>> captor = ArgumentCaptor.forClass(Wrapper.class);
+        verify(pushMessageService).update(captor.capture());
+        LambdaUpdateWrapper<PushMessage> updateWrapper = (LambdaUpdateWrapper<PushMessage>) captor.getValue();
+        assertTrue(updateWrapper.getSqlSet().contains("is_read"));
+        // 必须同时按消息 id 与当前用户过滤:既定位单条,也防越权把别人消息置已读
+        assertTrue(updateWrapper.getSqlSegment().contains("user_id"));
+        assertTrue(updateWrapper.getParamNameValuePairs().containsValue(5L));
+        assertTrue(updateWrapper.getParamNameValuePairs().containsValue("9"));
+    }
+
+    @Test
+    void readAllMessageEndpointMarksAllUnreadForTokenUser() {
+        when(pushMessageService.update(any(Wrapper.class))).thenReturn(true);
+
+        AjaxResult result = controller.readAllMessage(JwtUtil.token("9", "user"));
+
+        assertEquals(200, ((Number) result.get(AjaxResult.CODE_TAG)).intValue());
+        @SuppressWarnings("unchecked")
+        ArgumentCaptor<Wrapper<PushMessage>> captor = ArgumentCaptor.forClass(Wrapper.class);
+        verify(pushMessageService).update(captor.capture());
+        LambdaUpdateWrapper<PushMessage> updateWrapper = (LambdaUpdateWrapper<PushMessage>) captor.getValue();
+        assertTrue(updateWrapper.getSqlSet().contains("is_read"));
+        // 全部已读 = 该用户所有 is_read=0 的记录置 1,不夹带其它条件
+        assertTrue(updateWrapper.getSqlSegment().contains("user_id"));
+        assertTrue(updateWrapper.getSqlSegment().contains("is_read"));
+        assertTrue(updateWrapper.getParamNameValuePairs().containsValue("9"));
+        assertTrue(updateWrapper.getParamNameValuePairs().containsValue(0));
+    }
+}

+ 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;
+}

+ 28 - 0
ruoyi-system/src/main/java/com/ruoyi/system/domain/PushMessage.java

@@ -53,6 +53,25 @@ public class PushMessage
     @Excel(name = "${comment}", readConverterExp = "$column.readConverterExp()")
     private Date time;
 
+    /** 是否已读(0未读 1已读) */
+    private Integer isRead;
+
+    /** 关联订单号(外卖ddId/闪送orderNo) */
+    private String ddId;
+
+    /** 推送业务类型 1外卖 2充值 3闪送 */
+    private Integer pushType;
+
+    /** 以下为列表展示回填字段,不落库 */
+    private transient String shopName;
+    private transient String shAddress;
+    /** 订单缩略图:外卖=第一行商品图(food 快照 image 键);闪送=寄件凭证第一张 */
+    private transient String orderImage;
+    private transient String pickupAddress;
+    private transient String pickupAddressDetail;
+    private transient String deliveryAddress;
+    private transient String deliveryAddressDetail;
+
     public void setId(Long id)
     {
         this.id = id;
@@ -107,5 +126,14 @@ public class PushMessage
     {
         return time;
     }
+    public void setIsRead(Integer isRead)
+    {
+        this.isRead = isRead;
+    }
+
+    public Integer getIsRead()
+    {
+        return isRead;
+    }
 
 }

+ 1 - 1
ruoyi-system/src/main/java/com/ruoyi/system/domain/flash/FlashDeliveryOrder.java

@@ -15,7 +15,7 @@ public class FlashDeliveryOrder {
     @TableId(type = IdType.AUTO)
     /** 主键 ID,数据库自增。 */
     private Long id;
-    /** 订单号,FD 前缀加 24 位随机大写串,用于展示与检索。 */
+    /** 订单号,98 前缀加 13 位毫秒时间戳加 2 位递增序列(17 位纯数字),用于展示与检索;2026-09-22 前的存量单为 FD 前缀 24 位随机串。 */
     private String orderNo;
     /** 客户端请求幂等号,与 userId 共同建立唯一约束。 */
     private String clientRequestId;

+ 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> {
+}

+ 7 - 0
ruoyi-system/src/main/java/com/ruoyi/system/service/IPushMessageService.java

@@ -59,4 +59,11 @@ public interface IPushMessageService extends IService<PushMessage>
      * @return 结果
      */
     public int deletePushMessageById(Long id);
+
+    /**
+     * 按订单指针回填展示信息:外卖消息取商家名+收货地址,闪送消息取取件/收件地址
+     *
+     * @param messages 分页查出的当页消息
+     */
+    public void attachOrderInfo(List<PushMessage> messages);
 }

+ 188 - 0
ruoyi-system/src/main/java/com/ruoyi/system/service/impl/PushMessageServiceImpl.java

@@ -1,12 +1,30 @@
 package com.ruoyi.system.service.impl;
 
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.LinkedHashSet;
 import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.function.Function;
+import java.util.stream.Collectors;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.stereotype.Service;
+import com.alibaba.fastjson.JSONArray;
+import com.alibaba.fastjson.JSONObject;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
 import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.ruoyi.common.utils.StringUtils;
 import com.ruoyi.system.mapper.PushMessageMapper;
+import com.ruoyi.system.mapper.PosOrderMapper;
+import com.ruoyi.system.mapper.flash.FlashDeliveryOrderMapper;
+import com.ruoyi.system.mapper.flash.FlashDeliveryOrderImageMapper;
+import com.ruoyi.system.domain.PosOrder;
 import com.ruoyi.system.domain.PushMessage;
+import com.ruoyi.system.domain.flash.FlashDeliveryOrder;
+import com.ruoyi.system.domain.flash.FlashDeliveryOrderImage;
 import com.ruoyi.system.service.IPushMessageService;
 
 /**
@@ -21,6 +39,15 @@ public class PushMessageServiceImpl extends ServiceImpl<BaseMapper<PushMessage>,
     @Autowired
     private PushMessageMapper pushMessageMapper;
 
+    @Autowired
+    private PosOrderMapper posOrderMapper;
+
+    @Autowired
+    private FlashDeliveryOrderMapper flashDeliveryOrderMapper;
+
+    @Autowired
+    private FlashDeliveryOrderImageMapper flashDeliveryOrderImageMapper;
+
     /**
      * 查询推送消息
      *
@@ -92,4 +119,165 @@ public class PushMessageServiceImpl extends ServiceImpl<BaseMapper<PushMessage>,
     {
         return pushMessageMapper.deletePushMessageById(id);
     }
+
+    /**
+     * 按订单指针回填展示信息:外卖消息取商家名+收货地址,闪送消息取取件/收件地址
+     *
+     * @param messages 分页查出的当页消息
+     */
+    @Override
+    public void attachOrderInfo(List<PushMessage> messages)
+    {
+        if (messages == null || messages.isEmpty())
+        {
+            return;
+        }
+        Set<String> foodDdIds = new LinkedHashSet<>();
+        Set<String> flashOrderNos = new LinkedHashSet<>();
+        collectOrderPointers(messages, foodDdIds, flashOrderNos);
+        Map<String, PosOrder> foodOrders = foodDdIds.isEmpty() ? Collections.emptyMap()
+                : posOrderMapper.selectList(new LambdaQueryWrapper<PosOrder>().in(PosOrder::getDdId, foodDdIds))
+                .stream().filter(order -> order.getDdId() != null)
+                .collect(Collectors.toMap(PosOrder::getDdId, Function.identity(), (first, second) -> first));
+        Map<String, FlashDeliveryOrder> flashOrders = flashOrderNos.isEmpty() ? Collections.emptyMap()
+                : flashDeliveryOrderMapper.selectList(new LambdaQueryWrapper<FlashDeliveryOrder>().in(FlashDeliveryOrder::getOrderNo, flashOrderNos))
+                .stream().filter(order -> order.getOrderNo() != null)
+                .collect(Collectors.toMap(FlashDeliveryOrder::getOrderNo, Function.identity(), (first, second) -> first));
+        Map<Long, String> flashFirstImages = firstSenderImages(flashOrders.values());
+        fillOrderInfo(messages, foodOrders, flashOrders, flashFirstImages);
+    }
+
+    /** 收集当页消息的订单指针:push_type=1 归入外卖单号,push_type=3 归入闪送单号。 */
+    private void collectOrderPointers(List<PushMessage> messages, Set<String> foodDdIds, Set<String> flashOrderNos)
+    {
+        for (PushMessage message : messages)
+        {
+            if (message == null || StringUtils.isEmpty(message.getDdId()))
+            {
+                continue;
+            }
+            if (Integer.valueOf(1).equals(message.getPushType()))
+            {
+                foodDdIds.add(message.getDdId());
+            }
+            else if (Integer.valueOf(3).equals(message.getPushType()))
+            {
+                flashOrderNos.add(message.getDdId());
+            }
+        }
+    }
+
+    /** 按指针把商家名/地址/缩略图写回消息;订单已删或不存在的消息保持原样。 */
+    private void fillOrderInfo(List<PushMessage> messages, Map<String, PosOrder> foodOrders,
+                               Map<String, FlashDeliveryOrder> flashOrders, Map<Long, String> flashFirstImages)
+    {
+        for (PushMessage message : messages)
+        {
+            if (message == null || StringUtils.isEmpty(message.getDdId()))
+            {
+                continue;
+            }
+            if (Integer.valueOf(1).equals(message.getPushType()))
+            {
+                PosOrder order = foodOrders.get(message.getDdId());
+                if (order != null)
+                {
+                    message.setShopName(order.getPosName());
+                    message.setShAddress(order.getShAddress());
+                    message.setOrderImage(firstFoodImage(order.getFood()));
+                }
+            }
+            else if (Integer.valueOf(3).equals(message.getPushType()))
+            {
+                FlashDeliveryOrder order = flashOrders.get(message.getDdId());
+                if (order != null)
+                {
+                    message.setPickupAddress(order.getPickupAddress());
+                    message.setPickupAddressDetail(order.getPickupAddressDetail());
+                    message.setDeliveryAddress(order.getDeliveryAddress());
+                    message.setDeliveryAddressDetail(order.getDeliveryAddressDetail());
+                    if (order.getId() != null)
+                    {
+                        message.setOrderImage(flashFirstImages.get(order.getId()));
+                    }
+                }
+            }
+        }
+    }
+
+    /**
+     * 外卖订单缩略图:food 快照数组第一行的 image 键(行结构由 App 下单传入,
+     * 含 id/name/image/price/otherPrice/number 等);food 为空或非法返回 null。
+     */
+    private String firstFoodImage(String food)
+    {
+        if (StringUtils.isEmpty(food))
+        {
+            return null;
+        }
+        try
+        {
+            JSONArray rows = JSONArray.parseArray(food);
+            if (rows == null || rows.isEmpty())
+            {
+                return null;
+            }
+            JSONObject first = rows.getJSONObject(0);
+            String image = first == null ? null : first.getString("image");
+            return StringUtils.isEmpty(image) ? null : image;
+        }
+        catch (Exception e)
+        {
+            return null;
+        }
+    }
+
+    /** 闪送订单缩略图:批量查寄件凭证(SENDER),每单取 sortOrder 最小的一张;无凭证返回空。 */
+    private Map<Long, String> firstSenderImages(Collection<FlashDeliveryOrder> orders)
+    {
+        if (orders == null || orders.isEmpty())
+        {
+            return Collections.emptyMap();
+        }
+        Set<Long> orderIds = new LinkedHashSet<>();
+        for (FlashDeliveryOrder order : orders)
+        {
+            if (order != null && order.getId() != null)
+            {
+                orderIds.add(order.getId());
+            }
+        }
+        if (orderIds.isEmpty())
+        {
+            return Collections.emptyMap();
+        }
+        List<FlashDeliveryOrderImage> images = flashDeliveryOrderImageMapper.selectList(
+                new LambdaQueryWrapper<FlashDeliveryOrderImage>()
+                        .in(FlashDeliveryOrderImage::getOrderId, orderIds)
+                        .eq(FlashDeliveryOrderImage::getProofType, "SENDER"));
+        if (images == null || images.isEmpty())
+        {
+            return Collections.emptyMap();
+        }
+        Map<Long, FlashDeliveryOrderImage> firstImages = new HashMap<>();
+        for (FlashDeliveryOrderImage image : images)
+        {
+            if (image == null || image.getOrderId() == null || StringUtils.isEmpty(image.getImageUrl())
+                    || !"SENDER".equals(image.getProofType()))
+            {
+                continue;
+            }
+            firstImages.merge(image.getOrderId(), image,
+                    (current, candidate) -> sortKey(candidate) < sortKey(current) ? candidate : current);
+        }
+        Map<Long, String> result = new HashMap<>();
+        firstImages.forEach((orderId, image) -> result.put(orderId, image.getImageUrl()));
+        return result;
+    }
+
+    /** 凭证展示顺序;sortOrder 缺失时排最后。 */
+    private int sortKey(FlashDeliveryOrderImage image)
+    {
+        return image.getSortOrder() == null ? Integer.MAX_VALUE : image.getSortOrder();
+    }
 }

+ 0 - 7
ruoyi-system/src/main/java/com/ruoyi/system/utils/Auth.java

@@ -14,11 +14,4 @@ import java.lang.annotation.*;
 @Documented
 public @interface Auth {
     boolean token() default true;
-
-    /**
-     * 是否要求 Token 对应的服务端 Redis 会话仍然有效。
-     *
-     * <p>默认关闭以保持普通用户和骑手接口的现有认证行为;商家业务接口按需显式开启。</p>
-     */
-    boolean session() default false;
 }

+ 13 - 29
ruoyi-system/src/main/java/com/ruoyi/system/utils/AuthAspect.java

@@ -2,10 +2,6 @@ package com.ruoyi.system.utils;
 
 import com.ruoyi.common.core.domain.AjaxResult;
 import com.ruoyi.common.core.redis.RedisCache;
-import com.ruoyi.common.exception.ServiceException;
-import com.ruoyi.common.utils.MessageUtils;
-import com.ruoyi.system.domain.InfoUser;
-import com.ruoyi.system.mapper.InfoUserMapper;
 import org.aspectj.lang.ProceedingJoinPoint;
 import org.aspectj.lang.annotation.Around;
 import org.aspectj.lang.annotation.Aspect;
@@ -29,13 +25,14 @@ import java.util.Map;
 @Aspect
 public class AuthAspect {
 
+    /** 会话失效(被顶号/登出/撤销)与 token 过期返回同一响应,App 按既有 401 逻辑踢回登录页 */
+    private static final String SESSION_EXPIRED_MESSAGE = "token已过期,请重新登录!";
+
     private HttpServletRequest request;
     private final RedisCache redisCache;
-    private final InfoUserMapper infoUserMapper;
 
-    public AuthAspect(RedisCache redisCache, InfoUserMapper infoUserMapper) {
+    public AuthAspect(RedisCache redisCache) {
         this.redisCache = redisCache;
-        this.infoUserMapper = infoUserMapper;
     }
 
     @Resource
@@ -60,40 +57,27 @@ public class AuthAspect {
         if (auth.token()) {
             Map<String, Object> claims = JwtUtil.verifyToken(request.getHeader("token"));
             if (!Boolean.TRUE.equals(claims.get("valid"))) {
-                return AjaxResult.error(401,"token已过期,请重新登录!");
+                return AjaxResult.error(401, SESSION_EXPIRED_MESSAGE);
             }
 
-            Long userId = parseUserId(claims.get("id"));
             Object jtiValue = claims.get("jti");
             String jti = jtiValue instanceof String ? (String) jtiValue : null;
-            if (auth.session() && (userId == null || jti == null || jti.isBlank()
-                    || !Boolean.TRUE.equals(redisCache.hasKey(jti)))) {
-                // Redis 会话校验只服务商家侧账号的即时撤销;普通用户和骑手没有会话撤销用途,
-                // 旧 token(jti 从未写入 Redis)放行,凭已验签的 JWT 通过即可。
-                if (!isConsumerUser(userId)) {
-                    throw new ServiceException(MessageUtils.message("merchant.session.invalid"));
-                }
+            // 单设备登录(2026-09-22,与 cte_server 同构):登录时按 tokenKey+userId:* 删除旧会话,
+            // 此处强制新格式会话存在,使被顶号/登出/撤销的设备下一个请求即 401 掉线。
+            // 会话 jti 形如 "qtw_tokens:user:1:uuid"(含冒号);历史无冒号 jti 的旧 token 未写会话,
+            // 放行至 15 天自然过期淘汰,避免升级瞬间误伤存量登录。
+            if (jti != null && !jti.isBlank() && jti.contains(":")
+                    && !Boolean.TRUE.equals(redisCache.hasKey(jti))) {
+                return AjaxResult.error(401, SESSION_EXPIRED_MESSAGE);
             }
 
+            Long userId = parseUserId(claims.get("id"));
             request.setAttribute(AuthContext.USER_ID_ATTRIBUTE, userId);
             request.setAttribute(AuthContext.JTI_ATTRIBUTE, jti);
         }
         return joinPoint.proceed();
     }
 
-    /**
-     * 会话缺失时判断是否为无需商家会话的账号类型:0=普通用户、2=骑手。
-     * 商家侧类型(1/3/4/5)、用户不存在或类型未知时一律不放行。
-     */
-    private boolean isConsumerUser(Long userId) {
-        if (userId == null) {
-            return false;
-        }
-        InfoUser user = infoUserMapper.selectById(userId);
-        String userType = user == null ? null : user.getUserType();
-        return "0".equals(userType) || "2".equals(userType);
-    }
-
     private Long parseUserId(Object claim) {
         if (!(claim instanceof String value) || value.isBlank() || "null".equals(value)) {
             return null;

+ 14 - 1
ruoyi-system/src/main/resources/mapper/system/PushMessageMapper.xml

@@ -11,10 +11,13 @@
         <result property="content"    column="content"    />
         <result property="body"    column="body"    />
         <result property="time"    column="time"    />
+        <result property="isRead"    column="is_read"    />
+        <result property="ddId"    column="dd_id"    />
+        <result property="pushType"    column="push_type"    />
     </resultMap>
 
     <sql id="selectPushMessageVo">
-        select id, user_id, title, content, body, time from push_message
+        select id, user_id, title, content, body, time, is_read, dd_id, push_type from push_message
     </sql>
 
     <select id="selectPushMessageList" parameterType="PushMessage" resultMap="PushMessageResult">
@@ -25,6 +28,7 @@
             <if test="content != null  and content != ''"> and content = #{content}</if>
             <if test="body != null  and body != ''"> and body = #{body}</if>
             <if test="time != null "> and time = #{time}</if>
+            <if test="isRead != null "> and is_read = #{isRead}</if>
         </where>
     </select>
 
@@ -41,6 +45,9 @@
             <if test="content != null">content,</if>
             <if test="body != null">body,</if>
             <if test="time != null">time,</if>
+            <if test="isRead != null">is_read,</if>
+            <if test="ddId != null">dd_id,</if>
+            <if test="pushType != null">push_type,</if>
         </trim>
         <trim prefix="values (" suffix=")" suffixOverrides=",">
             <if test="userId != null">#{userId},</if>
@@ -48,6 +55,9 @@
             <if test="content != null">#{content},</if>
             <if test="body != null">#{body},</if>
             <if test="time != null">#{time},</if>
+            <if test="isRead != null">#{isRead},</if>
+            <if test="ddId != null">#{ddId},</if>
+            <if test="pushType != null">#{pushType},</if>
         </trim>
     </insert>
 
@@ -59,6 +69,9 @@
             <if test="content != null">content = #{content},</if>
             <if test="body != null">body = #{body},</if>
             <if test="time != null">time = #{time},</if>
+            <if test="isRead != null">is_read = #{isRead},</if>
+            <if test="ddId != null">dd_id = #{ddId},</if>
+            <if test="pushType != null">push_type = #{pushType},</if>
         </trim>
         where id = #{id}
     </update>

+ 218 - 0
ruoyi-system/src/test/java/com/ruoyi/system/service/impl/PushMessageServiceImplTest.java

@@ -0,0 +1,218 @@
+package com.ruoyi.system.service.impl;
+
+import com.baomidou.mybatisplus.core.MybatisConfiguration;
+import com.baomidou.mybatisplus.core.conditions.Wrapper;
+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.PushMessage;
+import com.ruoyi.system.domain.flash.FlashDeliveryOrder;
+import com.ruoyi.system.domain.flash.FlashDeliveryOrderImage;
+import com.ruoyi.system.mapper.PosOrderMapper;
+import com.ruoyi.system.mapper.flash.FlashDeliveryOrderImageMapper;
+import com.ruoyi.system.mapper.flash.FlashDeliveryOrderMapper;
+import org.apache.ibatis.builder.MapperBuilderAssistant;
+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.test.util.ReflectionTestUtils;
+
+import java.util.Collections;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+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.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * 消息列表订单信息回填:外卖消息(push_type=1)批量反查 pos_order 取商家名+收货地址;
+ * 闪送消息(push_type=3)反查 flash_delivery_order 取取件/收件地址;
+ * 无订单指针、非订单类型或订单已删时不回填,也不报错。
+ */
+class PushMessageServiceImplTest {
+
+    private final PosOrderMapper posOrderMapper = mock(PosOrderMapper.class);
+    private final FlashDeliveryOrderMapper flashDeliveryOrderMapper = mock(FlashDeliveryOrderMapper.class);
+    private final FlashDeliveryOrderImageMapper flashDeliveryOrderImageMapper = mock(FlashDeliveryOrderImageMapper.class);
+    private final PushMessageServiceImpl service = new PushMessageServiceImpl();
+
+    @BeforeAll
+    static void initTableInfo() {
+        // 纯单测下初始化 MP 实体元数据,否则 LambdaWrapper 解析列名时无缓存可用
+        MapperBuilderAssistant assistant = new MapperBuilderAssistant(new MybatisConfiguration(), "");
+        TableInfoHelper.initTableInfo(assistant, PosOrder.class);
+        TableInfoHelper.initTableInfo(assistant, FlashDeliveryOrder.class);
+        TableInfoHelper.initTableInfo(assistant, FlashDeliveryOrderImage.class);
+    }
+
+    @BeforeEach
+    void setUp() {
+        ReflectionTestUtils.setField(service, "posOrderMapper", posOrderMapper);
+        ReflectionTestUtils.setField(service, "flashDeliveryOrderMapper", flashDeliveryOrderMapper);
+        ReflectionTestUtils.setField(service, "flashDeliveryOrderImageMapper", flashDeliveryOrderImageMapper);
+    }
+
+    @Test
+    void foodOrderMessageGetsShopNameAndDeliveryAddress() {
+        PushMessage message = message("991790045286244", 1);
+        PosOrder order = new PosOrder();
+        order.setDdId("991790045286244");
+        order.setPosName("阿寶早餐店");
+        order.setShAddress("台北市大安區仁愛路一段1號");
+        when(posOrderMapper.selectList(any(Wrapper.class))).thenReturn(List.of(order));
+
+        service.attachOrderInfo(List.of(message));
+
+        assertEquals("阿寶早餐店", message.getShopName());
+        assertEquals("台北市大安區仁愛路一段1號", message.getShAddress());
+        verify(flashDeliveryOrderMapper, never()).selectList(any(Wrapper.class));
+        @SuppressWarnings("unchecked")
+        ArgumentCaptor<Wrapper<PosOrder>> captor = ArgumentCaptor.forClass(Wrapper.class);
+        verify(posOrderMapper).selectList(captor.capture());
+        // 反查必须按订单号过滤且不逐条查询(in 参数由 MP 惰性注册,断言以 sqlSegment 为准)
+        LambdaQueryWrapper<PosOrder> queryWrapper = (LambdaQueryWrapper<PosOrder>) captor.getValue();
+        String sqlSegment = queryWrapper.getSqlSegment();
+        assertTrue(sqlSegment.contains("dd_id"));
+        assertTrue(sqlSegment.contains("IN"));
+    }
+
+    @Test
+    void flashOrderMessageGetsPickupAndDeliveryAddress() {
+        PushMessage message = message("F20260924001", 3);
+        FlashDeliveryOrder order = new FlashDeliveryOrder();
+        order.setOrderNo("F20260924001");
+        order.setPickupAddress("台中市西屯區文華路100號");
+        order.setPickupAddressDetail("3樓之2");
+        order.setDeliveryAddress("台中市北區三民路三段129號");
+        order.setDeliveryAddressDetail("B1");
+        when(flashDeliveryOrderMapper.selectList(any(Wrapper.class))).thenReturn(List.of(order));
+
+        service.attachOrderInfo(List.of(message));
+
+        assertEquals("台中市西屯區文華路100號", message.getPickupAddress());
+        assertEquals("3樓之2", message.getPickupAddressDetail());
+        assertEquals("台中市北區三民路三段129號", message.getDeliveryAddress());
+        assertEquals("B1", message.getDeliveryAddressDetail());
+        verify(posOrderMapper, never()).selectList(any(Wrapper.class));
+    }
+
+    @Test
+    void foodOrderMessageBackfillsFirstFoodImage() {
+        PushMessage message = message("991790045286244", 1);
+        PosOrder order = new PosOrder();
+        order.setDdId("991790045286244");
+        order.setPosName("阿寶早餐店");
+        // food 快照行结构(App 下单传入):id/name/image/price/otherPrice/number/ask/beizhu/addType
+        order.setFood("[{\"id\":9079,\"name\":\"草莓冰淇淋\",\"image\":\"/profile/upload/2026/08/18/a.png\",\"price\":55,\"otherPrice\":20,\"number\":1},"
+                + "{\"id\":9080,\"name\":\"蛋餅\",\"image\":\"/profile/upload/2026/08/18/b.png\",\"price\":40,\"otherPrice\":0,\"number\":2}]");
+        when(posOrderMapper.selectList(any(Wrapper.class))).thenReturn(List.of(order));
+
+        service.attachOrderInfo(List.of(message));
+
+        // 只取第一行商品的 image
+        assertEquals("/profile/upload/2026/08/18/a.png", message.getOrderImage());
+    }
+
+    @Test
+    void foodOrderWithoutUsableFoodJsonLeavesOrderImageNull() {
+        PushMessage message = message("991790045286244", 1);
+        PosOrder order = new PosOrder();
+        order.setDdId("991790045286244");
+        order.setFood("not-a-json");
+        when(posOrderMapper.selectList(any(Wrapper.class))).thenReturn(List.of(order));
+
+        service.attachOrderInfo(List.of(message));
+
+        assertNull(message.getOrderImage());
+    }
+
+    @Test
+    void flashOrderMessageBackfillsFirstSenderImage() {
+        PushMessage message = message("F20260924001", 3);
+        FlashDeliveryOrder order = new FlashDeliveryOrder();
+        order.setId(77L);
+        order.setOrderNo("F20260924001");
+        order.setPickupAddress("台中市西屯區文華路100號");
+        when(flashDeliveryOrderMapper.selectList(any(Wrapper.class))).thenReturn(List.of(order));
+        when(flashDeliveryOrderImageMapper.selectList(any(Wrapper.class))).thenReturn(List.of(
+                senderImage(77L, 1, "/profile/upload/2026/09/24/second.png"),
+                senderImage(77L, 0, "/profile/upload/2026/09/24/first.png"),
+                proofImage(77L, "PICKUP", "/profile/upload/2026/09/24/pickup.png")));
+
+        service.attachOrderInfo(List.of(message));
+
+        // 寄件凭证按 sortOrder 取第一张;取件/送达凭证不作为缩略图
+        assertEquals("/profile/upload/2026/09/24/first.png", message.getOrderImage());
+    }
+
+    @Test
+    void messageWithoutDdIdStaysUntouched() {
+        PushMessage message = message(null, 1);
+
+        service.attachOrderInfo(List.of(message));
+
+        verify(posOrderMapper, never()).selectList(any(Wrapper.class));
+        verify(flashDeliveryOrderMapper, never()).selectList(any(Wrapper.class));
+        assertNull(message.getShopName());
+    }
+
+    @Test
+    void nonOrderPushTypeStaysUntouched() {
+        PushMessage message = message("R20260924001", 2);
+
+        service.attachOrderInfo(List.of(message));
+
+        verify(posOrderMapper, never()).selectList(any(Wrapper.class));
+        verify(flashDeliveryOrderMapper, never()).selectList(any(Wrapper.class));
+        assertNull(message.getShopName());
+    }
+
+    @Test
+    void missingOrderLeavesMessageUntouched() {
+        PushMessage message = message("991790045286244", 1);
+        when(posOrderMapper.selectList(any(Wrapper.class))).thenReturn(Collections.emptyList());
+
+        service.attachOrderInfo(List.of(message));
+
+        assertNull(message.getShopName());
+        assertNull(message.getShAddress());
+    }
+
+    @Test
+    void emptyListTouchesNothing() {
+        service.attachOrderInfo(Collections.emptyList());
+
+        verify(posOrderMapper, never()).selectList(any(Wrapper.class));
+        verify(flashDeliveryOrderMapper, never()).selectList(any(Wrapper.class));
+    }
+
+    private PushMessage message(String ddId, Integer pushType) {
+        PushMessage message = new PushMessage();
+        message.setDdId(ddId);
+        message.setPushType(pushType);
+        return message;
+    }
+
+    private FlashDeliveryOrderImage senderImage(Long orderId, int sortOrder, String url) {
+        return proofImage(orderId, "SENDER", url, sortOrder);
+    }
+
+    private FlashDeliveryOrderImage proofImage(Long orderId, String proofType, String url) {
+        return proofImage(orderId, proofType, url, 0);
+    }
+
+    private FlashDeliveryOrderImage proofImage(Long orderId, String proofType, String url, int sortOrder) {
+        FlashDeliveryOrderImage image = new FlashDeliveryOrderImage();
+        image.setOrderId(orderId);
+        image.setProofType(proofType);
+        image.setImageUrl(url);
+        image.setSortOrder(sortOrder);
+        return image;
+    }
+}

+ 46 - 109
ruoyi-system/src/test/java/com/ruoyi/system/utils/AuthAspectTest.java

@@ -2,36 +2,34 @@ package com.ruoyi.system.utils;
 
 import com.auth0.jwt.JWT;
 import com.auth0.jwt.algorithms.Algorithm;
+import com.ruoyi.common.constant.CacheConstants;
+import com.ruoyi.common.core.domain.AjaxResult;
 import com.ruoyi.common.core.redis.RedisCache;
-import com.ruoyi.common.exception.ServiceException;
-import com.ruoyi.common.utils.MessageUtils;
-import com.ruoyi.system.domain.InfoUser;
-import com.ruoyi.system.mapper.InfoUserMapper;
 import jakarta.servlet.http.HttpServletRequest;
 import org.aspectj.lang.ProceedingJoinPoint;
 import org.aspectj.lang.reflect.MethodSignature;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
-import org.mockito.MockedStatic;
 
 import java.lang.reflect.Method;
 import java.util.Date;
-import java.util.Map;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertSame;
-import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.mockito.ArgumentMatchers.anyString;
 import static org.mockito.Mockito.mock;
-import static org.mockito.Mockito.mockStatic;
 import static org.mockito.Mockito.never;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
 
+/**
+ * 单设备登录(2026-09-22)会话校验切面测试:
+ * 新格式会话 token(jti 含冒号)必须存在 Redis 会话,被顶号/登出删除后即 401;
+ * 历史无冒号 jti(旧无会话重载签发)放行至自然过期;无 jti 的手工 token 同样按历史处理。
+ */
 class AuthAspectTest {
 
     private RedisCache redisCache;
-    private InfoUserMapper infoUserMapper;
     private HttpServletRequest request;
     private ProceedingJoinPoint joinPoint;
     private MethodSignature signature;
@@ -40,36 +38,41 @@ class AuthAspectTest {
     @BeforeEach
     void setUp() {
         redisCache = mock(RedisCache.class);
-        infoUserMapper = mock(InfoUserMapper.class);
         request = mock(HttpServletRequest.class);
         joinPoint = mock(ProceedingJoinPoint.class);
         signature = mock(MethodSignature.class);
-        aspect = new AuthAspect(redisCache, infoUserMapper);
+        aspect = new AuthAspect(redisCache);
         aspect.setHttpServletRequest(request);
         when(joinPoint.getSignature()).thenReturn(signature);
     }
 
+    /** 手工签发新格式会话 token:jti 含冒号但不落 Redis,会话存在性由 redisCache 桩控制 */
+    private String colonJtiToken(String jti) {
+        return JWT.create()
+                .withClaim("id", "936")
+                .withExpiresAt(new Date(System.currentTimeMillis() + 60_000))
+                .withJWTId(jti)
+                .sign(Algorithm.HMAC256("TEST-AUTH-TOKEN"));
+    }
+
     @Test
-    void jwtOnlyAuthDoesNotRequireRedisSession() throws Throwable {
-        String token = JwtUtil.token("936", "merchant");
-        Object expected = new Object();
-        prepareInvocation("jwtOnly", token);
-        when(joinPoint.proceed()).thenReturn(expected);
+    void deletedSessionIsRejectedBeforeController() throws Throwable {
+        String jti = CacheConstants.USER_TOKEN_KEY + "936:deleted-session";
+        prepareInvocation("endpoint", colonJtiToken(jti));
+        when(redisCache.hasKey(jti)).thenReturn(false);
 
-        Object actual = aspect.around(joinPoint);
+        Object result = aspect.around(joinPoint);
 
-        assertSame(expected, actual);
-        verify(redisCache, never()).hasKey(anyString());
-        verify(request).setAttribute(AuthContext.USER_ID_ATTRIBUTE, 936L);
+        assertEquals(401, ((AjaxResult) result).get("code"), "会话被删(被顶号/登出)应返回 401");
+        verify(joinPoint, never()).proceed();
     }
 
     @Test
-    void merchantSessionAuthAllowsExistingRedisSession() throws Throwable {
-        String token = JwtUtil.token("936", "merchant");
-        String jti = tokenJti(token);
-        Object expected = new Object();
-        prepareInvocation("merchantSession", token);
+    void liveSessionProceedsAndExposesTrustedIdentity() throws Throwable {
+        String jti = CacheConstants.USER_TOKEN_KEY + "936:live-session";
+        prepareInvocation("endpoint", colonJtiToken(jti));
         when(redisCache.hasKey(jti)).thenReturn(true);
+        Object expected = new Object();
         when(joinPoint.proceed()).thenReturn(expected);
 
         Object actual = aspect.around(joinPoint);
@@ -80,95 +83,44 @@ class AuthAspectTest {
     }
 
     @Test
-    void merchantSessionAuthAllowsOrdinaryUserWithoutRedisSession() throws Throwable {
-        String token = JwtUtil.token("936", "user");
-        prepareInvocation("merchantSession", token);
-        loginUser("0");
-        when(redisCache.hasKey(anyString())).thenReturn(false);
+    void legacyNoColonJtiPassesWithoutRedisLookup() throws Throwable {
+        // 历史无会话重载 JwtUtil.token(id, userName) 签发:jti 为纯 UUID,不含冒号
+        prepareInvocation("endpoint", JwtUtil.token("936", "user"));
         Object expected = new Object();
         when(joinPoint.proceed()).thenReturn(expected);
 
         Object actual = aspect.around(joinPoint);
 
         assertSame(expected, actual);
+        verify(redisCache, never()).hasKey(anyString());
         verify(request).setAttribute(AuthContext.USER_ID_ATTRIBUTE, 936L);
     }
 
     @Test
-    void merchantSessionAuthAllowsRiderWithoutRedisSession() throws Throwable {
-        String token = JwtUtil.token("936", "rider");
-        prepareInvocation("merchantSession", token);
-        loginUser("2");
-        when(redisCache.hasKey(anyString())).thenReturn(false);
+    void tokenWithoutJtiTreatedAsLegacyAndProceeds() throws Throwable {
+        String token = JWT.create()
+                .withClaim("id", "936")
+                .withExpiresAt(new Date(System.currentTimeMillis() + 60_000))
+                .sign(Algorithm.HMAC256("TEST-AUTH-TOKEN"));
+        prepareInvocation("endpoint", token);
         Object expected = new Object();
         when(joinPoint.proceed()).thenReturn(expected);
 
         Object actual = aspect.around(joinPoint);
 
         assertSame(expected, actual);
-        verify(request).setAttribute(AuthContext.USER_ID_ATTRIBUTE, 936L);
-    }
-
-    @Test
-    void merchantSessionAuthRejectsMissingRedisSessionBeforeController() throws Throwable {
-        String token = JwtUtil.token("936", "merchant");
-        String jti = tokenJti(token);
-        prepareInvocation("merchantSession", token);
-        loginUser("1");
-        when(redisCache.hasKey(jti)).thenReturn(false);
-
-        try (MockedStatic<MessageUtils> messages = mockStatic(MessageUtils.class)) {
-            messages.when(() -> MessageUtils.message("merchant.session.invalid"))
-                    .thenReturn("session invalid");
-
-            ServiceException exception = assertThrows(ServiceException.class,
-                    () -> aspect.around(joinPoint));
-
-            assertEquals("session invalid", exception.getMessage());
-            verify(joinPoint, never()).proceed();
-        }
+        verify(redisCache, never()).hasKey(anyString());
     }
 
     @Test
-    void merchantSessionAuthRejectsUnknownUserWithoutRedisSession() throws Throwable {
-        String token = JwtUtil.token("936", "stranger");
-        String jti = tokenJti(token);
-        prepareInvocation("merchantSession", token);
-        when(infoUserMapper.selectById(936L)).thenReturn(null);
-        when(redisCache.hasKey(jti)).thenReturn(false);
-
-        try (MockedStatic<MessageUtils> messages = mockStatic(MessageUtils.class)) {
-            messages.when(() -> MessageUtils.message("merchant.session.invalid"))
-                    .thenReturn("session invalid");
+    void invalidSignatureRejectedBeforeSessionLookup() throws Throwable {
+        prepareInvocation("endpoint", "not-a-jwt");
 
-            ServiceException exception = assertThrows(ServiceException.class,
-                    () -> aspect.around(joinPoint));
+        Object result = aspect.around(joinPoint);
 
-            assertEquals("session invalid", exception.getMessage());
-            verify(joinPoint, never()).proceed();
-        }
-    }
-
-    @Test
-    void merchantSessionAuthRejectsTokenWithoutJti() throws Throwable {
-        String token = JWT.create()
-                .withClaim("id", "936")
-                .withExpiresAt(new Date(System.currentTimeMillis() + 60_000))
-                .sign(Algorithm.HMAC256("TEST-AUTH-TOKEN"));
-        prepareInvocation("merchantSession", token);
-        loginUser("1");
-
-        try (MockedStatic<MessageUtils> messages = mockStatic(MessageUtils.class)) {
-            messages.when(() -> MessageUtils.message("merchant.session.invalid"))
-                    .thenReturn("session invalid");
-
-            ServiceException exception = assertThrows(ServiceException.class,
-                    () -> aspect.around(joinPoint));
-
-            assertEquals("session invalid", exception.getMessage());
-            verify(redisCache, never()).hasKey(anyString());
-            verify(joinPoint, never()).proceed();
-        }
+        assertEquals(401, ((AjaxResult) result).get("code"));
+        verify(redisCache, never()).hasKey(anyString());
+        verify(joinPoint, never()).proceed();
     }
 
     private void prepareInvocation(String methodName, String token) throws Exception {
@@ -177,24 +129,9 @@ class AuthAspectTest {
         when(request.getHeader("token")).thenReturn(token);
     }
 
-    private void loginUser(String userType) {
-        InfoUser user = new InfoUser();
-        user.setUserType(userType);
-        when(infoUserMapper.selectById(936L)).thenReturn(user);
-    }
-
-    private String tokenJti(String token) {
-        Map<String, Object> claims = JwtUtil.verifyToken(token);
-        return (String) claims.get("jti");
-    }
-
     static class SecuredMethods {
         @Auth
-        public void jwtOnly() {
-        }
-
-        @Auth(session = true)
-        public void merchantSession() {
+        public void endpoint() {
         }
     }
 }

+ 8 - 0
specs/024-flash-delivery/spec.md

@@ -366,3 +366,11 @@
 - 骑手沿用既有 `info_user.vehicle_type`(`1`=机车、`2`=轿车);待抢列表、待抢详情和接单均要求与订单车型一致。历史骑手、运价和订单均默认机车,保证迁移后既有行为不变。
 - 重量范围与体积/规格说明改为选填;未传时保存 `NULL`,不参与本期运费计算。物品类别、数量、小费及地址校验保持必填规则。
 - 本期不修改用户 App 界面;后续 App 仅需在报价和创建请求都传相同的 `vehicleType`。
+
+## 2026-09-22:订单编号规则调整(FD+UUID → 98+时间戳+序列)
+
+订单号 orderNo 由「FD + UUID 前 24 位大写」(26 位无语义十六进制串)调整为
+「98 + 13 位毫秒时间戳 + 2 位同毫秒递增序列」(17 位纯数字),与外卖订单(99 + 毫秒时间戳)
+风格同构,便于电话报单与按时间排查。同毫秒并发由 JVM 内原子递增序列区分;
+时钟回拨、多实例等理论撞号由 order_no 唯一索引 uk_flash_order_no 兜底(updatesql/sql.md 2026-09-22 节),
+插入冲突时换号重试一次,userId+clientRequestId 幂等键行为不变。存量订单编号保持不变,不做迁移。

+ 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 条件的括号层级

+ 59 - 0
updatesql/sql.md

@@ -1599,3 +1599,62 @@ 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);
+```
+
+## 2026-09-22 闪送订单编号唯一索引
+
+订单号规则调整为「98 + 毫秒时间戳 + 2 位序列」(见 FlashDeliveryApplicationService.generateOrderNo),
+新增唯一索引作为时钟回拨/多实例等理论撞号的兜底。执行前先查重:
+
+```sql
+-- 预检查(应返回 0 行;如有重复需先人工处理再建索引)
+SELECT order_no, COUNT(*) AS cnt FROM flash_delivery_order GROUP BY order_no HAVING cnt > 1;
+
+-- 2026-09-22 闪送订单编号唯一索引(撞号兜底)
+ALTER TABLE flash_delivery_order ADD UNIQUE KEY uk_flash_order_no (order_no);
+```
+
+```sql
+-- 2026-09-22 推送消息已读标识(消息中心红点/未读数)
+ALTER TABLE push_message ADD COLUMN is_read TINYINT NOT NULL DEFAULT 0 COMMENT '是否已读(0未读 1已读)';
+-- 历史消息统一置为已读,避免上线当天全员角标爆量
+UPDATE push_message SET is_read = 1;
+```
+
+```sql
+-- 2026-09-24 消息列表反查订单展示信息(Bug #671 骑手消息显示商家名+地址/闪送取收件地址)
+ALTER TABLE push_message ADD COLUMN dd_id VARCHAR(32) DEFAULT NULL COMMENT '关联订单号(外卖ddId/闪送orderNo)';
+ALTER TABLE push_message ADD COLUMN push_type TINYINT DEFAULT NULL COMMENT '推送业务类型 1外卖 2充值 3闪送';
+```