Explorar o código

实现闪送收件订单与急送骑手独占

新增创建时收件手机号账号绑定和我收的查询权限。
使用统一骑手级Redis锁串行化外卖与闪送接单,并限制急送独占。
补齐自动化测试、五语言提示、迁移SQL和接口文档。
qmj hai 1 día
pai
achega
2eda7bfabc
Modificáronse 28 ficheiros con 733 adicións e 56 borrados
  1. 13 6
      docs/flash-delivery-app-api.md
  2. 3 2
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/controller/FlashDeliveryUserController.java
  3. 53 11
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryApplicationService.java
  4. 27 2
      ruoyi-admin/src/main/java/com/ruoyi/app/order/OrderLifecycleService.java
  5. 61 0
      ruoyi-admin/src/main/java/com/ruoyi/app/order/RiderDeliveryExclusivityService.java
  6. 70 0
      ruoyi-admin/src/main/java/com/ruoyi/app/order/RiderDeliveryLockService.java
  7. 1 0
      ruoyi-admin/src/main/resources/i18n/messages.properties
  8. 1 0
      ruoyi-admin/src/main/resources/i18n/messages_en_US.properties
  9. 1 0
      ruoyi-admin/src/main/resources/i18n/messages_vi.properties
  10. 1 0
      ruoyi-admin/src/main/resources/i18n/messages_zh_CN.properties
  11. 1 0
      ruoyi-admin/src/main/resources/i18n/messages_zh_TW.properties
  12. 3 1
      ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/controller/FlashDeliveryControllerContractTest.java
  13. 137 3
      ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryApplicationServiceTest.java
  14. 1 0
      ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryI18nContractTest.java
  15. 42 1
      ruoyi-admin/src/test/java/com/ruoyi/app/order/OrderLifecycleServiceTest.java
  16. 9 0
      ruoyi-admin/src/test/java/com/ruoyi/app/order/PosOrderQsOprateControllerTest.java
  17. 121 0
      ruoyi-admin/src/test/java/com/ruoyi/app/order/RiderDeliveryExclusivityServiceTest.java
  18. 109 0
      ruoyi-admin/src/test/java/com/ruoyi/app/order/RiderDeliveryLockServiceTest.java
  19. 2 0
      ruoyi-system/src/main/java/com/ruoyi/system/domain/flash/FlashDeliveryOrder.java
  20. 3 0
      ruoyi-system/src/main/java/com/ruoyi/system/mapper/InfoUserMapper.java
  21. 3 3
      ruoyi-system/src/main/java/com/ruoyi/system/mapper/flash/FlashDeliveryOrderMapper.java
  22. 3 2
      ruoyi-system/src/main/resources/mapper/flash/FlashDeliveryOrderMapper.xml
  23. 11 0
      ruoyi-system/src/main/resources/mapper/infouser/InfoUserMapper.xml
  24. 19 0
      ruoyi-system/src/test/java/com/ruoyi/system/mapper/flash/FlashDeliveryMapperXmlTest.java
  25. 15 15
      specs/024-flash-delivery/implementation-plan-2026-09-04.md
  26. 7 4
      specs/024-flash-delivery/quickstart.md
  27. 6 6
      specs/024-flash-delivery/tasks.md
  28. 10 0
      updatesql/sql.md

+ 13 - 6
docs/flash-delivery-app-api.md

@@ -16,7 +16,7 @@
 
 当前不包含支付、退款、骑手收入、结算、代购垫付、自动派单和骑手放弃订单。
 
-设计原型中的支付方式选择、已付款状态、附近骑手上线数量、预计接单时间和骑手收益金额均为静态示意,接口不提供这些数据,App 不应展示或请求它们。
+设计原型中的支付方式选择、已付款状态、附近骑手上线数量、预计接单时间和骑手收益金额均为静态示意,接口不提供这些数据,App 不应展示或请求它们。发布成功页统一使用静态文案“发布后等待附近骑手接单”。
 
 ## 2. 通用约定
 
@@ -500,10 +500,12 @@ POST /system/flashDelivery/orders
 
 成功响应 `data` 为完整详情对象,初始状态为 `WAITING_ACCEPTANCE`。创建响应中的 `amount` 是最终整数订单金额。
 
+创建时,后端会移除收件手机号中的空白、`+`、`-`、`(`、`)` 后,匹配有效且未删除的普通用户账号。仅在恰好匹配一个账号时把该账号固化为收件人;无匹配或重复匹配均不绑定。此绑定只执行一次,历史订单不会因收件人之后注册或修改手机号而自动认领。
+
 ### 7.4 查询用户订单列表
 
 ```http
-GET /system/flashDelivery/orders?page=1&size=10
+GET /system/flashDelivery/orders?page=1&size=10&role=sender
 ```
 
 查询参数:
@@ -512,8 +514,9 @@ GET /system/flashDelivery/orders?page=1&size=10
 |---|---:|---|
 | `page` | 否 | 默认 `1`,小于 `1` 时按 `1` 处理 |
 | `size` | 否 | 默认 `10`,范围为 `1` 至 `100` |
+| `role` | 否 | `sender` 查询“我发的”,`receiver` 查询“我收的”;默认 `sender` |
 
-仅查询当前用户,按创建时间倒序返回全部状态的订单摘要。接口不接受 `status`、`scene` 或 `serviceType`;响应 `data.records` 中每项为用户列表摘要对象。
+仅查询当前用户作为指定角色的订单,按创建时间倒序返回全部状态的订单摘要。接口不接受 `status`、`scene` 或 `serviceType`;响应 `data.records` 中每项为用户列表摘要对象。
 
 设计原型中的“待接单、配送中、已完成、已取消”筛选页签当前没有对应查询参数。第一版建议只展示“全部”列表;如需页签,只能基于当前页数据本地过滤,且不应依赖它得到准确的分页总数。
 
@@ -523,7 +526,7 @@ GET /system/flashDelivery/orders?page=1&size=10
 GET /system/flashDelivery/orders/{id}
 ```
 
-仅订单所属用户可访问。响应 `data` 直接为 App 完整订单字段,包括订单快照、三类图片数组、骑手摘要和可选的交付 PIN,不返回原始状态日志。响应不包含支付状态或支付方式字段,详情页不要展示“已付款”等支付信息。
+订单发件人和创建时固化的收件人均可访问。响应 `data` 直接为 App 完整订单字段,包括订单快照、三类图片数组、骑手摘要和可选的交付 PIN,不返回原始状态日志。响应不包含支付状态或支付方式字段,详情页不要展示“已付款”等支付信息。
 
 ### 7.6 用户取消订单
 
@@ -540,6 +543,7 @@ POST /system/flashDelivery/orders/{id}/cancel
 ```
 
 - `reason` 必填,去除首尾空格后不能为空,最长 500 字符。
+- 仅订单发件人可以取消;收件人不能取消。
 - 仅 `WAITING_ACCEPTANCE` 和 `ACCEPTED` 状态允许用户取消。
 - `PICKED_UP` 之后需要平台介入,用户端不能直接取消。
 - 设计原型中“骑士接单前可免费取消”的文案比接口口径更严格:接口允许骑手已接单但尚未取件时取消,且当前没有支付,取消不产生任何费用;接入支付后取消规则需重新对齐。
@@ -550,12 +554,14 @@ POST /system/flashDelivery/orders/{id}/cancel
 POST /system/flashDelivery/orders/{id}/confirmReceipt
 ```
 
-无请求体。仅 `DELIVERED` 状态可操作,成功后状态变为 `COMPLETED`。
+无请求体。订单发件人或创建时固化的收件人都可操作;仅 `DELIVERED` 状态可确认,成功后状态变为 `COMPLETED`。
 
 ## 8. 骑手端接口
 
 所有接口均要求骑手登录 `token`,且账号 `userType` 必须为 `2`。
 
+接单使用共享 Redis 锁 `lock:delivery:rider:{riderId}` 串行执行跨外卖、闪送的资格检查和订单条件更新,并在事务完成后释放。`URGENT` 急送要求骑手没有任何进行中的外卖或闪送;骑手已有进行中的急送时,也不能再接普通闪送或外卖。Redis 不可用或 3 秒内未取得锁时,本次接单直接失败。
+
 ### 8.1 查询骑手订单列表
 
 ```http
@@ -786,5 +792,6 @@ POST /system/address/{id}/top
 - 骑手严格按 `ACCEPTED -> PICKED_UP -> DELIVERED` 顺序操作。
 - 启用 PIN 时,骑手送达必须提交用户端详情显示的四位 PIN。
 - 对抢单冲突、状态变化和重复点击,均以刷新后的服务端订单状态为准。
-- 用户订单列表第一版只展示“全部”;如实现状态页签,仅基于当前页数据本地过滤。
+- 用户订单列表使用 `role=sender/receiver` 切换“我发的”和“我收的”;状态页签如需展示,仅基于当前页数据本地过滤。
+- 发布成功页显示“发布后等待附近骑手接单”,不显示虚构的骑手数量或预计接单时长。
 - 骑手端金额文案统一为“订单金额”,不使用“收益”;联系骑手/用户通过 IM,不提供通话。

+ 3 - 2
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/controller/FlashDeliveryUserController.java

@@ -42,8 +42,9 @@ public class FlashDeliveryUserController extends BaseController {
     @Auth
     public AjaxResult orders(@RequestHeader String token,
                              @RequestParam(defaultValue = "1") int page,
-                             @RequestParam(defaultValue = "10") int size) {
-        return success(service.userOrders(userId(token), page, size));
+                             @RequestParam(defaultValue = "10") int size,
+                             @RequestParam(required = false) String role) {
+        return success(service.userOrders(userId(token), page, size, role));
     }
 
     /** 查询本人订单详情,直接返回订单字段和按类型分组的履约图片。 */

+ 53 - 11
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryApplicationService.java

@@ -8,6 +8,8 @@ import com.ruoyi.app.flashdelivery.dto.*;
 import com.ruoyi.app.flashdelivery.route.FlashDeliveryRouteService;
 import com.ruoyi.app.flashdelivery.route.GeoPoint;
 import com.ruoyi.app.flashdelivery.route.RouteDistance;
+import com.ruoyi.app.order.RiderDeliveryExclusivityService;
+import com.ruoyi.app.order.RiderDeliveryLockService;
 import com.ruoyi.common.core.domain.entity.SysDictData;
 import com.ruoyi.common.exception.ServiceException;
 import com.ruoyi.common.utils.DictUtils;
@@ -55,6 +57,8 @@ public class FlashDeliveryApplicationService {
     private final InfoUserMapper userMapper;
     private final FlashDeliveryRouteService routeService;
     private final FlashDeliveryPricingCalculator pricingCalculator;
+    private final RiderDeliveryLockService riderDeliveryLockService;
+    private final RiderDeliveryExclusivityService riderDeliveryExclusivityService;
 
     public FlashDeliveryApplicationService(FlashDeliveryOrderMapper orderMapper,
                                            FlashDeliveryPricingMapper pricingMapper,
@@ -62,7 +66,9 @@ public class FlashDeliveryApplicationService {
                                            FlashDeliveryOrderLogMapper logMapper,
                                            InfoUserMapper userMapper,
                                            FlashDeliveryRouteService routeService,
-                                           FlashDeliveryPricingCalculator pricingCalculator) {
+                                           FlashDeliveryPricingCalculator pricingCalculator,
+                                           RiderDeliveryLockService riderDeliveryLockService,
+                                           RiderDeliveryExclusivityService riderDeliveryExclusivityService) {
         this.orderMapper = orderMapper;
         this.pricingMapper = pricingMapper;
         this.imageMapper = imageMapper;
@@ -70,6 +76,8 @@ public class FlashDeliveryApplicationService {
         this.userMapper = userMapper;
         this.routeService = routeService;
         this.pricingCalculator = pricingCalculator;
+        this.riderDeliveryLockService = riderDeliveryLockService;
+        this.riderDeliveryExclusivityService = riderDeliveryExclusivityService;
     }
 
     public FlashDeliveryHomeView home() {
@@ -138,6 +146,7 @@ public class FlashDeliveryApplicationService {
         order.setDeliveryPinCode(pinRequired ? String.format(Locale.ROOT, "%04d", PIN_RANDOM.nextInt(10_000)) : null);
         copyAddress(request.getPickup(), order, true);
         copyAddress(request.getDelivery(), order, false);
+        order.setReceiverUserId(resolveReceiverUserId(order.getDeliveryPhone()));
         order.setDistanceMeters(quote.route().distanceMeters());
         order.setDistanceSource(quote.route().source());
         order.setEstimatedDurationSeconds(quote.route().durationSeconds());
@@ -170,15 +179,20 @@ public class FlashDeliveryApplicationService {
         return participantDetail(order, true, false);
     }
 
-    public IPage<FlashDeliveryUserOrderListView> userOrders(Long userId, int pageNum, int pageSize) {
+    public IPage<FlashDeliveryUserOrderListView> userOrders(Long userId, int pageNum, int pageSize, String role) {
+        String selectedRole = hasText(role) ? role.trim().toLowerCase(Locale.ROOT) : "sender";
+        if (!"sender".equals(selectedRole) && !"receiver".equals(selectedRole)) {
+            throw fail("flash.delivery.tab.invalid");
+        }
+        String participantColumn = "receiver".equals(selectedRole) ? "receiver_user_id" : "user_id";
         QueryWrapper<FlashDeliveryOrder> query = new QueryWrapper<FlashDeliveryOrder>()
-                .eq("user_id", userId).orderByDesc("create_time");
+                .eq(participantColumn, userId).orderByDesc("create_time");
         return mapPage(orderMapper.selectPage(page(pageNum, pageSize), query), this::userOrderListView);
     }
 
     public FlashDeliveryOrderDetailView userDetail(Long userId, Long orderId) {
         FlashDeliveryOrder order = requireOrder(orderId);
-        if (!Objects.equals(userId, order.getUserId())) throw fail("flash.delivery.order.not.found");
+        if (!isParticipant(userId, order)) throw fail("flash.delivery.order.not.found");
         return participantDetail(order, true, false);
     }
 
@@ -198,9 +212,9 @@ public class FlashDeliveryApplicationService {
     @Transactional
     public void confirmReceipt(Long userId, Long orderId) {
         FlashDeliveryOrder order = requireOrder(orderId);
-        if (!Objects.equals(userId, order.getUserId())) throw fail("flash.delivery.order.not.found");
+        if (!isParticipant(userId, order)) throw fail("flash.delivery.order.not.found");
         Date now = new Date();
-        if (orderMapper.transitionByUser(orderId, userId, DELIVERED, COMPLETED, now) != 1) {
+        if (orderMapper.transitionByParticipant(orderId, userId, DELIVERED, COMPLETED, now) != 1) {
             throw fail("flash.delivery.complete.not.allowed");
         }
         writeLog(orderId, DELIVERED, COMPLETED, "USER", userId, null, now);
@@ -258,11 +272,17 @@ public class FlashDeliveryApplicationService {
         InfoUser rider = requireRider(riderId);
         // 抢单属于获取新任务,要求骑手承接闪送配送。
         if (!rider.supportsDeliveryType("FLASH")) throw fail("flash.delivery.rider.type.not.enabled");
-        Date now = new Date();
-        // Mapper 使用“待接单且 rider_id 为空”的条件更新,受影响行数为 1 才表示抢单成功。
-        if (orderMapper.accept(orderId, riderId, now) != 1) throw fail("flash.delivery.order.already.accepted");
-        writeLog(orderId, WAITING_ACCEPTANCE, ACCEPTED, "RIDER", riderId, null, now);
-        return participantDetail(requireOrder(orderId), false, false);
+        return riderDeliveryLockService.withLock(riderId, () -> {
+            FlashDeliveryOrder target = requireOrder(orderId);
+            riderDeliveryExclusivityService.assertCanAcceptFlash(riderId, target.getServiceType());
+            Date now = new Date();
+            // Mapper 使用“待接单且 rider_id 为空”的条件更新,受影响行数为 1 才表示抢单成功。
+            if (orderMapper.accept(orderId, riderId, now) != 1) {
+                throw fail("flash.delivery.order.already.accepted");
+            }
+            writeLog(orderId, WAITING_ACCEPTANCE, ACCEPTED, "RIDER", riderId, null, now);
+            return participantDetail(requireOrder(orderId), false, false);
+        });
     }
 
     @Transactional
@@ -775,6 +795,28 @@ public class FlashDeliveryApplicationService {
         }
     }
 
+    private Long resolveReceiverUserId(String phone) {
+        if (!hasText(phone)) return null;
+        StringBuilder normalized = new StringBuilder(phone.length());
+        for (int index = 0; index < phone.length(); index++) {
+            char value = phone.charAt(index);
+            if (Character.isWhitespace(value) || value == '+' || value == '-'
+                    || value == '(' || value == ')') {
+                continue;
+            }
+            if (value < '0' || value > '9') return null;
+            normalized.append(value);
+        }
+        if (normalized.isEmpty()) return null;
+        List<Long> matches = userMapper.selectOrdinaryUserIdsByNormalizedPhone(normalized.toString());
+        return matches != null && matches.size() == 1 ? matches.get(0) : null;
+    }
+
+    private boolean isParticipant(Long userId, FlashDeliveryOrder order) {
+        return Objects.equals(userId, order.getUserId())
+                || Objects.equals(userId, order.getReceiverUserId());
+    }
+
     private InfoUser requireRider(Long riderId) {
         InfoUser rider = riderId == null ? null : userMapper.selectInfoUserByUserId(riderId);
         if (rider == null || !"2".equals(rider.getUserType())) throw fail("flash.delivery.rider.required");

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

@@ -51,6 +51,8 @@ public class OrderLifecycleService {
     private final IPointsTransactionService pointsTransactionService;
     private final IPosOrderLinePaymentService linePaymentService;
     private final IPosOrderLineRefundService lineRefundService;
+    private final RiderDeliveryLockService riderDeliveryLockService;
+    private final RiderDeliveryExclusivityService riderDeliveryExclusivityService;
 
     public OrderLifecycleService(IPosOrderService posOrderService,
                                  OrderService billingService,
@@ -58,10 +60,9 @@ public class OrderLifecycleService {
                                  IUserWalletService userWalletService,
                                  IPointsTransactionService pointsTransactionService) {
         this(posOrderService, billingService, orderLogHelper, userWalletService,
-                pointsTransactionService, null, null);
+                pointsTransactionService, null, null, null, null);
     }
 
-    @Autowired
     public OrderLifecycleService(IPosOrderService posOrderService,
                                  OrderService billingService,
                                  OrderLogHelper orderLogHelper,
@@ -69,6 +70,20 @@ public class OrderLifecycleService {
                                  IPointsTransactionService pointsTransactionService,
                                  IPosOrderLinePaymentService linePaymentService,
                                  IPosOrderLineRefundService lineRefundService) {
+        this(posOrderService, billingService, orderLogHelper, userWalletService,
+                pointsTransactionService, linePaymentService, lineRefundService, null, null);
+    }
+
+    @Autowired
+    public OrderLifecycleService(IPosOrderService posOrderService,
+                                 OrderService billingService,
+                                 OrderLogHelper orderLogHelper,
+                                 IUserWalletService userWalletService,
+                                 IPointsTransactionService pointsTransactionService,
+                                 IPosOrderLinePaymentService linePaymentService,
+                                 IPosOrderLineRefundService lineRefundService,
+                                 RiderDeliveryLockService riderDeliveryLockService,
+                                 RiderDeliveryExclusivityService riderDeliveryExclusivityService) {
         this.posOrderService = posOrderService;
         this.billingService = billingService;
         this.orderLogHelper = orderLogHelper;
@@ -76,6 +91,8 @@ public class OrderLifecycleService {
         this.pointsTransactionService = pointsTransactionService;
         this.linePaymentService = linePaymentService;
         this.lineRefundService = lineRefundService;
+        this.riderDeliveryLockService = riderDeliveryLockService;
+        this.riderDeliveryExclusivityService = riderDeliveryExclusivityService;
     }
 
     public AdminOrderStatusContext getStatusContext(Long id) {
@@ -141,6 +158,14 @@ public class OrderLifecycleService {
 
     @Transactional(rollbackFor = Exception.class)
     public void acceptDeliveryByRider(Long id, Long riderId) {
+        riderDeliveryLockService.withLock(riderId, () -> {
+            riderDeliveryExclusivityService.assertCanAcceptFood(riderId);
+            acceptDeliveryByRiderLocked(id, riderId);
+            return null;
+        });
+    }
+
+    private void acceptDeliveryByRiderLocked(Long id, Long riderId) {
         PosOrder order = requireOrder(id);
         if (!Long.valueOf(0L).equals(order.getType()) || !Long.valueOf(0L).equals(order.getState())
                 || !Long.valueOf(0L).equals(order.getDeliveryStatus())

+ 61 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/order/RiderDeliveryExclusivityService.java

@@ -0,0 +1,61 @@
+package com.ruoyi.app.order;
+
+import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.common.utils.MessageUtils;
+import com.ruoyi.system.domain.PosOrder;
+import com.ruoyi.system.domain.flash.FlashDeliveryOrder;
+import com.ruoyi.system.mapper.flash.FlashDeliveryOrderMapper;
+import com.ruoyi.system.service.IPosOrderService;
+import org.springframework.stereotype.Service;
+
+import static com.ruoyi.system.domain.flash.FlashDeliveryStatus.ACCEPTED;
+import static com.ruoyi.system.domain.flash.FlashDeliveryStatus.PICKED_UP;
+
+/** 统一判断急送订单与外卖、普通闪送之间的骑手独占规则。 */
+@Service
+public class RiderDeliveryExclusivityService {
+    private static final String URGENT = "URGENT";
+
+    private final IPosOrderService foodOrders;
+    private final FlashDeliveryOrderMapper flashOrders;
+
+    public RiderDeliveryExclusivityService(IPosOrderService foodOrders,
+                                           FlashDeliveryOrderMapper flashOrders) {
+        this.foodOrders = foodOrders;
+        this.flashOrders = flashOrders;
+    }
+
+    public void assertCanAcceptFood(Long riderId) {
+        if (hasActiveFlash(riderId, true)) throw conflict();
+    }
+
+    public void assertCanAcceptFlash(Long riderId, String serviceType) {
+        if (URGENT.equals(serviceType)) {
+            if (hasActiveFood(riderId) || hasActiveFlash(riderId, false)) throw conflict();
+            return;
+        }
+        if (hasActiveFlash(riderId, true)) throw conflict();
+    }
+
+    private boolean hasActiveFood(Long riderId) {
+        QueryWrapper<PosOrder> query = new QueryWrapper<PosOrder>()
+                .eq("qs_id", riderId)
+                .in("delivery_status", 1, 2)
+                .in("state", 0, 1, 2)
+                .eq("after_sale_status", 0);
+        return foodOrders.count(query) > 0;
+    }
+
+    private boolean hasActiveFlash(Long riderId, boolean urgentOnly) {
+        QueryWrapper<FlashDeliveryOrder> query = new QueryWrapper<FlashDeliveryOrder>()
+                .eq("rider_id", riderId)
+                .in("status", ACCEPTED, PICKED_UP);
+        if (urgentOnly) query.eq("service_type", URGENT);
+        return flashOrders.selectCount(query) > 0;
+    }
+
+    private ServiceException conflict() {
+        return new ServiceException(MessageUtils.message("flash.delivery.rider.exclusive.conflict"));
+    }
+}

+ 70 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/order/RiderDeliveryLockService.java

@@ -0,0 +1,70 @@
+package com.ruoyi.app.order;
+
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.common.utils.MessageUtils;
+import org.redisson.api.RLock;
+import org.redisson.api.RedissonClient;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.support.TransactionSynchronization;
+import org.springframework.transaction.support.TransactionSynchronizationManager;
+
+import java.util.concurrent.TimeUnit;
+
+/** 串行化同一骑手跨外卖和闪送的接单检查与订单更新。 */
+@Service
+public class RiderDeliveryLockService {
+    private static final Logger log = LoggerFactory.getLogger(RiderDeliveryLockService.class);
+    private static final String PREFIX = "lock:delivery:rider:";
+    private static final long LOCK_WAIT_SECONDS = 3L;
+
+    private final RedissonClient redissonClient;
+
+    public RiderDeliveryLockService(RedissonClient redissonClient) {
+        this.redissonClient = redissonClient;
+    }
+
+    public <T> T withLock(Long riderId, LockedCall<T> call) {
+        if (riderId == null) throw new IllegalArgumentException("riderId is required");
+        RLock lock;
+        boolean acquired;
+        try {
+            lock = redissonClient.getLock(PREFIX + riderId);
+            // 不指定 leaseTime,由 Redisson watchdog 续期到事务完成。
+            acquired = lock.tryLock(LOCK_WAIT_SECONDS, TimeUnit.SECONDS);
+        } catch (InterruptedException interrupted) {
+            Thread.currentThread().interrupt();
+            throw new ServiceException(MessageUtils.message("no.operation.interrupted.try.again"));
+        } catch (RuntimeException redisFailure) {
+            log.error("Failed to acquire rider delivery lock, riderId={}", riderId, redisFailure);
+            throw new ServiceException(MessageUtils.message("no.system.busy.try.again"));
+        }
+        if (!acquired) {
+            log.warn("Rider delivery lock not acquired within {}s, riderId={}", LOCK_WAIT_SECONDS, riderId);
+            throw new ServiceException(MessageUtils.message("no.system.busy.try.again"));
+        }
+
+        boolean deferredUnlock = false;
+        try {
+            T result = call.call();
+            if (TransactionSynchronizationManager.isSynchronizationActive()) {
+                TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
+                    @Override
+                    public void afterCompletion(int status) {
+                        if (lock.isHeldByCurrentThread()) lock.unlock();
+                    }
+                });
+                deferredUnlock = true;
+            }
+            return result;
+        } finally {
+            if (!deferredUnlock && lock.isHeldByCurrentThread()) lock.unlock();
+        }
+    }
+
+    @FunctionalInterface
+    public interface LockedCall<T> {
+        T call();
+    }
+}

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

@@ -327,3 +327,4 @@ flash.delivery.coordinates.invalid=Latitude and longitude must be provided in pa
 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.exclusive.conflict=The rider has an active exclusive delivery and cannot accept this order

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

@@ -330,3 +330,4 @@ flash.delivery.coordinates.invalid=Latitude and longitude must be provided in pa
 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=This account is not enabled for flash delivery orders
+flash.delivery.rider.exclusive.conflict=The rider has an active exclusive delivery and cannot accept this order

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

@@ -330,3 +330,4 @@ flash.delivery.coordinates.invalid=Vĩ độ và kinh độ phải được cung
 address.access.denied=Địa chỉ không tồn tại hoặc không có quyền truy cập
 address.data.invalid=Thông tin liên hệ, địa chỉ hoặc tọa độ không hợp lệ
 flash.delivery.rider.type.not.enabled=Tài khoản này chưa mở giao hàng nhanh
+flash.delivery.rider.exclusive.conflict=Tài xế đang có nhiệm vụ giao hàng độc quyền và tạm thời không thể nhận đơn này

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

@@ -331,3 +331,4 @@ flash.delivery.coordinates.invalid=经纬度必须成对提供且范围有效
 address.access.denied=地址不存在或无权访问
 address.data.invalid=联系人、地址或经纬度信息无效
 flash.delivery.rider.type.not.enabled=当前账号未开通闪送配送
+flash.delivery.rider.exclusive.conflict=骑手存在进行中的独占配送任务,暂时不能接此订单

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

@@ -331,3 +331,4 @@ flash.delivery.coordinates.invalid=經緯度必須成對提供且範圍有效
 address.access.denied=地址不存在或無權存取
 address.data.invalid=聯絡人、地址或經緯度資訊無效
 flash.delivery.rider.type.not.enabled=當前賬號未開通闁送配送
+flash.delivery.rider.exclusive.conflict=騎手存在進行中的獨佔配送任務,暫時不能接此訂單

+ 3 - 1
ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/controller/FlashDeliveryControllerContractTest.java

@@ -73,10 +73,12 @@ class FlashDeliveryControllerContractTest {
     @Test
     void appOrderListsUseSimplePageAndRiderFoodOrderParameters() throws Exception {
         Method userOrders = FlashDeliveryUserController.class.getMethod("orders",
-                String.class, int.class, int.class);
+                String.class, int.class, int.class, String.class);
         assertNotNull(userOrders.getParameters()[0].getAnnotation(RequestHeader.class));
         assertNotNull(userOrders.getParameters()[1].getAnnotation(RequestParam.class));
         assertNotNull(userOrders.getParameters()[2].getAnnotation(RequestParam.class));
+        assertNotNull(userOrders.getParameters()[3].getAnnotation(RequestParam.class));
+        assertTrue(!userOrders.getParameters()[3].getAnnotation(RequestParam.class).required());
 
         Method riderOrders = FlashDeliveryRiderController.class.getMethod("orders",
                 String.class, int.class, int.class, String.class, BigDecimal.class, BigDecimal.class);

+ 137 - 3
ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryApplicationServiceTest.java

@@ -11,6 +11,8 @@ import com.ruoyi.app.flashdelivery.dto.FlashDeliveryDeliverRequest;
 import com.ruoyi.app.flashdelivery.dto.FlashDeliveryRiderOrderPageView;
 import com.ruoyi.app.flashdelivery.route.FlashDeliveryRouteService;
 import com.ruoyi.app.flashdelivery.route.RouteDistance;
+import com.ruoyi.app.order.RiderDeliveryExclusivityService;
+import com.ruoyi.app.order.RiderDeliveryLockService;
 import com.ruoyi.system.domain.InfoUser;
 import com.ruoyi.system.domain.flash.FlashDeliveryOrder;
 import com.ruoyi.system.domain.flash.FlashDeliveryOrderImage;
@@ -40,6 +42,7 @@ import java.util.Date;
 
 import static com.ruoyi.system.domain.flash.FlashDeliveryStatus.ACCEPTED;
 import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
 import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertNull;
 import static org.junit.jupiter.api.Assertions.assertThrows;
@@ -55,6 +58,7 @@ import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
 import static org.mockito.Mockito.doAnswer;
 import static org.mockito.Mockito.inOrder;
+import static org.mockito.Mockito.doThrow;
 
 class FlashDeliveryApplicationServiceTest {
     private static ConfigurableListableBeanFactory originalBeanFactory;
@@ -155,6 +159,8 @@ class FlashDeliveryApplicationServiceTest {
     @Test
     void scheduledCreateGeneratesUserOnlyPinAndStoresSenderProof() {
         Fixture fixture = new Fixture();
+        when(fixture.userMapper.selectOrdinaryUserIdsByNormalizedPhone("0922222222"))
+                .thenReturn(List.of(88L));
         when(fixture.pricingMapper.selectCurrent("HELP_SEND")).thenReturn(List.of(pricing()));
         when(fixture.routeService.calculate(any(), any())).thenReturn(new RouteDistance(3200, 600, "ROUTE"));
         doAnswer(invocation -> {
@@ -178,6 +184,7 @@ class FlashDeliveryApplicationServiceTest {
         verify(fixture.orderMapper).insert(saved.capture());
         assertEquals("SCHEDULED", saved.getValue().getDeliveryMode());
         assertEquals("DOCUMENT", saved.getValue().getPackageType());
+        assertEquals(88L, saved.getValue().getReceiverUserId());
         assertEquals(1L, saved.getValue().getPricingId());
         assertEquals("00:00", saved.getValue().getPricingStartTime());
         assertEquals("24:00", saved.getValue().getPricingEndTime());
@@ -198,7 +205,7 @@ class FlashDeliveryApplicationServiceTest {
         when(fixture.orderMapper.selectPage(any(com.baomidou.mybatisplus.core.metadata.IPage.class),
                 any(Wrapper.class))).thenReturn(source);
 
-        var result = fixture.service.userOrders(7L, 1, 10);
+        var result = fixture.service.userOrders(7L, 1, 10, null);
 
         ArgumentCaptor<com.baomidou.mybatisplus.core.conditions.query.QueryWrapper> query =
                 ArgumentCaptor.forClass(com.baomidou.mybatisplus.core.conditions.query.QueryWrapper.class);
@@ -213,6 +220,64 @@ class FlashDeliveryApplicationServiceTest {
         assertEquals(112L, result.getRecords().get(0).getAmount());
     }
 
+    @Test
+    void createDoesNotBindReceiverWhenNormalizedPhoneMatchesMultipleUsers() {
+        Fixture fixture = new Fixture();
+        when(fixture.pricingMapper.selectCurrent("HELP_SEND")).thenReturn(List.of(pricing()));
+        when(fixture.routeService.calculate(any(), any())).thenReturn(new RouteDistance(3200, 600, "ROUTE"));
+        when(fixture.userMapper.selectOrdinaryUserIdsByNormalizedPhone("886912345678"))
+                .thenReturn(List.of(88L, 99L));
+        doAnswer(invocation -> {
+            FlashDeliveryOrder order = invocation.getArgument(0);
+            order.setId(99L);
+            return 1;
+        }).when(fixture.orderMapper).insert(any(FlashDeliveryOrder.class));
+        FlashDeliveryCreateRequest request = createRequest();
+        request.getDelivery().setPhone("+886 912-345-678");
+
+        fixture.service.create(7L, request);
+
+        ArgumentCaptor<FlashDeliveryOrder> saved = ArgumentCaptor.forClass(FlashDeliveryOrder.class);
+        verify(fixture.orderMapper).insert(saved.capture());
+        assertNull(saved.getValue().getReceiverUserId());
+    }
+
+    @Test
+    void createLeavesReceiverUnboundWhenPhoneHasNoRegisteredUser() {
+        Fixture fixture = new Fixture();
+        when(fixture.pricingMapper.selectCurrent("HELP_SEND")).thenReturn(List.of(pricing()));
+        when(fixture.routeService.calculate(any(), any())).thenReturn(new RouteDistance(3200, 600, "ROUTE"));
+        when(fixture.userMapper.selectOrdinaryUserIdsByNormalizedPhone("0922222222"))
+                .thenReturn(List.of());
+        doAnswer(invocation -> {
+            FlashDeliveryOrder order = invocation.getArgument(0);
+            order.setId(99L);
+            return 1;
+        }).when(fixture.orderMapper).insert(any(FlashDeliveryOrder.class));
+
+        fixture.service.create(7L, createRequest());
+
+        ArgumentCaptor<FlashDeliveryOrder> saved = ArgumentCaptor.forClass(FlashDeliveryOrder.class);
+        verify(fixture.orderMapper).insert(saved.capture());
+        assertNull(saved.getValue().getReceiverUserId());
+    }
+
+    @Test
+    @SuppressWarnings({"rawtypes", "unchecked"})
+    void receiverListFiltersByCreationTimeReceiverBinding() {
+        Fixture fixture = new Fixture();
+        when(fixture.orderMapper.selectPage(any(com.baomidou.mybatisplus.core.metadata.IPage.class),
+                any(Wrapper.class))).thenReturn(new Page<FlashDeliveryOrder>());
+
+        fixture.service.userOrders(88L, 1, 10, "receiver");
+
+        ArgumentCaptor<com.baomidou.mybatisplus.core.conditions.query.QueryWrapper> query =
+                ArgumentCaptor.forClass(com.baomidou.mybatisplus.core.conditions.query.QueryWrapper.class);
+        verify(fixture.orderMapper).selectPage(any(com.baomidou.mybatisplus.core.metadata.IPage.class), query.capture());
+        assertTrue(query.getValue().getSqlSegment().toLowerCase().contains("receiver_user_id"));
+        assertTrue(query.getValue().getParamNameValuePairs().values().contains(88L));
+    }
+
     @Test
     @SuppressWarnings({"rawtypes", "unchecked"})
     void riderNewTaskUsesFoodOrderParametersAndReturnsPrototypeCardSummary() {
@@ -439,6 +504,36 @@ class FlashDeliveryApplicationServiceTest {
         verify(fixture.logMapper, never()).selectList(any());
     }
 
+    @Test
+    void matchedReceiverCanViewAndConfirmButCannotCancelOrder() {
+        Fixture fixture = new Fixture();
+        FlashDeliveryOrder order = order(10L, "DELIVERED");
+        order.setReceiverUserId(88L);
+        when(fixture.orderMapper.selectById(10L)).thenReturn(order);
+        when(fixture.orderMapper.transitionByParticipant(eq(10L), eq(88L), eq("DELIVERED"),
+                eq("COMPLETED"), any(Date.class))).thenReturn(1);
+        when(fixture.imageMapper.selectList(any(Wrapper.class))).thenReturn(List.of());
+
+        assertDoesNotThrow(() -> fixture.service.userDetail(88L, 10L));
+        assertDoesNotThrow(() -> fixture.service.confirmReceipt(88L, 10L));
+        assertThrows(ServiceException.class,
+                () -> fixture.service.userCancel(88L, 10L, new com.ruoyi.app.flashdelivery.dto.FlashDeliveryReasonRequest()));
+        verify(fixture.orderMapper).transitionByParticipant(eq(10L), eq(88L), eq("DELIVERED"),
+                eq("COMPLETED"), any(Date.class));
+    }
+
+    @Test
+    void orderWithoutCreationTimeReceiverBindingIsNotClaimedLater() {
+        Fixture fixture = new Fixture();
+        FlashDeliveryOrder historicalOrder = order(10L, "DELIVERED");
+        historicalOrder.setReceiverUserId(null);
+        when(fixture.orderMapper.selectById(10L)).thenReturn(historicalOrder);
+
+        assertThrows(ServiceException.class, () -> fixture.service.userDetail(88L, 10L));
+
+        verify(fixture.userMapper, never()).selectOrdinaryUserIdsByNormalizedPhone(any());
+    }
+
     @Test
     void pickupRejectsMissingProofBeforeChangingState() {
         Fixture fixture = new Fixture();
@@ -459,11 +554,43 @@ class FlashDeliveryApplicationServiceTest {
         InfoUser rider = new InfoUser();
         rider.setUserId(8L);
         rider.setUserType("2");
+        rider.setDeliveryType("FLASH");
         when(fixture.userMapper.selectInfoUserByUserId(8L)).thenReturn(rider);
+        when(fixture.orderMapper.selectById(10L)).thenReturn(order(10L, "WAITING_ACCEPTANCE"));
         when(fixture.orderMapper.accept(eq(10L), eq(8L), any())).thenReturn(0);
 
         assertThrows(RuntimeException.class, () -> fixture.service.accept(8L, 10L));
-        verify(fixture.orderMapper, never()).selectById(10L);
+    }
+
+    @Test
+    void flashAcceptUsesRiderLockAndChecksExclusivityBeforeAtomicUpdate() {
+        Fixture fixture = new Fixture();
+        rider(fixture, 8L);
+        FlashDeliveryOrder waiting = order(10L, "WAITING_ACCEPTANCE");
+        FlashDeliveryOrder accepted = order(10L, ACCEPTED);
+        accepted.setRiderId(8L);
+        when(fixture.orderMapper.selectById(10L)).thenReturn(waiting, accepted);
+        when(fixture.orderMapper.accept(eq(10L), eq(8L), any(Date.class))).thenReturn(1);
+
+        fixture.service.accept(8L, 10L);
+
+        InOrder order = inOrder(fixture.lockService, fixture.exclusivityService, fixture.orderMapper);
+        order.verify(fixture.lockService).withLock(eq(8L), any(RiderDeliveryLockService.LockedCall.class));
+        order.verify(fixture.exclusivityService).assertCanAcceptFlash(8L, "HELP_SEND");
+        order.verify(fixture.orderMapper).accept(eq(10L), eq(8L), any(Date.class));
+    }
+
+    @Test
+    void flashExclusiveConflictDoesNotUpdateOrder() {
+        Fixture fixture = new Fixture();
+        rider(fixture, 8L);
+        when(fixture.orderMapper.selectById(10L)).thenReturn(order(10L, "WAITING_ACCEPTANCE"));
+        doThrow(new ServiceException("exclusive"))
+                .when(fixture.exclusivityService).assertCanAcceptFlash(8L, "HELP_SEND");
+
+        assertThrows(ServiceException.class, () -> fixture.service.accept(8L, 10L));
+
+        verify(fixture.orderMapper, never()).accept(any(), any(), any());
     }
 
     @Test
@@ -722,6 +849,7 @@ class FlashDeliveryApplicationServiceTest {
         InfoUser rider = new InfoUser();
         rider.setUserId(riderId);
         rider.setUserType("2");
+        rider.setDeliveryType("FLASH");
         when(fixture.userMapper.selectInfoUserByUserId(riderId)).thenReturn(rider);
     }
 
@@ -776,13 +904,19 @@ class FlashDeliveryApplicationServiceTest {
         final FlashDeliveryOrderLogMapper logMapper = mock(FlashDeliveryOrderLogMapper.class);
         final InfoUserMapper userMapper = mock(InfoUserMapper.class);
         final FlashDeliveryRouteService routeService = mock(FlashDeliveryRouteService.class);
+        final RiderDeliveryLockService lockService = mock(RiderDeliveryLockService.class);
+        final RiderDeliveryExclusivityService exclusivityService = mock(RiderDeliveryExclusivityService.class);
         final FlashDeliveryApplicationService service = new FlashDeliveryApplicationService(
                 orderMapper, pricingMapper, imageMapper, logMapper, userMapper,
-                routeService, new FlashDeliveryPricingCalculator());
+                routeService, new FlashDeliveryPricingCalculator(), lockService, exclusivityService);
 
         Fixture() {
             when(pricingMapper.lockServiceType(any()))
                     .thenAnswer(invocation -> invocation.getArgument(0));
+            when(lockService.withLock(any(), any())).thenAnswer(invocation -> {
+                RiderDeliveryLockService.LockedCall<?> call = invocation.getArgument(1);
+                return call.call();
+            });
         }
     }
 }

+ 1 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryI18nContractTest.java

@@ -26,6 +26,7 @@ class FlashDeliveryI18nContractTest {
             "flash.delivery.pricing.invalid", "flash.delivery.pricing.not.available",
             "flash.delivery.pricing.overlap", "flash.delivery.pricing.not.found",
             "flash.delivery.pricing.changed",
+            "flash.delivery.rider.exclusive.conflict",
             "address.access.denied", "address.data.invalid");
 
     @Test

+ 42 - 1
ruoyi-admin/src/test/java/com/ruoyi/app/order/OrderLifecycleServiceTest.java

@@ -28,11 +28,15 @@ import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.anyInt;
 import static org.mockito.ArgumentMatchers.anyLong;
 import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
 import static org.mockito.Mockito.doAnswer;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.never;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.inOrder;
+import org.mockito.InOrder;
 
 class OrderLifecycleServiceTest {
 
@@ -41,6 +45,8 @@ class OrderLifecycleServiceTest {
     private OrderLogHelper orderLogHelper;
     private IUserWalletService userWalletService;
     private IPointsTransactionService pointsTransactionService;
+    private RiderDeliveryLockService riderDeliveryLockService;
+    private RiderDeliveryExclusivityService riderDeliveryExclusivityService;
     private OrderLifecycleService service;
 
     @BeforeEach
@@ -50,8 +56,15 @@ class OrderLifecycleServiceTest {
         orderLogHelper = mock(OrderLogHelper.class);
         userWalletService = mock(IUserWalletService.class);
         pointsTransactionService = mock(IPointsTransactionService.class);
+        riderDeliveryLockService = mock(RiderDeliveryLockService.class);
+        riderDeliveryExclusivityService = mock(RiderDeliveryExclusivityService.class);
+        when(riderDeliveryLockService.withLock(any(), any())).thenAnswer(invocation -> {
+            RiderDeliveryLockService.LockedCall<?> call = invocation.getArgument(1);
+            return call.call();
+        });
         service = new OrderLifecycleService(posOrderService, billingService, orderLogHelper,
-                userWalletService, pointsTransactionService);
+                userWalletService, pointsTransactionService, null, null,
+                riderDeliveryLockService, riderDeliveryExclusivityService);
     }
 
     @Test
@@ -212,6 +225,34 @@ class OrderLifecycleServiceTest {
                 () -> service.pickupDeliveryByRider(10L, 88L, "proof"));
     }
 
+    @Test
+    void foodAcceptUsesSharedRiderLockAndChecksExclusivityBeforeCas() {
+        PosOrder order = deliveryOrder();
+        order.setState(0L);
+        order.setDeliveryStatus(0L);
+        order.setQsId(null);
+        when(posOrderService.getById(10L)).thenReturn(order);
+        when(posOrderService.update(any(PosOrder.class), any(Wrapper.class))).thenReturn(true);
+
+        service.acceptDeliveryByRider(10L, 88L);
+
+        InOrder calls = inOrder(riderDeliveryLockService, riderDeliveryExclusivityService, posOrderService);
+        calls.verify(riderDeliveryLockService)
+                .withLock(eq(88L), any(RiderDeliveryLockService.LockedCall.class));
+        calls.verify(riderDeliveryExclusivityService).assertCanAcceptFood(88L);
+        calls.verify(posOrderService).update(any(PosOrder.class), any(Wrapper.class));
+    }
+
+    @Test
+    void foodExclusiveConflictDoesNotUpdateOrder() {
+        doThrow(new ServiceException("exclusive"))
+                .when(riderDeliveryExclusivityService).assertCanAcceptFood(88L);
+
+        assertThrows(ServiceException.class, () -> service.acceptDeliveryByRider(10L, 88L));
+
+        verify(posOrderService, never()).update(any(PosOrder.class), any(Wrapper.class));
+    }
+
     @Test
     void marksDeliveryAsDeliveredAndCompletesExactlyOnce() {
         PosOrder order = deliveryOrder();

+ 9 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/order/PosOrderQsOprateControllerTest.java

@@ -6,6 +6,8 @@ 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.system.domain.PosOrder;
+import com.ruoyi.system.domain.InfoUser;
+import com.ruoyi.app.service.UserService;
 import com.ruoyi.system.service.IInfoUserService;
 import com.ruoyi.system.service.IPosOrderService;
 import com.ruoyi.system.service.IPosStoreService;
@@ -55,6 +57,13 @@ class PosOrderQsOprateControllerTest {
         ReflectionTestUtils.setField(controller, "posOrderService", orders);
         ReflectionTestUtils.setField(controller, "posStoreService", mock(IPosStoreService.class));
         ReflectionTestUtils.setField(controller, "infoUserService", mock(IInfoUserService.class));
+        UserService userService = mock(UserService.class);
+        InfoUser rider = new InfoUser();
+        rider.setUserId(88L);
+        rider.setUserType("2");
+        rider.setDeliveryType("FOOD");
+        when(userService.checkUserStatus(88L)).thenReturn(rider);
+        ReflectionTestUtils.setField(controller, "userService", userService);
         Page<PosOrder> emptyPage = new Page<>(1, 10);
         emptyPage.setRecords(Collections.emptyList());
         when(orders.page(any(Page.class), any(Wrapper.class))).thenReturn(emptyPage);

+ 121 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/order/RiderDeliveryExclusivityServiceTest.java

@@ -0,0 +1,121 @@
+package com.ruoyi.app.order;
+
+import com.baomidou.mybatisplus.core.conditions.Wrapper;
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.common.utils.spring.SpringUtils;
+import com.ruoyi.system.mapper.flash.FlashDeliveryOrderMapper;
+import com.ruoyi.system.service.IPosOrderService;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+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 static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+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.when;
+import static org.mockito.Mockito.verify;
+
+class RiderDeliveryExclusivityServiceTest {
+    private static ConfigurableListableBeanFactory originalBeanFactory;
+
+    @BeforeAll
+    static void initializeMessages() {
+        originalBeanFactory = (ConfigurableListableBeanFactory)
+                ReflectionTestUtils.getField(SpringUtils.class, "beanFactory");
+        DefaultListableBeanFactory beanFactory = new DefaultListableBeanFactory();
+        StaticMessageSource messageSource = new StaticMessageSource();
+        messageSource.setUseCodeAsDefaultMessage(true);
+        beanFactory.registerSingleton("messageSource", messageSource);
+        new SpringUtils().postProcessBeanFactory(beanFactory);
+    }
+
+    @AfterAll
+    static void restoreMessages() {
+        new SpringUtils().postProcessBeanFactory(originalBeanFactory);
+    }
+
+    @Test
+    void urgentRejectsActiveFood() {
+        Fixture fixture = new Fixture();
+        when(fixture.foodOrders.count(any(Wrapper.class))).thenReturn(1L);
+        assertThrows(ServiceException.class,
+                () -> fixture.service.assertCanAcceptFlash(123L, "URGENT"));
+    }
+
+    @Test
+    void urgentRejectsActiveNormalFlash() {
+        Fixture fixture = new Fixture();
+        when(fixture.foodOrders.count(any(Wrapper.class))).thenReturn(0L);
+        when(fixture.flashOrders.selectCount(any(Wrapper.class))).thenReturn(1L);
+        assertThrows(ServiceException.class,
+                () -> fixture.service.assertCanAcceptFlash(123L, "URGENT"));
+    }
+
+    @Test
+    void urgentAllowsRiderWithoutActiveWork() {
+        Fixture fixture = new Fixture();
+        when(fixture.foodOrders.count(any(Wrapper.class))).thenReturn(0L);
+        when(fixture.flashOrders.selectCount(any(Wrapper.class))).thenReturn(0L);
+        assertDoesNotThrow(() -> fixture.service.assertCanAcceptFlash(123L, "URGENT"));
+    }
+
+    @Test
+    void normalFlashRejectsActiveUrgent() {
+        Fixture fixture = new Fixture();
+        when(fixture.flashOrders.selectCount(any(Wrapper.class))).thenReturn(1L);
+        assertThrows(ServiceException.class,
+                () -> fixture.service.assertCanAcceptFlash(123L, "HELP_SEND"));
+    }
+
+    @Test
+    void foodRejectsActiveUrgent() {
+        Fixture fixture = new Fixture();
+        when(fixture.flashOrders.selectCount(any(Wrapper.class))).thenReturn(1L);
+        assertThrows(ServiceException.class, () -> fixture.service.assertCanAcceptFood(123L));
+    }
+
+    @Test
+    void foodAllowsActiveNormalFlashWhenNoUrgentExists() {
+        Fixture fixture = new Fixture();
+        when(fixture.flashOrders.selectCount(any(Wrapper.class))).thenReturn(0L);
+        assertDoesNotThrow(() -> fixture.service.assertCanAcceptFood(123L));
+    }
+
+    @Test
+    @SuppressWarnings({"rawtypes", "unchecked"})
+    void activeQueriesUseOnlyDefinedFoodAndFlashStates() {
+        Fixture fixture = new Fixture();
+        when(fixture.foodOrders.count(any(Wrapper.class))).thenReturn(0L);
+        when(fixture.flashOrders.selectCount(any(Wrapper.class))).thenReturn(0L);
+
+        fixture.service.assertCanAcceptFlash(123L, "URGENT");
+
+        ArgumentCaptor<Wrapper> foodQuery = ArgumentCaptor.forClass(Wrapper.class);
+        ArgumentCaptor<Wrapper> flashQuery = ArgumentCaptor.forClass(Wrapper.class);
+        verify(fixture.foodOrders).count(foodQuery.capture());
+        verify(fixture.flashOrders).selectCount(flashQuery.capture());
+        String foodSql = foodQuery.getValue().getSqlSegment().toLowerCase();
+        String flashSql = flashQuery.getValue().getSqlSegment().toLowerCase();
+        assertTrue(foodSql.contains("qs_id"));
+        assertTrue(foodSql.contains("delivery_status"));
+        assertTrue(foodSql.contains("state"));
+        assertTrue(foodSql.contains("after_sale_status"));
+        assertTrue(flashSql.contains("rider_id"));
+        assertTrue(flashSql.contains("status"));
+        assertTrue(!flashSql.contains("service_type"));
+    }
+
+    private static class Fixture {
+        final IPosOrderService foodOrders = mock(IPosOrderService.class);
+        final FlashDeliveryOrderMapper flashOrders = mock(FlashDeliveryOrderMapper.class);
+        final RiderDeliveryExclusivityService service =
+                new RiderDeliveryExclusivityService(foodOrders, flashOrders);
+    }
+}

+ 109 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/order/RiderDeliveryLockServiceTest.java

@@ -0,0 +1,109 @@
+package com.ruoyi.app.order;
+
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.common.utils.spring.SpringUtils;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.redisson.api.RLock;
+import org.redisson.api.RedissonClient;
+import org.springframework.transaction.support.TransactionSynchronization;
+import org.springframework.transaction.support.TransactionSynchronizationManager;
+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.List;
+import java.util.concurrent.TimeUnit;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+class RiderDeliveryLockServiceTest {
+    private static ConfigurableListableBeanFactory originalBeanFactory;
+
+    @BeforeAll
+    static void initializeMessages() {
+        originalBeanFactory = (ConfigurableListableBeanFactory)
+                ReflectionTestUtils.getField(SpringUtils.class, "beanFactory");
+        DefaultListableBeanFactory beanFactory = new DefaultListableBeanFactory();
+        StaticMessageSource messageSource = new StaticMessageSource();
+        messageSource.setUseCodeAsDefaultMessage(true);
+        beanFactory.registerSingleton("messageSource", messageSource);
+        new SpringUtils().postProcessBeanFactory(beanFactory);
+    }
+
+    @AfterAll
+    static void restoreMessages() {
+        new SpringUtils().postProcessBeanFactory(originalBeanFactory);
+    }
+
+    @Test
+    void usesSharedRiderLockWithWatchdogAndReleasesOutsideTransaction() throws Exception {
+        RedissonClient redisson = mock(RedissonClient.class);
+        RLock lock = mock(RLock.class);
+        when(redisson.getLock("lock:delivery:rider:123")).thenReturn(lock);
+        when(lock.tryLock(3L, TimeUnit.SECONDS)).thenReturn(true);
+        when(lock.isHeldByCurrentThread()).thenReturn(true);
+        RiderDeliveryLockService service = new RiderDeliveryLockService(redisson);
+
+        String result = service.withLock(123L, () -> "accepted");
+
+        assertEquals("accepted", result);
+        verify(lock).tryLock(3L, TimeUnit.SECONDS);
+        verify(lock).unlock();
+    }
+
+    @Test
+    void defersUnlockUntilTransactionCompletion() throws Exception {
+        RedissonClient redisson = mock(RedissonClient.class);
+        RLock lock = mock(RLock.class);
+        when(redisson.getLock("lock:delivery:rider:123")).thenReturn(lock);
+        when(lock.tryLock(3L, TimeUnit.SECONDS)).thenReturn(true);
+        when(lock.isHeldByCurrentThread()).thenReturn(true);
+        RiderDeliveryLockService service = new RiderDeliveryLockService(redisson);
+        TransactionSynchronizationManager.initSynchronization();
+        try {
+            assertEquals("accepted", service.withLock(123L, () -> "accepted"));
+            verify(lock, never()).unlock();
+            List<TransactionSynchronization> synchronizations =
+                    TransactionSynchronizationManager.getSynchronizations();
+            assertEquals(1, synchronizations.size());
+
+            synchronizations.get(0).afterCompletion(TransactionSynchronization.STATUS_COMMITTED);
+
+            verify(lock).unlock();
+        } finally {
+            TransactionSynchronizationManager.clearSynchronization();
+        }
+    }
+
+    @Test
+    void rejectsTimeoutInterruptionAndRedisFailure() throws Exception {
+        RedissonClient timeoutRedisson = mock(RedissonClient.class);
+        RLock timeoutLock = mock(RLock.class);
+        when(timeoutRedisson.getLock("lock:delivery:rider:123")).thenReturn(timeoutLock);
+        when(timeoutLock.tryLock(3L, TimeUnit.SECONDS)).thenReturn(false);
+        assertThrows(ServiceException.class,
+                () -> new RiderDeliveryLockService(timeoutRedisson).withLock(123L, () -> "ignored"));
+
+        RedissonClient interruptedRedisson = mock(RedissonClient.class);
+        RLock interruptedLock = mock(RLock.class);
+        when(interruptedRedisson.getLock("lock:delivery:rider:123")).thenReturn(interruptedLock);
+        when(interruptedLock.tryLock(3L, TimeUnit.SECONDS)).thenThrow(new InterruptedException());
+        assertThrows(ServiceException.class,
+                () -> new RiderDeliveryLockService(interruptedRedisson).withLock(123L, () -> "ignored"));
+        assertTrue(Thread.interrupted());
+
+        RedissonClient failedRedisson = mock(RedissonClient.class);
+        when(failedRedisson.getLock("lock:delivery:rider:123")).thenThrow(new IllegalStateException("redis down"));
+        assertThrows(ServiceException.class,
+                () -> new RiderDeliveryLockService(failedRedisson).withLock(123L, () -> "ignored"));
+    }
+}

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

@@ -18,6 +18,8 @@ public class FlashDeliveryOrder {
     /** 客户端请求幂等号,与 userId 共同建立唯一约束。 */
     private String clientRequestId;
     private Long userId;
+    /** 创建订单时按收件手机号唯一匹配的普通用户;匹配后不再动态追溯。 */
+    private Long receiverUserId;
     /** 抢单前为空,原子抢单成功后绑定且不允许骑手主动放弃。 */
     private Long riderId;
     private String serviceType;

+ 3 - 0
ruoyi-system/src/main/java/com/ruoyi/system/mapper/InfoUserMapper.java

@@ -28,6 +28,9 @@ public interface InfoUserMapper  extends BaseMapper<InfoUser>
      */
     public InfoUser selectInfoUserByUserId(Long userId);
 
+    /** 按标准化手机号查询有效普通用户,最多返回两条以识别重复账号。 */
+    public List<Long> selectOrdinaryUserIdsByNormalizedPhone(@Param("normalizedPhone") String normalizedPhone);
+
     /**
      * 查询用户信息列表
      *

+ 3 - 3
ruoyi-system/src/main/java/com/ruoyi/system/mapper/flash/FlashDeliveryOrderMapper.java

@@ -27,9 +27,9 @@ public interface FlashDeliveryOrderMapper extends BaseMapper<FlashDeliveryOrder>
                           @Param("timeColumn") String timeColumn, @Param("now") Date now);
 
     /** 按订单所有人和预期状态完成用户确认收货。 */
-    int transitionByUser(@Param("id") Long id, @Param("userId") Long userId,
-                         @Param("expectedStatus") String expectedStatus,
-                         @Param("nextStatus") String nextStatus, @Param("now") Date now);
+    int transitionByParticipant(@Param("id") Long id, @Param("userId") Long userId,
+                                @Param("expectedStatus") String expectedStatus,
+                                @Param("nextStatus") String nextStatus, @Param("now") Date now);
 
     /** 供平台和系统任务按预期状态执行条件更新。 */
     int transitionByStatus(@Param("id") Long id, @Param("expectedStatus") String expectedStatus,

+ 3 - 2
ruoyi-system/src/main/resources/mapper/flash/FlashDeliveryOrderMapper.xml

@@ -6,6 +6,7 @@
         <result property="orderNo" column="order_no"/>
         <result property="clientRequestId" column="client_request_id"/>
         <result property="userId" column="user_id"/>
+        <result property="receiverUserId" column="receiver_user_id"/>
         <result property="riderId" column="rider_id"/>
         <result property="serviceType" column="service_type"/>
         <result property="status" column="status"/>
@@ -84,10 +85,10 @@
     </update>
 
     <!-- 用户确认收货必须同时满足订单归属和已送达状态。 -->
-    <update id="transitionByUser">
+    <update id="transitionByParticipant">
         update flash_delivery_order
         set status=#{nextStatus}, completed_at=#{now}, update_time=#{now}, version = version + 1
-        where id=#{id} and user_id=#{userId} and status=#{expectedStatus}
+        where id=#{id} and (user_id=#{userId} or receiver_user_id = #{userId}) and status=#{expectedStatus}
     </update>
 
     <!-- 平台/系统完成同样使用预期状态条件,防止与用户确认并发重复成功。 -->

+ 11 - 0
ruoyi-system/src/main/resources/mapper/infouser/InfoUserMapper.xml

@@ -102,6 +102,17 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         where user_id = #{userId}
     </select>
 
+    <select id="selectOrdinaryUserIdsByNormalizedPhone" resultType="java.lang.Long">
+        select user_id
+        from info_user
+        where user_type = '0'
+          and status = '0'
+          and (del_flag = '0' or del_flag is null)
+          and regexp_replace(phone, '[[:space:]()+-]', '') = #{normalizedPhone}
+        order by user_id
+        limit 2
+    </select>
+
     <insert id="insertInfoUser" parameterType="InfoUser" useGeneratedKeys="true" keyProperty="userId">
         insert into info_user
         <trim prefix="(" suffix=")" suffixOverrides=",">

+ 19 - 0
ruoyi-system/src/test/java/com/ruoyi/system/mapper/flash/FlashDeliveryMapperXmlTest.java

@@ -17,6 +17,23 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
 
 class FlashDeliveryMapperXmlTest {
 
+    @Test
+    void receiverLookupRequiresUniqueEligibleNormalizedPhoneCandidate() throws Exception {
+        String resource = "mapper/infouser/InfoUserMapper.xml";
+        String xml;
+        try (InputStream input = getClass().getClassLoader().getResourceAsStream(resource)) {
+            assertNotNull(input);
+            xml = new String(input.readAllBytes(), StandardCharsets.UTF_8).toLowerCase();
+        }
+
+        assertTrue(xml.contains("selectordinaryuseridsbynormalizedphone"));
+        assertTrue(xml.contains("user_type = '0'"));
+        assertTrue(xml.contains("status = '0'"));
+        assertTrue(xml.contains("del_flag = '0' or del_flag is null"));
+        assertTrue(xml.contains("regexp_replace(phone"));
+        assertTrue(xml.contains("limit 2"));
+    }
+
     @Test
     void orderMapperMapsSnapshotsAndUsesConditionalAtomicUpdates() throws Exception {
         String resource = "mapper/flash/FlashDeliveryOrderMapper.xml";
@@ -42,6 +59,7 @@ class FlashDeliveryMapperXmlTest {
                 .map(ResultMapping::getProperty)
                 .collect(Collectors.toSet());
         assertTrue(mappedProperties.containsAll(Set.of(
+                "receiverUserId",
                 "pricingId", "pricingVersion", "pricingStartTime", "pricingEndTime",
                 "startingDistance", "startingFare", "distance", "freight", "distanceFee")));
         assertTrue(xml.contains("status = 'WAITING_ACCEPTANCE'"));
@@ -51,5 +69,6 @@ class FlashDeliveryMapperXmlTest {
         assertTrue(xml.contains("delivery_pin_code"));
         assertTrue(xml.contains("delivered_at &lt;="));
         assertTrue(xml.contains("version = version + 1"));
+        assertTrue(xml.contains("receiver_user_id = #{userId}"));
     }
 }

+ 15 - 15
specs/024-flash-delivery/implementation-plan-2026-09-04.md

@@ -38,7 +38,7 @@
 - Consumes: `delivery.phone` from `FlashDeliveryCreateRequest` and token-derived current user ID.
 - Produces: `FlashDeliveryOrder.receiverUserId`; `InfoUserMapper.selectOrdinaryUserIdsByNormalizedPhone(String)`; `FlashDeliveryOrderMapper.transitionByParticipant(...)`; `GET /system/flashDelivery/orders?page=1&size=10&role=receiver`; participant-aware detail and confirmation.
 
-- [ ] **Step 1: Write receiver-binding and participant-access tests**
+- [x] **Step 1: Write receiver-binding and participant-access tests**
 
 Add service tests whose observable assertions are:
 
@@ -63,7 +63,7 @@ assertThrows(ServiceException.class,
 
 Add Controller reflection tests requiring optional `@RequestParam String role`. Extend the mapper XML test to assert `receiverUserId` mapping and participant transition SQL.
 
-- [ ] **Step 2: Run tests and verify RED**
+- [x] **Step 2: Run tests and verify RED**
 
 ```powershell
 $env:JAVA_HOME='C:\Users\qmj\.jdks\graalvm-jdk-21.0.7'
@@ -73,7 +73,7 @@ mvn -pl ruoyi-admin -am -Dtest='FlashDeliveryApplicationServiceTest,FlashDeliver
 
 Expected: compilation or assertions fail because `receiverUserId`, role-aware list, participant transition and phone lookup do not exist.
 
-- [ ] **Step 3: Implement the minimal receiver model and queries**
+- [x] **Step 3: Implement the minimal receiver model and queries**
 
 Add:
 
@@ -104,7 +104,7 @@ WHERE id = #{id}
   AND status = #{expectedStatus}
 ```
 
-- [ ] **Step 4: Run Task 1 tests and verify GREEN**
+- [x] **Step 4: Run Task 1 tests and verify GREEN**
 
 Run the Step 2 command. Expected: all specified tests pass with zero failures and errors.
 
@@ -127,7 +127,7 @@ Run the Step 2 command. Expected: all specified tests pass with zero failures an
 - Consumes: `RedissonClient`, `IPosOrderService`, `FlashDeliveryOrderMapper`, rider ID and target service type.
 - Produces: `RiderDeliveryLockService.withLock(Long, LockedCall<T>)`; `RiderDeliveryExclusivityService.assertCanAcceptFood(Long)`; `assertCanAcceptFlash(Long,String)`.
 
-- [ ] **Step 1: Write failing lock lifecycle tests**
+- [x] **Step 1: Write failing lock lifecycle tests**
 
 Test the real lock wrapper behavior around a mocked external Redisson boundary:
 
@@ -141,7 +141,7 @@ assertEquals("accepted", result);
 
 Verify through behavior that acquisition timeout, interruption and runtime Redis failure throw `ServiceException`; outside a transaction the lock releases in `finally`; with active transaction synchronization it remains held until `afterCompletion`.
 
-- [ ] **Step 2: Write failing exclusivity tests**
+- [x] **Step 2: Write failing exclusivity tests**
 
 Cover these literal outcomes:
 
@@ -156,7 +156,7 @@ food + active normal flash   -> allow
 
 Active flash statuses are exactly `ACCEPTED` and `PICKED_UP`; active food uses `qs_id=riderId`, `delivery_status in (1,2)`, `state in (0,1,2)` and `after_sale_status=0`.
 
-- [ ] **Step 3: Run tests and verify RED**
+- [x] **Step 3: Run tests and verify RED**
 
 ```powershell
 mvn -pl ruoyi-admin -am -Dtest='RiderDeliveryLockServiceTest,RiderDeliveryExclusivityServiceTest' -Dsurefire.failIfNoSpecifiedTests=false test
@@ -164,7 +164,7 @@ mvn -pl ruoyi-admin -am -Dtest='RiderDeliveryLockServiceTest,RiderDeliveryExclus
 
 Expected: compilation fails because both services are absent.
 
-- [ ] **Step 4: Implement lock and exclusivity services**
+- [x] **Step 4: Implement lock and exclusivity services**
 
 `RiderDeliveryLockService` uses:
 
@@ -177,7 +177,7 @@ Do not pass a lease time so Redisson watchdog renews the lock. Register `Transac
 
 `RiderDeliveryExclusivityService` uses real MyBatis-Plus count queries and throws the new `flash.delivery.rider.exclusive.conflict` message when rules reject acceptance.
 
-- [ ] **Step 5: Run Task 2 tests and verify GREEN**
+- [x] **Step 5: Run Task 2 tests and verify GREEN**
 
 Run the Step 3 command. Expected: all specified tests pass with zero failures and errors.
 
@@ -199,11 +199,11 @@ Run the Step 3 command. Expected: all specified tests pass with zero failures an
 - Consumes: Task 1 participant model and Task 2 lock/exclusivity services.
 - Produces: both effective acceptance paths execute eligibility checks and order update under `lock:delivery:rider:{riderId}`; documented receiver role API and SQL migration.
 
-- [ ] **Step 1: Write failing integration-level service tests**
+- [x] **Step 1: Write failing integration-level service tests**
 
 For flash acceptance, assert the returned order is accepted only when `withLock` invokes the callback and exclusivity allows it; assert exclusive conflict leaves `orderMapper.accept` uncalled. For food acceptance, assert `OrderLifecycleService.acceptDeliveryByRider` enters the same rider lock and checks `assertCanAcceptFood` before applying CAS.
 
-- [ ] **Step 2: Run tests and verify RED**
+- [x] **Step 2: Run tests and verify RED**
 
 ```powershell
 mvn -pl ruoyi-admin -am -Dtest='FlashDeliveryApplicationServiceTest,OrderLifecycleServiceTest,RiderDeliveryLockServiceTest,RiderDeliveryExclusivityServiceTest' -Dsurefire.failIfNoSpecifiedTests=false test
@@ -211,7 +211,7 @@ mvn -pl ruoyi-admin -am -Dtest='FlashDeliveryApplicationServiceTest,OrderLifecyc
 
 Expected: assertions fail because acceptance methods do not invoke the shared lock and exclusivity services.
 
-- [ ] **Step 3: Integrate both acceptance services**
+- [x] **Step 3: Integrate both acceptance services**
 
 Wrap `FlashDeliveryApplicationService.accept` as:
 
@@ -225,7 +225,7 @@ return riderDeliveryLockService.withLock(riderId, () -> {
 
 Wrap the full body of `OrderLifecycleService.acceptDeliveryByRider` with the same lock and call `assertCanAcceptFood(riderId)` before `applyCas`. Keep the existing per-order conditional update; do not lock pickup or delivery operations.
 
-- [ ] **Step 4: Record SQL and update integration docs**
+- [x] **Step 4: Record SQL and update integration docs**
 
 Append, but do not execute:
 
@@ -237,7 +237,7 @@ ALTER TABLE flash_delivery_order
 
 Document `role=sender/receiver`, default `sender`, participant detail/signature rules, no historical claim, integer TWD, no payment and the UI copy “发布后等待附近骑手接单”. Mark T055–T060 complete only after their corresponding source and docs exist.
 
-- [ ] **Step 5: Run focused tests and full module verification**
+- [x] **Step 5: Run focused tests and full module verification**
 
 ```powershell
 $env:JAVA_HOME='C:\Users\qmj\.jdks\graalvm-jdk-21.0.7'
@@ -249,6 +249,6 @@ git diff --check
 
 Expected: tests report zero failures/errors, package exits 0, and diff check produces no errors.
 
-- [ ] **Step 6: Audit and commit**
+- [x] **Step 6: Audit and commit**
 
 Inspect `git diff --name-only` and `git diff --cached --name-only`; stage only Phase 12 production, tests, five i18n bundles, SQL and flash-delivery documents. Commit with a Chinese subject. Do not push unless the user requests it.

+ 7 - 4
specs/024-flash-delivery/quickstart.md

@@ -2,7 +2,10 @@
 
 1. 开发者人工执行 `updatesql/sql.md` 中 2026-08-31 闪送迁移,并在字典 `sys_google_routes_key` 配置仅供后端使用、已启用 Google Routes API 的独立密钥;不得复用匿名接口可读取的 `sys_googlemap_key`。
 2. 启动 `ruoyi-admin`;用户和骑手请求携带现有 `token` 请求头,平台请求使用后台权限。
-3. 调用 `/system/flashDelivery/home`、`/quote`、`/orders`;创建时服务端重新报价。
-4. 骑手从 `/system/flashDelivery/rider/orders?page=1&size=10&tab=newTask&longitude=121.5&latitude=25.0` 拉取待抢任务并调用 `/accept`;五个页签分别为 `newTask`(待接单)、`toPickup`(已接单)、`delivering`(已取件)、`completed`(已送达/已完成)和 `cancelled`(已取消)。`newTask` 的经纬度仅用于附近范围、取件点距离和排序,闪送没有 `refund` 页签。接单前只展示完整文字地址,隐藏联系人、电话、实际 PIN、精确坐标和履约图片;图片先走现有上传接口,再把 URL 传给 `/pickup` 和 `/deliver`。
-5. 用户 `/confirmReceipt` 完成;送达满 24 小时未操作则自动完成。
-6. 完整字段见 [API 契约](contracts/api.md)。
+3. 调用 `/system/flashDelivery/home`、`/quote`、`/orders`;创建时服务端重新报价,并按标准化收件手机号唯一匹配普通用户。匹配结果固化在新订单中,不追溯历史订单。
+4. 用户通过 `/system/flashDelivery/orders?role=sender` 查看“我发的”,通过 `role=receiver` 查看“我收的”;省略 `role` 时默认 `sender`。发件人与固化收件人均可查看详情和确认收货,只有发件人可以取消。
+5. 骑手从 `/system/flashDelivery/rider/orders?page=1&size=10&tab=newTask&longitude=121.5&latitude=25.0` 拉取待抢任务并调用 `/accept`;五个页签分别为 `newTask`(待接单)、`toPickup`(已接单)、`delivering`(已取件)、`completed`(已送达/已完成)和 `cancelled`(已取消)。`newTask` 的经纬度仅用于附近范围、取件点距离和排序,闪送没有 `refund` 页签。接单前只展示完整文字地址,隐藏联系人、电话、实际 PIN、精确坐标和履约图片;图片先走现有上传接口,再把 URL 传给 `/pickup` 和 `/deliver`。
+6. 急送接单与外卖、普通闪送共用骑手级 Redis 锁;急送进行期间骑手不能承接其他配送,接急送前骑手也不能有任何进行中配送。
+7. 发布成功页使用静态文案“发布后等待附近骑手接单”,不展示动态附近骑手数或承诺接单时间。闪送不接入支付,金额保持整数 TWD。
+8. 用户 `/confirmReceipt` 完成;送达满 24 小时未操作则自动完成。
+9. 完整字段见 [API 契约](contracts/api.md)。

+ 6 - 6
specs/024-flash-delivery/tasks.md

@@ -97,10 +97,10 @@
 
 ## Phase 12:新版蓝湖稿的收件人订单与急送独占
 
-- [ ] T055 更新规格、设计、数据模型和 API 契约,固定非支付、整数 TWD、静态等待接单文案、创建时收件账号匹配及急送独占规则
-- [ ] T056 先扩展 Mapper、应用服务和 Controller 测试,覆盖收件账号唯一匹配、无匹配、重复匹配、历史不追溯、`role` 列表、参与者详情/签收、收件人禁止取消,并确认旧实现红灯
-- [ ] T057 新增 `receiver_user_id` 实体/XML/索引和普通用户手机号精确匹配查询,实现创建时固化、寄件人/收件人数据库分页及参与者权限
-- [ ] T058 先补跨外卖/闪送接单测试,覆盖骑手级 Redisson 锁、忙碌骑手拒绝急送、急送阻止其他接单、终态释放和并发串行检查,并确认旧实现红灯
-- [ ] T059 以共享锁键 `lock:delivery:rider:{riderId}` 实现 `URGENT` 跨订单独占,事务完成后释放锁,并在当前有效外卖与闪送接单入口返回一致的国际化业务错误
-- [ ] T060 在 `updatesql/sql.md` 追加 `receiver_user_id` 字段和索引迁移,仅记录不执行;同步 App 接口文档和五语言错误文案
+- [x] T055 更新规格、设计、数据模型和 API 契约,固定非支付、整数 TWD、静态等待接单文案、创建时收件账号匹配及急送独占规则
+- [x] T056 先扩展 Mapper、应用服务和 Controller 测试,覆盖收件账号唯一匹配、无匹配、重复匹配、历史不追溯、`role` 列表、参与者详情/签收、收件人禁止取消,并确认旧实现红灯
+- [x] T057 新增 `receiver_user_id` 实体/XML/索引和普通用户手机号精确匹配查询,实现创建时固化、寄件人/收件人数据库分页及参与者权限
+- [x] T058 先补跨外卖/闪送接单测试,覆盖骑手级 Redisson 锁、忙碌骑手拒绝急送、急送阻止其他接单、终态释放和并发串行检查,并确认旧实现红灯
+- [x] T059 以共享锁键 `lock:delivery:rider:{riderId}` 实现 `URGENT` 跨订单独占,事务完成后释放锁,并在当前有效外卖与闪送接单入口返回一致的国际化业务错误
+- [x] T060 在 `updatesql/sql.md` 追加 `receiver_user_id` 字段和索引迁移,仅记录不执行;同步 App 接口文档和五语言错误文案
 - [ ] T061 使用 JDK 21 运行闪送、外卖接单和 i18n 定向测试、模块构建与差异检查,核对暂存范围后使用中文提交

+ 10 - 0
updatesql/sql.md

@@ -1428,3 +1428,13 @@ VALUES (1,'臺灣銀行','臺灣銀行','taiwan_bank_list',NULL,'default','N','0
  (28,'星展(台灣)銀行','星展(台灣)銀行','taiwan_bank_list',NULL,'default','N','0','admin',NOW(),NULL),
  (29,'中華郵政(郵局)','中華郵政(郵局)','taiwan_bank_list',NULL,'default','N','0','admin',NOW(),NULL);
 ```
+
+## 2026-09-04 闪送收件账号绑定(024-flash-delivery)
+
+> 用途:订单创建时按收件手机号唯一匹配已注册普通用户,支持用户端“我收的”列表。只记录脚本,由开发者手动执行;不回填历史订单。
+
+```sql
+ALTER TABLE flash_delivery_order
+  ADD COLUMN receiver_user_id BIGINT NULL COMMENT '创建时匹配的收件用户ID' AFTER user_id,
+  ADD KEY idx_flash_receiver_list (receiver_user_id, create_time);
+```