Ver Fonte

feat: 增加骑手接单容量限制

qmj há 2 dias atrás
pai
commit
17072fa8ac
23 ficheiros alterados com 509 adições e 234 exclusões
  1. 5 1
      docs/flash-delivery-app-api.md
  2. 6 8
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryApplicationService.java
  3. 5 4
      ruoyi-admin/src/main/java/com/ruoyi/app/order/OrderLifecycleService.java
  4. 0 16
      ruoyi-admin/src/main/java/com/ruoyi/app/order/PosOrderQsOprateController.java
  5. 156 0
      ruoyi-admin/src/main/java/com/ruoyi/app/order/RiderDeliveryAcceptanceService.java
  6. 0 61
      ruoyi-admin/src/main/java/com/ruoyi/app/order/RiderDeliveryExclusivityService.java
  7. 4 0
      ruoyi-admin/src/main/resources/i18n/messages.properties
  8. 4 0
      ruoyi-admin/src/main/resources/i18n/messages_en_US.properties
  9. 4 0
      ruoyi-admin/src/main/resources/i18n/messages_th_TH.properties
  10. 4 0
      ruoyi-admin/src/main/resources/i18n/messages_vi.properties
  11. 4 0
      ruoyi-admin/src/main/resources/i18n/messages_zh_CN.properties
  12. 4 0
      ruoyi-admin/src/main/resources/i18n/messages_zh_TW.properties
  13. 8 8
      ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryApplicationServiceTest.java
  14. 2 0
      ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryI18nContractTest.java
  15. 2 2
      ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryMultiPickupTest.java
  16. 8 8
      ruoyi-admin/src/test/java/com/ruoyi/app/order/OrderLifecycleServiceTest.java
  17. 226 0
      ruoyi-admin/src/test/java/com/ruoyi/app/order/RiderDeliveryAcceptanceServiceTest.java
  18. 0 126
      ruoyi-admin/src/test/java/com/ruoyi/app/order/RiderDeliveryExclusivityServiceTest.java
  19. 6 0
      specs/024-flash-delivery/contracts/api.md
  20. 10 0
      specs/024-flash-delivery/plan.md
  21. 11 0
      specs/024-flash-delivery/spec.md
  22. 14 0
      specs/024-flash-delivery/tasks.md
  23. 26 0
      updatesql/sql.md

+ 5 - 1
docs/flash-delivery-app-api.md

@@ -953,7 +953,11 @@ GET /system/flashDelivery/rider/orders/{id}
 POST /system/flashDelivery/rider/orders/{id}/accept
 ```
 
-无请求体。骑手账号必须已开通闪送配送。抢单成功后状态变为 `ACCEPTED`,响应 `data` 直接返回完整订单字段;订单已被其他骑手抢走时返回“订单已被其他骑手接走”。违反急送独占规则时返回统一的独占冲突提示,App 应保留在列表页并刷新可接任务。
+无请求体。服务端取得骑手锁后重新校验,不信任待抢列表的过滤结果。骑手必须已审核、账号启用、`userType=2`、`offline=0` 并开通闪送配送;同时校验进行中闪送容量、外卖与闪送合计容量及急送独占。容量分别由 `sys_rider_flash_active_order_limit`、`sys_rider_total_active_order_limit` 控制,外卖接单另使用 `sys_rider_food_active_order_limit`;配置只接受 1..20,缺失、空值、非法、越界或读取异常一律按 1 执行,不会变成不限单。
+
+抢单成功后状态变为 `ACCEPTED`,响应 `data` 直接返回完整订单字段。失败会返回明确原因,包括账号未审核或停用、骑手离线、未开通闪送、闪送任务已满、配送任务总量已满、急送独占冲突或订单已被其他骑手接走;App 应展示 `msg`、保留在列表页并刷新可接任务。
+
+当前版本尚未把容量规则用于待抢列表,也未在接单时校验服务端位置更新时间或接单距离,因此列表可见不代表一定能接;最终以本接口复核为准。
 
 ### 8.4 确认取件
 

+ 6 - 8
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryApplicationService.java

@@ -9,7 +9,7 @@ import com.ruoyi.app.flashdelivery.exception.FlashDeliveryQuoteChangedException;
 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.RiderDeliveryAcceptanceService;
 import com.ruoyi.app.order.RiderDeliveryLockService;
 import com.ruoyi.common.core.domain.entity.SysDictData;
 import com.ruoyi.common.exception.ServiceException;
@@ -73,7 +73,7 @@ public class FlashDeliveryApplicationService {
     private final FlashDeliveryRouteService routeService;
     private final FlashDeliveryPricingCalculator pricingCalculator;
     private final RiderDeliveryLockService riderDeliveryLockService;
-    private final RiderDeliveryExclusivityService riderDeliveryExclusivityService;
+    private final RiderDeliveryAcceptanceService riderDeliveryAcceptanceService;
 
     public FlashDeliveryApplicationService(FlashDeliveryOrderMapper orderMapper,
                                            FlashDeliveryPricingMapper pricingMapper,
@@ -83,7 +83,7 @@ public class FlashDeliveryApplicationService {
                                            FlashDeliveryRouteService routeService,
                                            FlashDeliveryPricingCalculator pricingCalculator,
                                            RiderDeliveryLockService riderDeliveryLockService,
-                                           RiderDeliveryExclusivityService riderDeliveryExclusivityService,
+                                           RiderDeliveryAcceptanceService riderDeliveryAcceptanceService,
                                            FlashDeliveryOrderStopMapper stopMapper,
                                            FlashDeliveryTargetAdjustmentMapper adjustmentMapper) {
         this.orderMapper = orderMapper;
@@ -96,7 +96,7 @@ public class FlashDeliveryApplicationService {
         this.routeService = routeService;
         this.pricingCalculator = pricingCalculator;
         this.riderDeliveryLockService = riderDeliveryLockService;
-        this.riderDeliveryExclusivityService = riderDeliveryExclusivityService;
+        this.riderDeliveryAcceptanceService = riderDeliveryAcceptanceService;
     }
 
     /** 闪送首页:返回开放的业务场景、配送等级,以及当前时段各服务的公开运价摘要。 */
@@ -476,12 +476,10 @@ public class FlashDeliveryApplicationService {
     /** 原子抢单:骑手锁内完成互斥校验与条件更新,同一订单仅一名骑手成功。 */
     @Transactional
     public FlashDeliveryOrderDetailView accept(Long riderId, Long orderId) {
-        InfoUser rider = requireRider(riderId);
-        // 抢单属于获取新任务,要求骑手承接闪送配送。
-        if (!rider.supportsDeliveryType("FLASH")) throw fail("flash.delivery.rider.type.not.enabled");
         return riderDeliveryLockService.withLock(riderId, () -> {
             FlashDeliveryOrder target = requireOrder(orderId);
-            riderDeliveryExclusivityService.assertCanAcceptFlash(riderId, target.getDeliveryType());
+            // 账号资格、闪送/总任务容量与急送独占统一在骑手锁内复核,列表结果不能替代接单校验。
+            riderDeliveryAcceptanceService.assertCanAcceptFlash(riderId, target.getDeliveryType());
             Date now = new Date();
             // Mapper 使用“待接单且 rider_id 为空”的条件更新,受影响行数为 1 才表示抢单成功。
             if (orderMapper.accept(orderId, riderId, now) != 1) {

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

@@ -52,7 +52,7 @@ public class OrderLifecycleService {
     private final IPosOrderLinePaymentService linePaymentService;
     private final IPosOrderLineRefundService lineRefundService;
     private final RiderDeliveryLockService riderDeliveryLockService;
-    private final RiderDeliveryExclusivityService riderDeliveryExclusivityService;
+    private final RiderDeliveryAcceptanceService riderDeliveryAcceptanceService;
 
     public OrderLifecycleService(IPosOrderService posOrderService,
                                  OrderService billingService,
@@ -83,7 +83,7 @@ public class OrderLifecycleService {
                                  IPosOrderLinePaymentService linePaymentService,
                                  IPosOrderLineRefundService lineRefundService,
                                  RiderDeliveryLockService riderDeliveryLockService,
-                                 RiderDeliveryExclusivityService riderDeliveryExclusivityService) {
+                                 RiderDeliveryAcceptanceService riderDeliveryAcceptanceService) {
         this.posOrderService = posOrderService;
         this.billingService = billingService;
         this.orderLogHelper = orderLogHelper;
@@ -92,7 +92,7 @@ public class OrderLifecycleService {
         this.linePaymentService = linePaymentService;
         this.lineRefundService = lineRefundService;
         this.riderDeliveryLockService = riderDeliveryLockService;
-        this.riderDeliveryExclusivityService = riderDeliveryExclusivityService;
+        this.riderDeliveryAcceptanceService = riderDeliveryAcceptanceService;
     }
 
     public AdminOrderStatusContext getStatusContext(Long id) {
@@ -159,7 +159,8 @@ public class OrderLifecycleService {
     @Transactional(rollbackFor = Exception.class)
     public void acceptDeliveryByRider(Long id, Long riderId) {
         riderDeliveryLockService.withLock(riderId, () -> {
-            riderDeliveryExclusivityService.assertCanAcceptFood(riderId);
+            // 资格、外卖/闪送容量与急送独占必须和订单更新处于同一骑手锁内,避免并发接单绕过上限。
+            riderDeliveryAcceptanceService.assertCanAcceptFood(riderId);
             acceptDeliveryByRiderLocked(id, riderId);
             return null;
         });

+ 0 - 16
ruoyi-admin/src/main/java/com/ruoyi/app/order/PosOrderQsOprateController.java

@@ -79,10 +79,6 @@ public class PosOrderQsOprateController extends BaseController {
         JwtUtil jwtUtil = new JwtUtil();
         PayPush push = new PayPush();
         String qsId = jwtUtil.getusid(token);
-        InfoUser qsRider = requireRider(qsId);
-        if (!qsRider.supportsDeliveryType("FOOD")) {
-            throw new ServiceException(MessageUtils.message("no.user.delivery.type.not.enabled"));
-        }
         PosOrder order = posOrderService.getOne(new LambdaQueryWrapper<PosOrder>().eq(PosOrder::getId, id));
         if (order == null) {
             throw new ServiceException(MessageUtils.message("no.order.not.found"));
@@ -180,22 +176,10 @@ public class PosOrderQsOprateController extends BaseController {
             if (lock.tryLock(20L, 10L, TimeUnit.SECONDS)) {
                 boolean releaseInfinally = true;
                 try {
-                    requireRider(qsId);
                     PosOrder orst = posOrderService.getById(posOrder.getId());
                     if (orst.getDeliveryStatus() != null && orst.getDeliveryStatus() == 1L && posOrder.getDeliveryStatus() != null && posOrder.getDeliveryStatus() == 1L) {
                         throw new ServiceException(MessageUtils.message("no.order.snatched"));
                     }
-                    if (posOrder.getDeliveryStatus() != null && posOrder.getDeliveryStatus() == 1L) {
-                        QueryWrapper<PosOrder> wrapper = new QueryWrapper<>();
-                        wrapper.eq("qs_id", qsId);
-                        wrapper.in("delivery_status", 1, 2);
-                        wrapper.in("state", 0, 1, 2);
-                        wrapper.eq("after_sale_status", 0);
-                        List<PosOrder> orsts = posOrderService.list(wrapper);
-                        if (orsts.size() > 0) {
-                            throw new ServiceException(MessageUtils.message("no.exist.undelivered.order"));
-                        }
-                    }
                     boolean delivered = posOrder.getDeliveryStatus() != null && posOrder.getDeliveryStatus() == 3L;
                     boolean org;
                     if (delivered) {

+ 156 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/order/RiderDeliveryAcceptanceService.java

@@ -0,0 +1,156 @@
+package com.ruoyi.app.order;
+
+import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
+import com.ruoyi.common.core.domain.entity.SysDictData;
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.common.utils.DictUtils;
+import com.ruoyi.common.utils.MessageUtils;
+import com.ruoyi.system.domain.InfoUser;
+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.IInfoUserService;
+import com.ruoyi.system.service.IPosOrderService;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Service;
+
+import java.util.List;
+
+import static com.ruoyi.system.domain.flash.FlashDeliveryStatus.ACCEPTED;
+import static com.ruoyi.system.domain.flash.FlashDeliveryStatus.PICKED_UP;
+
+/**
+ * 骑手接单统一策略:在骑手级锁内检查账号资格、配送类型、进行中任务容量和急送独占。
+ * 调用方必须先取得 {@link RiderDeliveryLockService} 的同一骑手锁,避免并发请求同时绕过容量检查。
+ */
+@Service
+public class RiderDeliveryAcceptanceService {
+    static final String FOOD_LIMIT_DICT = "sys_rider_food_active_order_limit";
+    static final String FLASH_LIMIT_DICT = "sys_rider_flash_active_order_limit";
+    static final String TOTAL_LIMIT_DICT = "sys_rider_total_active_order_limit";
+    private static final String URGENT = "URGENT";
+    private static final int DEFAULT_LIMIT = 1;
+    private static final int MAX_LIMIT = 20;
+    private static final Logger log = LoggerFactory.getLogger(RiderDeliveryAcceptanceService.class);
+
+    private final IInfoUserService riders;
+    private final IPosOrderService foodOrders;
+    private final FlashDeliveryOrderMapper flashOrders;
+
+    public RiderDeliveryAcceptanceService(IInfoUserService riders,
+                                           IPosOrderService foodOrders,
+                                           FlashDeliveryOrderMapper flashOrders) {
+        this.riders = riders;
+        this.foodOrders = foodOrders;
+        this.flashOrders = flashOrders;
+    }
+
+    /** 校验骑手能否再承接一笔外卖;进行中外卖和跨类型总量均受字典上限控制。 */
+    public void assertCanAcceptFood(Long riderId) {
+        requireEligibleRider(riderId, "FOOD");
+        ActiveTasks active = activeTasks(riderId);
+        if (active.urgentFlash() > 0) throw exclusiveConflict();
+        if (active.food() >= configuredLimit(FOOD_LIMIT_DICT)) {
+            throw new ServiceException(MessageUtils.message("rider.accept.food.limit.reached"));
+        }
+        assertTotalCapacity(active);
+    }
+
+    /** 校验骑手能否承接闪送;加急闪送保持跨外卖、闪送的一对一独占。 */
+    public void assertCanAcceptFlash(Long riderId, String deliveryType) {
+        requireEligibleRider(riderId, "FLASH");
+        ActiveTasks active = activeTasks(riderId);
+        if (URGENT.equals(deliveryType)) {
+            if (active.food() > 0 || active.flash() > 0) throw exclusiveConflict();
+            return;
+        }
+        if (active.urgentFlash() > 0) throw exclusiveConflict();
+        if (active.flash() >= configuredLimit(FLASH_LIMIT_DICT)) {
+            throw new ServiceException(MessageUtils.message("rider.accept.flash.limit.reached"));
+        }
+        assertTotalCapacity(active);
+    }
+
+    /**
+     * 统一账号资格口径。空配送类型仍沿用 InfoUser 的历史兼容规则,但停用、未审核和离线账号均不得接单。
+     */
+    private InfoUser requireEligibleRider(Long riderId, String deliveryType) {
+        InfoUser rider = riderId == null ? null : riders.getById(riderId);
+        if (rider == null || !"2".equals(rider.getUserType())) {
+            throw new ServiceException(MessageUtils.message("rider.operation.role.required"));
+        }
+        if (!"1".equals(rider.getAuditStatus())) {
+            throw new ServiceException(MessageUtils.message("no.user.state.no.audit"));
+        }
+        if (!"0".equals(rider.getStatus())) {
+            throw new ServiceException(MessageUtils.message("no.user.stop"));
+        }
+        if (!"0".equals(rider.getOffline())) {
+            throw new ServiceException(MessageUtils.message("rider.accept.offline"));
+        }
+        if (!rider.supportsDeliveryType(deliveryType)) {
+            String key = "FOOD".equals(deliveryType)
+                    ? "no.user.delivery.type.not.enabled"
+                    : "flash.delivery.rider.type.not.enabled";
+            throw new ServiceException(MessageUtils.message(key));
+        }
+        return rider;
+    }
+
+    /** 一次读取两类进行中任务,所有容量判断使用同一数据库快照口径。 */
+    private ActiveTasks activeTasks(Long riderId) {
+        QueryWrapper<PosOrder> foodQuery = new QueryWrapper<PosOrder>()
+                .eq("qs_id", riderId)
+                .in("delivery_status", 1, 2)
+                .in("state", 0, 1, 2)
+                .eq("after_sale_status", 0);
+        QueryWrapper<FlashDeliveryOrder> flashQuery = new QueryWrapper<FlashDeliveryOrder>()
+                .eq("rider_id", riderId)
+                .in("status", ACCEPTED, PICKED_UP);
+        QueryWrapper<FlashDeliveryOrder> urgentQuery = new QueryWrapper<FlashDeliveryOrder>()
+                .eq("rider_id", riderId)
+                .in("status", ACCEPTED, PICKED_UP)
+                .eq("delivery_type", URGENT);
+        return new ActiveTasks(safe(foodOrders.count(foodQuery)),
+                safe(flashOrders.selectCount(flashQuery)), safe(flashOrders.selectCount(urgentQuery)));
+    }
+
+    private void assertTotalCapacity(ActiveTasks active) {
+        if (active.food() + active.flash() >= configuredLimit(TOTAL_LIMIT_DICT)) {
+            throw new ServiceException(MessageUtils.message("rider.accept.total.limit.reached"));
+        }
+    }
+
+    /**
+     * 容量字典只接受 1 至 20 的整数。缺失、空值、越界、解析或缓存异常一律保守回退为 1,绝不放宽为不限单。
+     */
+    private int configuredLimit(String dictType) {
+        List<SysDictData> values;
+        try {
+            values = DictUtils.getDictCache(dictType);
+        } catch (RuntimeException exception) {
+            log.warn("Failed to read rider acceptance limit, fallback to 1, dictType={}", dictType, exception);
+            return DEFAULT_LIMIT;
+        }
+        if (values == null || values.isEmpty()) return DEFAULT_LIMIT;
+        String raw = values.get(0).getDictValue();
+        if (raw == null || !raw.trim().matches("[0-9]+")) return DEFAULT_LIMIT;
+        try {
+            int value = Integer.parseInt(raw.trim());
+            return value >= 1 && value <= MAX_LIMIT ? value : DEFAULT_LIMIT;
+        } catch (NumberFormatException ignored) {
+            return DEFAULT_LIMIT;
+        }
+    }
+
+    private long safe(Long value) {
+        return value == null ? 0L : value;
+    }
+
+    private ServiceException exclusiveConflict() {
+        return new ServiceException(MessageUtils.message("flash.delivery.rider.exclusive.conflict"));
+    }
+
+    private record ActiveTasks(long food, long flash, long urgentFlash) { }
+}

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

@@ -1,61 +0,0 @@
-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 deliveryType) {
-        if (URGENT.equals(deliveryType)) {
-            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("delivery_type", URGENT);
-        return flashOrders.selectCount(query) > 0;
-    }
-
-    private ServiceException conflict() {
-        return new ServiceException(MessageUtils.message("flash.delivery.rider.exclusive.conflict"));
-    }
-}

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

@@ -332,6 +332,10 @@ 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
+rider.accept.offline=The rider is offline and cannot accept orders
+rider.accept.food.limit.reached=The active food-delivery order limit has been reached
+rider.accept.flash.limit.reached=The active flash-delivery order limit has been reached
+rider.accept.total.limit.reached=The total active delivery task limit has been reached
 flash.delivery.edit.not.allowed=仅待接单且未分配骑手的订单可以修改
 flash.delivery.order.version.invalid=请提供有效的订单版本
 flash.delivery.tip.additional.invalid=追加小费必须为正整数且不能超出金额范围

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

@@ -335,6 +335,10 @@ 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
+rider.accept.offline=The rider is offline and cannot accept orders
+rider.accept.food.limit.reached=The active food-delivery order limit has been reached
+rider.accept.flash.limit.reached=The active flash-delivery order limit has been reached
+rider.accept.total.limit.reached=The total active delivery task limit has been reached
 flash.delivery.edit.not.allowed=Only orders awaiting acceptance with no assigned rider can be edited
 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

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

@@ -336,6 +336,10 @@ address.access.denied=ไม่มีที่อยู่นี้หรือ
 address.data.invalid=ข้อมูลผู้ติดต่อ ที่อยู่ หรือพิกัดไม่ถูกต้อง
 flash.delivery.rider.type.not.enabled=บัญชีนี้ยังไม่ได้เปิดใช้บริการส่งด่วน
 flash.delivery.rider.exclusive.conflict=ไรเดอร์มีงานจัดส่งแบบเฉพาะที่กำลังดำเนินการ จึงยังไม่สามารถรับคำสั่งซื้อนี้ได้
+rider.accept.offline=ไรเดอร์ออฟไลน์อยู่และไม่สามารถรับงานได้
+rider.accept.food.limit.reached=จำนวนออเดอร์ส่งอาหารที่กำลังดำเนินการถึงขีดจำกัดแล้ว
+rider.accept.flash.limit.reached=จำนวนออเดอร์ส่งด่วนที่กำลังดำเนินการถึงขีดจำกัดแล้ว
+rider.accept.total.limit.reached=จำนวนงานจัดส่งที่กำลังดำเนินการทั้งหมดถึงขีดจำกัดแล้ว
 flash.delivery.edit.not.allowed=แก้ไขได้เฉพาะคำสั่งซื้อที่รอรับงานและยังไม่มีไรเดอร์
 flash.delivery.order.version.invalid=กรุณาระบุเวอร์ชันคำสั่งซื้อที่ถูกต้อง
 flash.delivery.tip.additional.invalid=ทิปเพิ่มเติมต้องเป็นจำนวนเต็มบวกและไม่เกินวงเงินที่รองรับ

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

@@ -335,6 +335,10 @@ address.access.denied=Địa chỉ không tồn tại hoặc không có quyền
 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
+rider.accept.offline=Tài xế đang ngoại tuyến và không thể nhận đơn
+rider.accept.food.limit.reached=Đã đạt giới hạn đơn giao đồ ăn đang thực hiện
+rider.accept.flash.limit.reached=Đã đạt giới hạn đơn giao nhanh đang thực hiện
+rider.accept.total.limit.reached=Đã đạt giới hạn tổng số nhiệm vụ giao hàng đang thực hiện
 flash.delivery.edit.not.allowed=Chỉ có thể sửa đơn đang chờ nhận và chưa có tài xế
 flash.delivery.order.version.invalid=Vui lòng cung cấp phiên bản đơn hàng hợp lệ
 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ợ

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

@@ -336,6 +336,10 @@ address.access.denied=地址不存在或无权访问
 address.data.invalid=联系人、地址或经纬度信息无效
 flash.delivery.rider.type.not.enabled=当前账号未开通闪送配送
 flash.delivery.rider.exclusive.conflict=骑手存在进行中的独占配送任务,暂时不能接此订单
+rider.accept.offline=骑手当前已离线,不能接单
+rider.accept.food.limit.reached=进行中的外卖订单已达到接单上限
+rider.accept.flash.limit.reached=进行中的闪送订单已达到接单上限
+rider.accept.total.limit.reached=进行中的配送任务总数已达到接单上限
 flash.delivery.edit.not.allowed=仅待接单且未分配骑手的订单可以修改
 flash.delivery.order.version.invalid=请提供有效的订单版本
 flash.delivery.tip.additional.invalid=追加小费必须为正整数且不能超出金额范围

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

@@ -336,6 +336,10 @@ address.access.denied=地址不存在或無權存取
 address.data.invalid=聯絡人、地址或經緯度資訊無效
 flash.delivery.rider.type.not.enabled=當前賬號未開通闁送配送
 flash.delivery.rider.exclusive.conflict=騎手存在進行中的獨佔配送任務,暫時不能接此訂單
+rider.accept.offline=騎手目前已離線,不能接單
+rider.accept.food.limit.reached=進行中的外賣訂單已達接單上限
+rider.accept.flash.limit.reached=進行中的閃送訂單已達接單上限
+rider.accept.total.limit.reached=進行中的配送任務總數已達接單上限
 flash.delivery.edit.not.allowed=僅待接單且未分配騎手的訂單可以修改
 flash.delivery.order.version.invalid=請提供有效的訂單版本
 flash.delivery.tip.additional.invalid=追加小費必須為正整數且不能超出金額範圍

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

@@ -16,7 +16,7 @@ import com.ruoyi.app.flashdelivery.dto.FlashDeliveryRiderOrderPageView;
 import com.ruoyi.app.flashdelivery.exception.FlashDeliveryQuoteChangedException;
 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.RiderDeliveryAcceptanceService;
 import com.ruoyi.app.order.RiderDeliveryLockService;
 import com.ruoyi.system.domain.InfoUser;
 import com.ruoyi.system.domain.flash.FlashDeliveryOrder;
@@ -590,7 +590,7 @@ class FlashDeliveryApplicationServiceTest {
     }
 
     @Test
-    void flashAcceptUsesRiderLockAndChecksExclusivityBeforeAtomicUpdate() {
+    void flashAcceptUsesRiderLockAndChecksAcceptancePolicyBeforeAtomicUpdate() {
         Fixture fixture = new Fixture();
         rider(fixture, 8L);
         FlashDeliveryOrder waiting = order(10L, "WAITING_ACCEPTANCE");
@@ -601,19 +601,19 @@ class FlashDeliveryApplicationServiceTest {
 
         fixture.service.accept(8L, 10L);
 
-        InOrder order = inOrder(fixture.lockService, fixture.exclusivityService, fixture.orderMapper);
+        InOrder order = inOrder(fixture.lockService, fixture.acceptanceService, fixture.orderMapper);
         order.verify(fixture.lockService).withLock(eq(8L), any(RiderDeliveryLockService.LockedCall.class));
-        order.verify(fixture.exclusivityService).assertCanAcceptFlash(8L, "NORMAL");
+        order.verify(fixture.acceptanceService).assertCanAcceptFlash(8L, "NORMAL");
         order.verify(fixture.orderMapper).accept(eq(10L), eq(8L), any(Date.class));
     }
 
     @Test
-    void flashExclusiveConflictDoesNotUpdateOrder() {
+    void flashAcceptanceConflictDoesNotUpdateOrder() {
         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, "NORMAL");
+                .when(fixture.acceptanceService).assertCanAcceptFlash(8L, "NORMAL");
 
         assertThrows(ServiceException.class, () -> fixture.service.accept(8L, 10L));
 
@@ -1406,10 +1406,10 @@ class FlashDeliveryApplicationServiceTest {
         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 RiderDeliveryAcceptanceService acceptanceService = mock(RiderDeliveryAcceptanceService.class);
         final FlashDeliveryApplicationService service = new FlashDeliveryApplicationService(
                 orderMapper, pricingMapper, imageMapper, logMapper, userMapper,
-                routeService, new FlashDeliveryPricingCalculator(), lockService, exclusivityService, stopMapper, adjustmentMapper);
+                routeService, new FlashDeliveryPricingCalculator(), lockService, acceptanceService, stopMapper, adjustmentMapper);
 
         Fixture() {
             // Existing single-stop scenarios now explicitly provide station rows; production never synthesizes legacy stops.

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

@@ -32,6 +32,8 @@ class FlashDeliveryI18nContractTest {
             "flash.delivery.edit.not.allowed", "flash.delivery.order.version.invalid",
             "flash.delivery.tip.additional.invalid",
             "flash.delivery.rider.exclusive.conflict",
+            "rider.accept.offline", "rider.accept.food.limit.reached",
+            "rider.accept.flash.limit.reached", "rider.accept.total.limit.reached",
             "address.access.denied", "address.data.invalid");
 
     @Test

+ 2 - 2
ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryMultiPickupTest.java

@@ -3,7 +3,7 @@ package com.ruoyi.app.flashdelivery.service;
 import com.ruoyi.app.flashdelivery.dto.*;
 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.RiderDeliveryAcceptanceService;
 import com.ruoyi.app.order.RiderDeliveryLockService;
 import com.ruoyi.common.core.domain.entity.SysDictData;
 import com.ruoyi.common.exception.ServiceException;
@@ -414,7 +414,7 @@ class FlashDeliveryMultiPickupTest {
         final InfoUserMapper users = mock(InfoUserMapper.class);
         final FlashDeliveryRouteService routes = mock(FlashDeliveryRouteService.class);
         final FlashDeliveryApplicationService service = new FlashDeliveryApplicationService(orders, prices, images, logs, users,
-                routes, new FlashDeliveryPricingCalculator(), mock(RiderDeliveryLockService.class), mock(RiderDeliveryExclusivityService.class), stops, adjustments);
+                routes, new FlashDeliveryPricingCalculator(), mock(RiderDeliveryLockService.class), mock(RiderDeliveryAcceptanceService.class), stops, adjustments);
         final FlashDeliveryOrder order = new FlashDeliveryOrder();
         final List<FlashDeliveryOrderStop> rows = new ArrayList<>(List.of(stop(11L, 0, "PICKUP"), stop(12L, 1, "PICKUP"), stop(13L, 2, "DELIVERY")));
         final Map<String, FlashDeliveryTargetAdjustment> records = new HashMap<>();

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

@@ -46,7 +46,7 @@ class OrderLifecycleServiceTest {
     private IUserWalletService userWalletService;
     private IPointsTransactionService pointsTransactionService;
     private RiderDeliveryLockService riderDeliveryLockService;
-    private RiderDeliveryExclusivityService riderDeliveryExclusivityService;
+    private RiderDeliveryAcceptanceService riderDeliveryAcceptanceService;
     private OrderLifecycleService service;
 
     @BeforeEach
@@ -57,14 +57,14 @@ class OrderLifecycleServiceTest {
         userWalletService = mock(IUserWalletService.class);
         pointsTransactionService = mock(IPointsTransactionService.class);
         riderDeliveryLockService = mock(RiderDeliveryLockService.class);
-        riderDeliveryExclusivityService = mock(RiderDeliveryExclusivityService.class);
+        riderDeliveryAcceptanceService = mock(RiderDeliveryAcceptanceService.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, null, null,
-                riderDeliveryLockService, riderDeliveryExclusivityService);
+                riderDeliveryLockService, riderDeliveryAcceptanceService);
     }
 
     @Test
@@ -226,7 +226,7 @@ class OrderLifecycleServiceTest {
     }
 
     @Test
-    void foodAcceptUsesSharedRiderLockAndChecksExclusivityBeforeCas() {
+    void foodAcceptUsesSharedRiderLockAndChecksAcceptancePolicyBeforeCas() {
         PosOrder order = deliveryOrder();
         order.setState(0L);
         order.setDeliveryStatus(0L);
@@ -236,17 +236,17 @@ class OrderLifecycleServiceTest {
 
         service.acceptDeliveryByRider(10L, 88L);
 
-        InOrder calls = inOrder(riderDeliveryLockService, riderDeliveryExclusivityService, posOrderService);
+        InOrder calls = inOrder(riderDeliveryLockService, riderDeliveryAcceptanceService, posOrderService);
         calls.verify(riderDeliveryLockService)
                 .withLock(eq(88L), any(RiderDeliveryLockService.LockedCall.class));
-        calls.verify(riderDeliveryExclusivityService).assertCanAcceptFood(88L);
+        calls.verify(riderDeliveryAcceptanceService).assertCanAcceptFood(88L);
         calls.verify(posOrderService).update(any(PosOrder.class), any(Wrapper.class));
     }
 
     @Test
-    void foodExclusiveConflictDoesNotUpdateOrder() {
+    void foodAcceptanceConflictDoesNotUpdateOrder() {
         doThrow(new ServiceException("exclusive"))
-                .when(riderDeliveryExclusivityService).assertCanAcceptFood(88L);
+                .when(riderDeliveryAcceptanceService).assertCanAcceptFood(88L);
 
         assertThrows(ServiceException.class, () -> service.acceptDeliveryByRider(10L, 88L));
 

+ 226 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/order/RiderDeliveryAcceptanceServiceTest.java

@@ -0,0 +1,226 @@
+package com.ruoyi.app.order;
+
+import com.baomidou.mybatisplus.core.conditions.Wrapper;
+import com.ruoyi.common.core.domain.entity.SysDictData;
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.common.utils.DictUtils;
+import com.ruoyi.common.utils.spring.SpringUtils;
+import com.ruoyi.system.domain.InfoUser;
+import com.ruoyi.system.mapper.flash.FlashDeliveryOrderMapper;
+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.Test;
+import org.mockito.MockedStatic;
+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 static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.mockStatic;
+import static org.mockito.Mockito.when;
+
+class RiderDeliveryAcceptanceServiceTest {
+    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 missingFoodLimitFallsBackToOneAndRejectsSecondFoodOrder() {
+        Fixture fixture = new Fixture();
+        fixture.activeFood(1L);
+        fixture.activeFlash(0L, 0L);
+        try (MockedStatic<DictUtils> dictionaries = mockStatic(DictUtils.class)) {
+            dictionaries.when(() -> DictUtils.getDictCache("sys_rider_flash_active_order_limit"))
+                    .thenReturn(List.of(dictionary("10")));
+            dictionaries.when(() -> DictUtils.getDictCache("sys_rider_total_active_order_limit"))
+                    .thenReturn(List.of(dictionary("10")));
+
+            ServiceException error = assertThrows(ServiceException.class,
+                    () -> fixture.service.assertCanAcceptFood(123L));
+
+            assertEquals("rider.accept.food.limit.reached", error.getMessage());
+        }
+    }
+
+    @Test
+    void malformedFoodLimitsFallBackToOneButValidTwoAllowsOneActiveOrder() {
+        for (String invalid : new String[] {null, "", "0", "-1", "1.5", "abc", "21", "999999999999"}) {
+            Fixture fixture = new Fixture();
+            fixture.activeFood(1L);
+            fixture.activeFlash(0L, 0L);
+            try (MockedStatic<DictUtils> dictionaries = mockStatic(DictUtils.class)) {
+                stubLimits(dictionaries, invalid, "10", "10");
+                assertThrows(ServiceException.class, () -> fixture.service.assertCanAcceptFood(123L), invalid);
+            }
+        }
+
+        Fixture fixture = new Fixture();
+        fixture.activeFood(1L);
+        fixture.activeFlash(0L, 0L);
+        try (MockedStatic<DictUtils> dictionaries = mockStatic(DictUtils.class)) {
+            stubLimits(dictionaries, "2", "10", "2");
+            assertDoesNotThrow(() -> fixture.service.assertCanAcceptFood(123L));
+        }
+    }
+
+    @Test
+    void normalFlashChecksItsOwnLimitAndTheCrossTypeTotalLimit() {
+        Fixture flashFull = new Fixture();
+        flashFull.activeFood(0L);
+        flashFull.activeFlash(1L, 0L);
+        try (MockedStatic<DictUtils> dictionaries = mockStatic(DictUtils.class)) {
+            stubLimits(dictionaries, "10", "1", "10");
+            ServiceException error = assertThrows(ServiceException.class,
+                    () -> flashFull.service.assertCanAcceptFlash(123L, "NORMAL"));
+            assertEquals("rider.accept.flash.limit.reached", error.getMessage());
+        }
+
+        Fixture totalFull = new Fixture();
+        totalFull.activeFood(1L);
+        totalFull.activeFlash(0L, 0L);
+        try (MockedStatic<DictUtils> dictionaries = mockStatic(DictUtils.class)) {
+            stubLimits(dictionaries, "10", "10", "1");
+            ServiceException error = assertThrows(ServiceException.class,
+                    () -> totalFull.service.assertCanAcceptFlash(123L, "NORMAL"));
+            assertEquals("rider.accept.total.limit.reached", error.getMessage());
+        }
+    }
+
+    @Test
+    void missingOrInvalidFlashAndTotalLimitsAlsoFallBackToOne() {
+        Fixture flashFull = new Fixture();
+        flashFull.activeFood(0L);
+        flashFull.activeFlash(1L, 0L);
+        try (MockedStatic<DictUtils> dictionaries = mockStatic(DictUtils.class)) {
+            stubLimits(dictionaries, "10", "21", "10");
+            assertMessage("rider.accept.flash.limit.reached",
+                    () -> flashFull.service.assertCanAcceptFlash(123L, "NORMAL"));
+        }
+
+        Fixture totalFull = new Fixture();
+        totalFull.activeFood(1L);
+        totalFull.activeFlash(0L, 0L);
+        try (MockedStatic<DictUtils> dictionaries = mockStatic(DictUtils.class)) {
+            dictionaries.when(() -> DictUtils.getDictCache("sys_rider_food_active_order_limit"))
+                    .thenReturn(List.of(dictionary("10")));
+            assertMessage("rider.accept.total.limit.reached",
+                    () -> totalFull.service.assertCanAcceptFood(123L));
+        }
+    }
+
+    @Test
+    void urgentDeliveryRemainsExclusiveAcrossFoodAndFlashOrders() {
+        Fixture fixture = new Fixture();
+        fixture.activeFood(1L);
+        fixture.activeFlash(0L, 0L);
+        try (MockedStatic<DictUtils> dictionaries = mockStatic(DictUtils.class)) {
+            stubLimits(dictionaries, "10", "10", "10");
+            ServiceException error = assertThrows(ServiceException.class,
+                    () -> fixture.service.assertCanAcceptFlash(123L, "URGENT"));
+            assertEquals("flash.delivery.rider.exclusive.conflict", error.getMessage());
+        }
+    }
+
+    @Test
+    void rejectsRidersThatAreNotApprovedEnabledOnlineAndTypeCompatible() {
+        Fixture fixture = new Fixture();
+
+        fixture.rider.setAuditStatus("0");
+        assertMessage("no.user.state.no.audit", () -> fixture.service.assertCanAcceptFood(123L));
+
+        fixture.rider.setAuditStatus("1");
+        fixture.rider.setStatus("1");
+        assertMessage("no.user.stop", () -> fixture.service.assertCanAcceptFood(123L));
+
+        fixture.rider.setStatus("0");
+        fixture.rider.setOffline("1");
+        assertMessage("rider.accept.offline", () -> fixture.service.assertCanAcceptFood(123L));
+
+        fixture.rider.setOffline("0");
+        fixture.rider.setUserType("0");
+        assertMessage("rider.operation.role.required", () -> fixture.service.assertCanAcceptFood(123L));
+
+        fixture.rider.setUserType("2");
+        fixture.rider.setDeliveryType("FLASH");
+        assertMessage("no.user.delivery.type.not.enabled", () -> fixture.service.assertCanAcceptFood(123L));
+
+        fixture.rider.setDeliveryType("FOOD");
+        assertMessage("flash.delivery.rider.type.not.enabled",
+                () -> fixture.service.assertCanAcceptFlash(123L, "NORMAL"));
+    }
+
+    private static void assertMessage(String expected, Runnable call) {
+        assertEquals(expected, assertThrows(ServiceException.class, call::run).getMessage());
+    }
+
+    private static void stubLimits(MockedStatic<DictUtils> dictionaries,
+                                   String food, String flash, String total) {
+        dictionaries.when(() -> DictUtils.getDictCache("sys_rider_food_active_order_limit"))
+                .thenReturn(List.of(dictionary(food)));
+        dictionaries.when(() -> DictUtils.getDictCache("sys_rider_flash_active_order_limit"))
+                .thenReturn(List.of(dictionary(flash)));
+        dictionaries.when(() -> DictUtils.getDictCache("sys_rider_total_active_order_limit"))
+                .thenReturn(List.of(dictionary(total)));
+    }
+
+    private static SysDictData dictionary(String value) {
+        SysDictData data = new SysDictData();
+        data.setDictValue(value);
+        return data;
+    }
+
+    private static class Fixture {
+        final IInfoUserService riders = mock(IInfoUserService.class);
+        final IPosOrderService foodOrders = mock(IPosOrderService.class);
+        final FlashDeliveryOrderMapper flashOrders = mock(FlashDeliveryOrderMapper.class);
+        final InfoUser rider = eligibleRider();
+        final RiderDeliveryAcceptanceService service =
+                new RiderDeliveryAcceptanceService(riders, foodOrders, flashOrders);
+
+        Fixture() {
+            when(riders.getById(123L)).thenReturn(rider);
+        }
+
+        void activeFood(long count) {
+            when(foodOrders.count(any(Wrapper.class))).thenReturn(count);
+        }
+
+        void activeFlash(long all, long urgent) {
+            when(flashOrders.selectCount(any(Wrapper.class))).thenReturn(all, urgent);
+        }
+
+        private static InfoUser eligibleRider() {
+            InfoUser rider = new InfoUser();
+            rider.setUserId(123L);
+            rider.setUserType("2");
+            rider.setAuditStatus("1");
+            rider.setStatus("0");
+            rider.setOffline("0");
+            rider.setDeliveryType("FOOD,FLASH");
+            return rider;
+        }
+    }
+}

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

@@ -1,126 +0,0 @@
-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.times;
-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, "NORMAL"));
-    }
-
-    @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");
-        fixture.service.assertCanAcceptFood(123L);
-
-        ArgumentCaptor<Wrapper> foodQuery = ArgumentCaptor.forClass(Wrapper.class);
-        ArgumentCaptor<Wrapper> flashQuery = ArgumentCaptor.forClass(Wrapper.class);
-        verify(fixture.foodOrders).count(foodQuery.capture());
-        verify(fixture.flashOrders, times(2)).selectCount(flashQuery.capture());
-        String foodSql = foodQuery.getValue().getSqlSegment().toLowerCase();
-        String allFlashSql = flashQuery.getAllValues().get(0).getSqlSegment().toLowerCase();
-        String urgentFlashSql = flashQuery.getAllValues().get(1).getSqlSegment().toLowerCase();
-        assertTrue(foodSql.contains("qs_id"));
-        assertTrue(foodSql.contains("delivery_status"));
-        assertTrue(foodSql.contains("state"));
-        assertTrue(foodSql.contains("after_sale_status"));
-        assertTrue(allFlashSql.contains("rider_id"));
-        assertTrue(allFlashSql.contains("status"));
-        assertTrue(urgentFlashSql.contains("delivery_type"));
-        assertTrue(!allFlashSql.contains("service_type"));
-        assertTrue(!urgentFlashSql.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);
-    }
-}

+ 6 - 0
specs/024-flash-delivery/contracts/api.md

@@ -78,6 +78,12 @@
 
 本期没有支付、退款、取消费、退回费、结算、骑手收入、代购/垫付、商品金额、违禁品电子确认、骑手放弃或自动派单字段和接口。所有闪送金额保持整数 TWD;创建订单后直接待接单,不存在待支付状态。`deliveryType=URGENT` 为 1 对 1 独占配送,相关外卖与闪送接单入口必须执行跨订单独占检查。骑手小费仅为订单费用明细,不代表已付款。
 
+### 骑手抢单资格与容量(2026-09-16)
+
+`POST /system/flashDelivery/rider/orders/{id}/accept` 在同一骑手锁内重新校验账号和容量,不信任此前的列表结果。骑手必须为已审核、启用、在线的 `userType=2` 账号并开通 `FLASH`;服务端同时检查进行中闪送数量、外卖与闪送合计数量以及急送独占。外卖接单入口使用相同策略并要求开通 `FOOD`。
+
+容量由 `sys_rider_food_active_order_limit`、`sys_rider_flash_active_order_limit`、`sys_rider_total_active_order_limit` 控制;有效范围 1..20,缺失或非法时回退 1。接口会区分返回离线、配送类型未开通、类型任务已满、总任务已满、急送独占和订单已被抢等原因。当前待抢列表未共用容量过滤,也未增加位置过期或接单距离校验,客户端必须以 accept 响应为最终结果并在失败后刷新列表。
+
 运价时段使用业务当地时间和 `HH:mm` 格式,采用开始时间包含、结束时间不包含的匹配语义;`24:00` 只允许作为结束时间,跨午夜时段必须拆成两个配置。全部时间段不得重叠。起送距离和计价距离必须为正数,起送价格和计价金额必须为正整数,加急比例和最低加急费必须为非负数;立即订单按当前时刻、预约订单按预约开始时刻匹配,目标时刻没有匹配时段时报“对应取件时段暂无可用运价”。
 
 ## 2026-09-09:待接单订单修改取送信息与追加小费

+ 10 - 0
specs/024-flash-delivery/plan.md

@@ -718,6 +718,16 @@ The staged list must not contain `.claude/homunculus/observations.jsonl`、`.tmp
 
 9. **顺序调整记录(T098)**:按 FR-095、FR-096 设计独立的履约目标选择/调整操作,使用明确 RequestBody DTO 和 token 请求头;服务端保存当前目标及订单、调整前后目标、原因、声明、骑手和时间,不修改 stop_order、站点物品或报价。采用订单行锁与取件/取消串行校验,防止重复请求追加同一次记录或过期请求覆盖目标;已取站及已取消订单拒绝新调整。目标完成后回到用户顺序中最早未取站。同步实体、Mapper/XML、视图及契约;必要 SQL 在实现时写入 updatesql/sql.md,不执行迁移。此操作保存履约选择,不生成导航 URL,也不推进取件状态。
 10. **客户端与异常核对(T099)**:用户下单前可排序,待接单调整需重报价确认,显示 FR-094 提示。骑手默认突出下一站,可查看其他站;选择非默认站时先提示联系下单人,要求原因及已取得同意声明,保存成功后再导航。区分“前往”与“已取货”,声明不显示为用户审批;订单聊天可作为依据,首期不新增用户 App 审批流程。各端保留原顺序,展示实际进度及取消条件,平台展示调整记录、异常处理结果和物品交接信息;可见文本按各端既有国际化要求实现。关联文档在 T094 同步,后端契约与平台页面已落实;用户/骑手 App 的排序、目标选择及导航页面仍需客户端接入。
+
+## 2026-09-16:骑手接单资格与容量实施计划
+
+**范围**:仅调整当前有效外卖、闪送接单入口。待抢列表共用过滤、服务端位置过期和距离复核明确留待后续,不借本次改动扩大范围。
+
+**实现**:以 `RiderDeliveryAcceptanceService` 取代只判断急送的 `RiderDeliveryExclusivityService`。外卖与闪送在 `RiderDeliveryLockService` 锁内调用统一策略,一次读取两类进行中任务并依次判断资格、急送独占、类型容量和合计容量;订单自身仍使用既有条件更新保证只能被抢一次。三个容量字典只接受 1..20,任何缺失或非法配置都保守回退 1。
+
+**持久化和文案**:不新增业务表字段;仅在 `updatesql/sql.md` 记录三个字典类型及初始值 1,不执行数据库操作。新增离线、外卖容量、闪送容量和合计容量错误,并同步全部后端语言资源。
+
+**验证**:单元测试覆盖资格、三类容量、非法配置回退和急送双向独占;应用服务测试覆盖校验发生在骑手锁内且早于订单条件更新。使用 JDK 21 运行定向测试、模块构建和差异检查,并单列仓库既有的非本功能失败。
 11. **补充验收(T100)**:覆盖默认 A、沟通并保存调整后前往 B、仅导航状态不变、缺原因/声明或直接绕过调整操作拒绝、B 取件后默认回 A、原顺序/物品/金额不变、部分取件进度及取消入口、平台明确标注骑手声明、无法取货的介入与交接记录;覆盖调整请求重复、与取件/取消竞争、目标变化后的取件幂等重试。联调验证前不得将 App 行为标记为已验证,骑手声明不能作为已验证用户同意真实性的依据。
 
 外部约束:Google Routes 单次最多 25 个中途点,Maps URL 使用 two-wheeler 且途经点数量受平台限制;本期采用字典有效上限最多 26 个取货点与单站导航,不做无限站点分段请求。

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

@@ -352,6 +352,17 @@
 - FR-097:保留用户原始站点顺序及报价,按每站实际确认时间和状态展示履约进度;用户、骑手及平台显示已取数量/总数量(如“已取 1/3 站”)。部分取件主状态仍为 ACCEPTED,取消入口按是否任意站已取判断,不能仅依据主状态显示可取消。平台详情可查看 FR-095 调整记录;调整声明和取件凭证分别展示,不相互替代。
 - FR-098:骑手仅能调整本单未取货地点的访问先后,不能新增或删除地点、修改地址或物品,也不能漏取。某站暂时无法取货可以沟通后先去其他站,该站继续保留待取件;最终无法取货、缺件或拒收须联系平台处理,不能虚假确认取件、擅自部分履约或直接完成整单。平台介入时记录问题站、处理结果及已取物品去向和交接凭证,再关闭异常订单;沿用既有介入权限,本次不新增支付退款功能。
 
+## 2026-09-16:骑手接单资格与容量限制
+
+本次只收口外卖和闪送接单接口的服务端最终校验,不调整待抢列表过滤,也不增加位置新鲜度或接单距离限制。列表结果不能替代接单接口复核,后续实现列表共用规则时必须继续保留接口校验。
+
+- FR-099:外卖和闪送接单必须先取得同一把骑手锁,再在锁内统一校验骑手身份、审核状态、账号状态、在线状态、对应配送类型、进行中任务容量和急送独占;外卖原“已有未完成订单”查询迁入锁内,避免并发请求同时越过容量判断。
+- FR-100:使用 `sys_rider_food_active_order_limit`、`sys_rider_flash_active_order_limit`、`sys_rider_total_active_order_limit` 三个数据字典分别限制进行中外卖、进行中闪送和两类任务合计数量。每项只接受 1 至 20 的整数;缺失、空值、非整数、越界、溢出或字典读取异常时有效值均为 1,绝不回退为不限单。初始配置三项均为 1。
+- FR-101:进行中外卖沿用 `qs_id=骑手`、`delivery_status IN (1,2)`、`state IN (0,1,2)`、`after_sale_status=0`;进行中闪送为当前骑手的 `ACCEPTED` 或 `PICKED_UP`。达到对应类型上限或合计上限时拒绝新接单,订单的原子条件更新仍负责判断是否已被其他骑手抢走。
+- FR-102:接单资格统一要求骑手存在、`userType=2`、`auditStatus=1`、`status=0`、`offline=0`,并开通目标订单对应的 `FOOD` 或 `FLASH` 配送类型。失败时返回可区分的国际化原因,包括角色不符、未审核、账号停用、离线、配送类型未开通、类型容量已满、总容量已满、急送独占冲突或订单已被抢。
+- FR-103:`URGENT` 闪送继续跨外卖和闪送独占:骑手已有任一进行中配送任务时不能接加急闪送;骑手已有进行中加急闪送时不能再接外卖或普通闪送。独占判断与容量判断使用同一骑手锁。
+- FR-104:本阶段不把接单规则复用到待抢列表,也不校验服务端位置更新时间和接单距离;因此列表可能出现最终不可接的订单,App 必须以接单接口结果为准并按明确原因刷新。位置规则须在定位上报频率、过期阈值和距离口径另行确认后实施。
+
 **方案来源**:上述为本项目 2026-09-15 已确认的方式 2。Lalamove 的业务规范仅作为讨论背景;骑手声明、调整记录及具体 App 操作是本项目设计,不标注为已核实的 Lalamove 操作流程。
 
 地图约束依据:[Google Routes 中途点限制](https://developers.google.com/maps/documentation/routes/intermed_waypoints)、[Google Maps URL 参数及平台途经点限制](https://developers.google.com/maps/documentation/urls/get-started)。

+ 14 - 0
specs/024-flash-delivery/tasks.md

@@ -187,3 +187,17 @@
 - T099:平台站点、进度、目标声明和异常交接页面已实现并通过浏览器模拟接口检查;当前工作区没有用户/骑手闪送页面源码,客户端部分保留待办,商家 PC 未新增入口。
 - T100:默认目标、同意声明、目标调整重试、逐站取件幂等、取完 B 回 A、金额不变、取消限制和平台异常交接已覆盖本地测试;真实并发、数据库回滚与 App 操作链路未验收。
 - 地图计价继续使用现有 Google provider,扩展有序多经点和整线直线降级;Foodie_HCM_Map 部署与后端切换不在本次交付内。
+
+## Phase 19:骑手接单资格与容量限制
+
+- [x] T101 记录 FR-099 至 FR-104:外卖/闪送共用锁内资格与容量检查,三个字典上限 1..20、缺失或非法回退 1,急送继续跨类型独占;待抢列表和位置规则不在本阶段实施
+- [x] T102 先补策略及应用服务测试确认旧实现缺少普通闪送容量、跨类型总容量和锁内统一资格校验,再实现 `RiderDeliveryAcceptanceService` 并迁移两个当前有效接单入口
+- [x] T103 同步六套后端国际化文案、App 接口文档、契约及 `updatesql/sql.md`,三个字典初始值均为 1,SQL 只记录不执行
+- [x] T104 使用 JDK 21 运行策略、外卖/闪送接单及国际化定向测试和模块构建,执行差异检查;单列与本次无关的仓库基线失败及未执行的真实数据库/App 联调
+
+### Phase 19 验证记录(2026-09-16)
+
+- JDK 21 定向运行 `RiderDeliveryAcceptanceServiceTest`、`FlashDeliveryI18nContractTest`、`OrderLifecycleServiceTest` 及闪送抢单的 3 个应用服务用例,共 30 项通过,0 失败/错误/跳过。
+- JDK 21 执行 `mvn -pl ruoyi-admin -am -DskipTests package`,全部参与模块 `BUILD SUCCESS`;`git diff --check` 通过。
+- 扩大运行完整 `FlashDeliveryApplicationServiceTest` 时发现仓库基线既有矛盾:`normalizePayType` 当前允许任意非空支付类型,而 `createRejectsUnsupportedPayType` 仍要求拒绝 `2`;本次未修改该逻辑,也未为接单容量需求顺带修复。
+- 三个字典 SQL 仅记录、未执行;未进行真实 MySQL 并发、Redis 多实例或骑手 App 联调,待抢列表和位置/距离规则按 FR-104 保持未实施。

+ 26 - 0
updatesql/sql.md

@@ -1619,3 +1619,29 @@ INSERT INTO sys_dict_data (dict_sort, dict_label, dict_value, dict_type, is_defa
 SELECT 1, '加急选项展示', '1', 'flash_show_urgent_option', 'Y', '0', 'admin', NOW()
 WHERE NOT EXISTS (SELECT 1 FROM sys_dict_data WHERE dict_type='flash_show_urgent_option');
 ```
+
+## 2026-09-16 骑手进行中配送任务接单上限
+
+> 用途:分别限制骑手进行中的外卖、闪送和两类任务合计数量。有效值为 1 至 20;缺失、空值、非法、越界或读取异常时服务端保守回退为 1。初始值均为 1;脚本只记录,由开发者手动执行。
+
+```sql
+INSERT INTO sys_dict_type (dict_name, dict_type, status, create_by, create_time, remark)
+SELECT '骑手外卖接单上限', 'sys_rider_food_active_order_limit', '0', 'admin', NOW(), '进行中外卖上限,整数1至20,缺失或非法回退1'
+WHERE NOT EXISTS (SELECT 1 FROM sys_dict_type WHERE dict_type='sys_rider_food_active_order_limit');
+INSERT INTO sys_dict_type (dict_name, dict_type, status, create_by, create_time, remark)
+SELECT '骑手闪送接单上限', 'sys_rider_flash_active_order_limit', '0', 'admin', NOW(), '进行中闪送上限,整数1至20,缺失或非法回退1'
+WHERE NOT EXISTS (SELECT 1 FROM sys_dict_type WHERE dict_type='sys_rider_flash_active_order_limit');
+INSERT INTO sys_dict_type (dict_name, dict_type, status, create_by, create_time, remark)
+SELECT '骑手配送总接单上限', 'sys_rider_total_active_order_limit', '0', 'admin', NOW(), '进行中外卖和闪送合计上限,整数1至20,缺失或非法回退1'
+WHERE NOT EXISTS (SELECT 1 FROM sys_dict_type WHERE dict_type='sys_rider_total_active_order_limit');
+
+INSERT INTO sys_dict_data (dict_sort, dict_label, dict_value, dict_type, is_default, status, create_by, create_time)
+SELECT 1, '进行中外卖上限', '1', 'sys_rider_food_active_order_limit', 'Y', '0', 'admin', NOW()
+WHERE NOT EXISTS (SELECT 1 FROM sys_dict_data WHERE dict_type='sys_rider_food_active_order_limit');
+INSERT INTO sys_dict_data (dict_sort, dict_label, dict_value, dict_type, is_default, status, create_by, create_time)
+SELECT 1, '进行中闪送上限', '1', 'sys_rider_flash_active_order_limit', 'Y', '0', 'admin', NOW()
+WHERE NOT EXISTS (SELECT 1 FROM sys_dict_data WHERE dict_type='sys_rider_flash_active_order_limit');
+INSERT INTO sys_dict_data (dict_sort, dict_label, dict_value, dict_type, is_default, status, create_by, create_time)
+SELECT 1, '进行中配送任务总上限', '1', 'sys_rider_total_active_order_limit', 'Y', '0', 'admin', NOW()
+WHERE NOT EXISTS (SELECT 1 FROM sys_dict_data WHERE dict_type='sys_rider_total_active_order_limit');
+```