فهرست منبع

feat: 闪送预约单按取件时间窗口限制接单

qmj 1 ساعت پیش
والد
کامیت
6ed1ca9bc1
18فایلهای تغییر یافته به همراه198 افزوده شده و 27 حذف شده
  1. 33 22
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryApplicationService.java
  2. 17 2
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/task/FlashDeliveryAutoCompleteTask.java
  3. 1 0
      ruoyi-admin/src/main/resources/i18n/messages.properties
  4. 1 0
      ruoyi-admin/src/main/resources/i18n/messages_en_US.properties
  5. 1 0
      ruoyi-admin/src/main/resources/i18n/messages_th_TH.properties
  6. 1 0
      ruoyi-admin/src/main/resources/i18n/messages_vi.properties
  7. 1 0
      ruoyi-admin/src/main/resources/i18n/messages_zh_CN.properties
  8. 1 0
      ruoyi-admin/src/main/resources/i18n/messages_zh_TW.properties
  9. 67 1
      ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryApplicationServiceTest.java
  10. 1 1
      ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryI18nContractTest.java
  11. 18 0
      ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/task/FlashDeliveryAutoCompleteTaskTest.java
  12. 6 0
      ruoyi-system/src/main/java/com/ruoyi/system/mapper/flash/FlashDeliveryOrderMapper.java
  13. 20 1
      ruoyi-system/src/main/resources/mapper/flash/FlashDeliveryOrderMapper.xml
  14. 5 0
      ruoyi-system/src/test/java/com/ruoyi/system/mapper/flash/FlashDeliveryMapperXmlTest.java
  15. 8 0
      specs/024-flash-delivery/contracts/api.md
  16. 2 0
      specs/024-flash-delivery/spec.md
  17. 1 0
      specs/024-flash-delivery/tasks.md
  18. 14 0
      updatesql/sql.md

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

@@ -54,6 +54,8 @@ public class FlashDeliveryApplicationService {
     /** 支付方式取值与主订单系统同域:4=现金、6=线下转账(027 线下转账支付)。 */
     private static final String PAY_TYPE_CASH = "4";
     private static final String PAY_TYPE_TRANSFER = "6";
+    /** 自动取消原因使用稳定代码,供不同端按各自语言展示而不是保存单一语言文本。 */
+    private static final String SCHEDULE_EXPIRED_REASON = "SCHEDULE_EXPIRED";
     private static final DateTimeFormatter PRICING_TIME_FORMAT = DateTimeFormatter.ofPattern("HH:mm");
     private static final ZoneId TAIPEI = ZoneId.of("Asia/Taipei");
     private static final int MAX_DISTANCE_METERS = 40_000;
@@ -472,8 +474,10 @@ public class FlashDeliveryApplicationService {
             FlashDeliveryOrder target = requireOrder(orderId);
             // 列表过滤不能替代锁内校验,防止骑手绕过列表直接提交不同车型订单。
             if (!isRiderVehicleCompatible(rider, target)) throw fail("flash.delivery.rider.vehicle.not.match");
-            riderDeliveryExclusivityService.assertCanAcceptFlash(riderId, target.getDeliveryType());
             Date now = new Date();
+            // 预约单必须仍处在取件窗口内;即使骑手持有订单 ID,也不能绕过列表限制提前或逾期接单。
+            if (!isAvailableAt(target, now)) throw fail("flash.delivery.schedule.not.open");
+            riderDeliveryExclusivityService.assertCanAcceptFlash(riderId, target.getDeliveryType());
             // Mapper 使用“待接单且 rider_id 为空”的条件更新,受影响行数为 1 才表示抢单成功。
             if (orderMapper.accept(orderId, riderId, now) != 1) {
                 throw fail("flash.delivery.order.already.accepted");
@@ -632,6 +636,19 @@ public class FlashDeliveryApplicationService {
         return orderMapper.selectAutoCompletable(deadline, Math.min(Math.max(limit, 1), 500));
     }
 
+    /** 查询已经错过预约取件结束时间、但仍无人接单的订单,供 Quartz 任务分批处理。 */
+    public List<FlashDeliveryOrder> expiredScheduledCandidates(Date now, int limit) {
+        return orderMapper.selectExpiredScheduled(now, Math.min(Math.max(limit, 1), 500));
+    }
+
+    /** 原子取消过期预约单;条件更新失败表示订单已被抢、取消或状态已变化,无需重复写日志。 */
+    @Transactional
+    public boolean cancelExpiredScheduledOrder(Long orderId, Date now) {
+        if (orderId == null || now == null || orderMapper.cancelExpiredScheduled(orderId, now) != 1) return false;
+        writeLog(orderId, WAITING_ACCEPTANCE, CANCELLED, "SYSTEM", null, SCHEDULE_EXPIRED_REASON, now);
+        return true;
+    }
+
     /** 报价核心:校验枚举、物品、地址与预约窗后,按时段命中运价并计算路线距离,产出报价上下文。 */
     private QuoteContext calculateQuote(FlashDeliveryQuoteRequest request) {
         if (request == null) throw fail("flash.delivery.request.required");
@@ -811,7 +828,15 @@ public class FlashDeliveryApplicationService {
         if (userView) {
             view.setOrderVersion(order.getVersion());
             view.setDeliveryPinCode(Boolean.TRUE.equals(order.getPinRequired()) ? order.getDeliveryPinCode() : null);
-            view.setRider(riderSummary(order));
+            // 接单后直接返回骑手完整用户资料,不做字段加工。
+            if (order.getRiderId() != null) {
+                view.setRider(userMapper.selectInfoUserByUserId(order.getRiderId()));
+            }
+        } else if (!hideSensitiveFields) {
+            // 接单后仅中单骑手可见寄件用户完整资料;抢单前脱敏视图不返回用户身份信息。
+            if (order.getUserId() != null) {
+                view.setUser(userMapper.selectInfoUserByUserId(order.getUserId()));
+            }
         }
         if (!hideSensitiveFields && order.getId() != null) {
             groupImageUrls(view, imageMapper.selectList(new QueryWrapper<FlashDeliveryOrderImage>()
@@ -992,23 +1017,6 @@ public class FlashDeliveryApplicationService {
         return view;
     }
 
-    /** 组装骑手公开资料;仅已接单或已取件状态附带骑手实时位置。 */
-    private FlashDeliveryRiderSummaryView riderSummary(FlashDeliveryOrder order) {
-        if (order.getRiderId() == null) return null;
-        InfoUser rider = userMapper.selectInfoUserByUserId(order.getRiderId());
-        if (rider == null) return null;
-        FlashDeliveryRiderSummaryView view = new FlashDeliveryRiderSummaryView();
-        view.setName(hasText(rider.getNickName()) ? rider.getNickName() : rider.getUserName());
-        view.setAvatar(rider.getAvatar());
-        view.setRating(rider.getStar());
-        view.setImUserId(rider.getImUserId() == null ? null : String.valueOf(rider.getImUserId()));
-        if (ACCEPTED.equals(order.getStatus()) || PICKED_UP.equals(order.getStatus())) {
-            view.setLongitude(rider.getLongitude());
-            view.setLatitude(rider.getLatitude());
-        }
-        return view;
-    }
-
     /** 把费用明细写入报价视图。 */
     private void applyBreakdown(FlashDeliveryQuoteView view, FlashDeliveryPriceBreakdown breakdown) {
         view.setBillableDistance(breakdown.getBillableDistance());
@@ -1214,7 +1222,8 @@ public class FlashDeliveryApplicationService {
                 query.eq("status", WAITING_ACCEPTANCE).isNull("rider_id")
                         .eq("vehicle_type", riderVehicleType)
                         .and(item -> item.eq("delivery_mode", "NOW")
-                                .or().le("scheduled_pickup_start_at", now));
+                                .or(nested -> nested.le("scheduled_pickup_start_at", now)
+                                        .gt("scheduled_pickup_end_at", now)));
                 if (longitude != null) {
                     Integer limitKm = newTaskDistanceLimit();
                     if (limitKm != null) {
@@ -1317,12 +1326,14 @@ public class FlashDeliveryApplicationService {
         }
     }
 
-    /** 判断订单当前是否开放抢单:立即单随时可抢,预约单到点开放。 */
+    /** 判断订单当前是否开放抢单:立即单随时可抢,预约单仅在开始(含)至结束(不含)之间可抢。 */
     private boolean isAvailableAt(FlashDeliveryOrder order, Date now) {
         return "NOW".equals(order.getDeliveryMode())
                 || "SCHEDULED".equals(order.getDeliveryMode())
                 && order.getScheduledPickupStartAt() != null
-                && !order.getScheduledPickupStartAt().after(now);
+                && order.getScheduledPickupEndAt() != null
+                && !order.getScheduledPickupStartAt().after(now)
+                && order.getScheduledPickupEndAt().after(now);
     }
 
     private void optionalEq(QueryWrapper<FlashDeliveryOrder> query, String column, String value) {

+ 17 - 2
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/task/FlashDeliveryAutoCompleteTask.java

@@ -11,8 +11,8 @@ import java.time.temporal.ChronoUnit;
 import java.util.Date;
 
 /**
- * 将送达超过 24 小时且用户未确认的订单自动收口为已完成
- * 由若依任务调度调用 {@code flashDeliveryAutoCompleteTask.completeDeliveredOrders()}
+ * 提供闪送订单的 Quartz 收口任务:送达超时自动完成、预约取件超时自动取消
+ * 由若依任务调度分别调用本 Bean 的公开方法,避免使用应用内 {@code @Scheduled} 造成重复执行
  */
 @Component("flashDeliveryAutoCompleteTask")
 public class FlashDeliveryAutoCompleteTask {
@@ -31,4 +31,19 @@ public class FlashDeliveryAutoCompleteTask {
             }
         }
     }
+
+    /**
+     * 每分钟扫描已过预约取件结束时间但仍未接单的订单。接单和取消均走条件更新,
+     * 因此任务延迟或并发执行不会取消已经被骑手接取的订单。
+     */
+    public void cancelExpiredScheduledOrders() {
+        Date now = new Date();
+        for (FlashDeliveryOrder order : service.expiredScheduledCandidates(now, 200)) {
+            try {
+                service.cancelExpiredScheduledOrder(order.getId(), now);
+            } catch (RuntimeException exception) {
+                log.error("Flash delivery scheduled expiry failed, orderId={}", order.getId(), exception);
+            }
+        }
+    }
 }

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

@@ -338,3 +338,4 @@ flash.delivery.edit.not.allowed=仅待接单且未分配骑手的订单可以修
 flash.delivery.order.version.invalid=请提供有效的订单版本
 flash.delivery.tip.additional.invalid=追加小费必须为正整数且不能超出金额范围
 flash.delivery.pay.type.invalid=Chiỉ hỗ trợ thanh toán bằng tiền mặt hoặc chuyển khoản ngoại tuyến
+flash.delivery.schedule.not.open=The scheduled pickup window has not started or has already ended

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

@@ -341,3 +341,4 @@ flash.delivery.edit.not.allowed=Only orders awaiting acceptance with no assigned
 flash.delivery.order.version.invalid=Please provide a valid order version
 flash.delivery.tip.additional.invalid=Additional tip must be a positive integer within the supported amount range
 flash.delivery.pay.type.invalid=Payment method must be cash or offline transfer
+flash.delivery.schedule.not.open=The scheduled pickup window has not started or has already ended

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

@@ -342,3 +342,4 @@ flash.delivery.edit.not.allowed=แก้ไขได้เฉพาะคำส
 flash.delivery.order.version.invalid=กรุณาระบุเวอร์ชันคำสั่งซื้อที่ถูกต้อง
 flash.delivery.tip.additional.invalid=ทิปเพิ่มเติมต้องเป็นจำนวนเต็มบวกและไม่เกินวงเงินที่รองรับ
 flash.delivery.pay.type.invalid=รองรับเฉพาะการชำระเงินสดหรือโอนแบบออฟไลน์
+flash.delivery.schedule.not.open=ยังไม่ถึงช่วงเวลานัดรับสินค้า หรือช่วงเวลาดังกล่าวสิ้นสุดแล้ว จึงยังรับออเดอร์นี้ไม่ได้

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

@@ -341,3 +341,4 @@ flash.delivery.edit.not.allowed=Chỉ có thể sửa đơn đang chờ nhận v
 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ợ
 flash.delivery.pay.type.invalid=Chiỉ hỗ trợ thanh toán bằng tiền mặt hoặc chuyển khoản ngoại tuyến
+flash.delivery.schedule.not.open=Chưa đến hoặc đã hết thời gian nhận đơn theo lịch hẹn

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

@@ -342,3 +342,4 @@ flash.delivery.edit.not.allowed=仅待接单且未分配骑手的订单可以修
 flash.delivery.order.version.invalid=请提供有效的订单版本
 flash.delivery.tip.additional.invalid=追加小费必须为正整数且不能超出金额范围
 flash.delivery.pay.type.invalid=支付方式仅支持现金或线下转账
+flash.delivery.schedule.not.open=预约取件时间未开始或已结束,暂不能接单

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

@@ -342,3 +342,4 @@ flash.delivery.edit.not.allowed=僅待接單且未分配騎手的訂單可以修
 flash.delivery.order.version.invalid=請提供有效的訂單版本
 flash.delivery.tip.additional.invalid=追加小費必須為正整數且不能超出金額範圍
 flash.delivery.pay.type.invalid=支付方式僅支援現金或線下轉帳
+flash.delivery.schedule.not.open=預約取件時間未開始或已結束,暫不能接單

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

@@ -339,6 +339,7 @@ class FlashDeliveryApplicationServiceTest {
         assertTrue(sql.contains("rider_id is null"));
         assertTrue(sql.contains("vehicle_type"));
         assertTrue(sql.contains("scheduled_pickup_start_at"));
+        assertTrue(sql.contains("scheduled_pickup_end_at"));
         assertTrue(sql.contains("st_distance_sphere"));
         assertTrue(parameters.contains("WAITING_ACCEPTANCE"));
         assertTrue(parameters.contains(1));
@@ -353,6 +354,7 @@ class FlashDeliveryApplicationServiceTest {
         assertTrue(summarySql.contains("rider_id is null"));
         assertTrue(summarySql.contains("vehicle_type"));
         assertTrue(summarySql.contains("scheduled_pickup_start_at"));
+        assertTrue(summarySql.contains("scheduled_pickup_end_at"));
         assertTrue(summarySql.contains("st_distance_sphere"));
         assertTrue(summaryParameters.contains("WAITING_ACCEPTANCE"));
     }
@@ -505,9 +507,11 @@ class FlashDeliveryApplicationServiceTest {
         assertNull(result.getDelivery().getLatitude());
         assertNull(result.getDeliveryPinCode());
         assertNull(result.getUserNote());
+        assertNull(result.getUser());
         assertTrue(result.getSenderImageUrls().isEmpty());
         assertTrue(result.getPickupImageUrls().isEmpty());
         assertTrue(result.getDeliveryImageUrls().isEmpty());
+        verify(fixture.userMapper, never()).selectInfoUserByUserId(7L);
         verify(fixture.imageMapper, never()).selectList(any());
         verify(fixture.logMapper, never()).selectList(any());
     }
@@ -535,6 +539,53 @@ class FlashDeliveryApplicationServiceTest {
         verify(fixture.logMapper, never()).selectList(any());
     }
 
+    @Test
+    void userDetailReturnsRiderInfoAfterAcceptance() {
+        Fixture fixture = new Fixture();
+        FlashDeliveryOrder order = order(10L, ACCEPTED);
+        order.setRiderId(8L);
+        when(fixture.orderMapper.selectById(10L)).thenReturn(order);
+        InfoUser rider = new InfoUser();
+        rider.setUserId(8L);
+        rider.setUserType("2");
+        rider.setNickName("闪送骑士");
+        rider.setTelPhone("0933333333");
+        rider.setVehicleType("2");
+        rider.setLicensePlate("ABC-1234");
+        when(fixture.userMapper.selectInfoUserByUserId(8L)).thenReturn(rider);
+
+        var result = fixture.service.userDetail(7L, 10L);
+
+        assertEquals(8L, result.getRider().getUserId());
+        assertEquals("闪送骑士", result.getRider().getNickName());
+        assertEquals("0933333333", result.getRider().getTelPhone());
+        assertEquals("ABC-1234", result.getRider().getLicensePlate());
+        assertNull(result.getUser());
+    }
+
+    @Test
+    void assignedRiderDetailReturnsSenderInfo() {
+        Fixture fixture = new Fixture();
+        rider(fixture, 8L);
+        FlashDeliveryOrder order = order(10L, ACCEPTED);
+        order.setRiderId(8L);
+        when(fixture.orderMapper.selectById(10L)).thenReturn(order);
+        InfoUser sender = new InfoUser();
+        sender.setUserId(7L);
+        sender.setNickName("寄件达人");
+        sender.setPhone("0911111111");
+        sender.setAvatar("https://example.com/avatar.png");
+        when(fixture.userMapper.selectInfoUserByUserId(7L)).thenReturn(sender);
+
+        var result = fixture.service.riderDetail(8L, 10L);
+
+        assertEquals(7L, result.getUser().getUserId());
+        assertEquals("寄件达人", result.getUser().getNickName());
+        assertEquals("0911111111", result.getUser().getPhone());
+        assertEquals("https://example.com/avatar.png", result.getUser().getAvatar());
+        assertNull(result.getRider());
+    }
+
     @Test
     void matchedReceiverCanViewAndConfirmButCannotCancelOrder() {
         Fixture fixture = new Fixture();
@@ -593,6 +644,22 @@ class FlashDeliveryApplicationServiceTest {
         assertThrows(RuntimeException.class, () -> fixture.service.accept(8L, 10L));
     }
 
+    // 预约单已过取件结束时间:即使骑手绕过列表直接持有订单 ID 请求接单,锁内校验也必须拒绝。
+    @Test
+    void expiredScheduledOrderCannotBeAcceptedEvenWhenItsIdIsKnown() {
+        Fixture fixture = new Fixture();
+        rider(fixture, 8L);
+        FlashDeliveryOrder order = order(10L, "WAITING_ACCEPTANCE");
+        Date now = new Date();
+        order.setDeliveryMode("SCHEDULED");
+        order.setScheduledPickupStartAt(new Date(now.getTime() - 31L * 60L * 1000L));
+        order.setScheduledPickupEndAt(new Date(now.getTime() - 60L * 1000L));
+        when(fixture.orderMapper.selectById(10L)).thenReturn(order);
+
+        assertThrows(ServiceException.class, () -> fixture.service.accept(8L, 10L));
+        verify(fixture.orderMapper, never()).accept(any(), any(), any());
+    }
+
     @Test
     void flashAcceptUsesRiderLockAndChecksExclusivityBeforeAtomicUpdate() {
         Fixture fixture = new Fixture();
@@ -1138,7 +1205,6 @@ class FlashDeliveryApplicationServiceTest {
         return image;
     }
 
-
     @Test
     void missingOrInvalidBodyOrderIdIsRejectedBeforeDatabaseAccess() {
         Fixture fixture = new Fixture();

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

@@ -31,7 +31,7 @@ class FlashDeliveryI18nContractTest {
             "flash.delivery.tip.invalid", "flash.delivery.quote.changed",
             "flash.delivery.edit.not.allowed", "flash.delivery.order.version.invalid",
             "flash.delivery.tip.additional.invalid",
-            "flash.delivery.rider.exclusive.conflict",
+            "flash.delivery.rider.exclusive.conflict", "flash.delivery.schedule.not.open",
             "address.access.denied", "address.data.invalid");
 
     @Test

+ 18 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/task/FlashDeliveryAutoCompleteTaskTest.java

@@ -8,6 +8,7 @@ import org.springframework.stereotype.Component;
 
 import java.util.Date;
 import java.util.List;
+import java.util.Arrays;
 
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.anyInt;
@@ -61,4 +62,21 @@ class FlashDeliveryAutoCompleteTaskTest {
 
         verify(service).complete(null, "SYSTEM", 2L);
     }
+
+    // 若依 Quartz 按方法名反射调用任务 Bean,需保证超时取消入口存在且为 public。
+    @Test
+    void exposesQuartzMethodForCancellingExpiredScheduledOrders() throws Exception {
+        var method = Arrays.stream(FlashDeliveryAutoCompleteTask.class.getMethods())
+                .filter(candidate -> "cancelExpiredScheduledOrders".equals(candidate.getName()))
+                .findFirst().orElse(null);
+
+        assertEquals("cancelExpiredScheduledOrders", method == null ? null : method.getName());
+        if (method != null) {
+            FlashDeliveryApplicationService service = mock(FlashDeliveryApplicationService.class);
+            method.invoke(new FlashDeliveryAutoCompleteTask(service));
+            assertEquals(1L, org.mockito.Mockito.mockingDetails(service).getInvocations().stream()
+                    .filter(invocation -> "expiredScheduledCandidates".equals(invocation.getMethod().getName()))
+                    .count());
+        }
+    }
 }

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

@@ -52,4 +52,10 @@ public interface FlashDeliveryOrderMapper extends BaseMapper<FlashDeliveryOrder>
     /** 分批查询送达时间早于截止点且仍未完成的订单。 */
     List<FlashDeliveryOrder> selectAutoCompletable(@Param("deadline") Date deadline,
                                                    @Param("limit") int limit);
+
+    /** 查询预约取件时段结束后仍未被骑手接取的订单,供系统任务分批取消。 */
+    List<FlashDeliveryOrder> selectExpiredScheduled(@Param("now") Date now, @Param("limit") int limit);
+
+    /** 原子取消过期预约单;抢单或人工取消已先完成时返回 0,避免覆盖正常状态。 */
+    int cancelExpiredScheduled(@Param("id") Long id, @Param("now") Date now);
 }

+ 20 - 1
ruoyi-system/src/main/resources/mapper/flash/FlashDeliveryOrderMapper.xml

@@ -82,7 +82,8 @@
         update flash_delivery_order
         set rider_id=#{riderId}, status='ACCEPTED', accepted_at=#{now}, update_time=#{now}, version = version + 1
         where id=#{id} and status = 'WAITING_ACCEPTANCE' and rider_id is null
-          and (delivery_mode = 'NOW' or scheduled_pickup_start_at &lt;= #{now})
+          and (delivery_mode = 'NOW' or (scheduled_pickup_start_at &lt;= #{now}
+               and scheduled_pickup_end_at &gt; #{now}))
     </update>
 
     <!-- 编辑与抢单竞争时,由同一订单行的原子条件更新决定谁先成功。 -->
@@ -159,4 +160,22 @@
         where status='DELIVERED' and delivered_at &lt;= #{deadline}
         order by delivered_at asc limit #{limit}
     </select>
+
+    <!-- 预约时段结束仍无人接单时,由任务分批收口;只读取仍可取消的待接订单。 -->
+    <select id="selectExpiredScheduled" resultMap="FlashDeliveryOrderResult">
+        select * from flash_delivery_order
+        where status='WAITING_ACCEPTANCE' and rider_id is null and delivery_mode='SCHEDULED'
+          and scheduled_pickup_end_at &lt;= #{now}
+        order by scheduled_pickup_end_at asc limit #{limit}
+    </select>
+
+    <!-- 接单、用户取消与定时取消竞争时,以此条件更新决定唯一终态。 -->
+    <update id="cancelExpiredScheduled">
+        update flash_delivery_order
+        set status='CANCELLED', cancelled_at=#{now}, cancel_reason='SCHEDULE_EXPIRED',
+            cancel_operator_type='SYSTEM', cancel_operator_id=null,
+            update_time=#{now}, version = version + 1
+        where id=#{id} and status='WAITING_ACCEPTANCE' and rider_id is null and delivery_mode='SCHEDULED'
+          and scheduled_pickup_end_at &lt;= #{now}
+    </update>
 </mapper>

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

@@ -117,6 +117,11 @@ class FlashDeliveryMapperXmlTest {
         assertTrue(xml.contains("status = 'WAITING_ACCEPTANCE'"));
         assertTrue(xml.contains("rider_id is null"));
         assertTrue(xml.contains("scheduled_pickup_start_at &lt;= #{now}"));
+        assertTrue(xml.contains("scheduled_pickup_end_at &gt; #{now}"));
+        assertTrue(xml.contains("selectExpiredScheduled"));
+        assertTrue(xml.contains("cancelExpiredScheduled"));
+        assertTrue(xml.contains("scheduled_pickup_end_at &lt;= #{now}"));
+        assertTrue(xml.contains("cancel_reason='SCHEDULE_EXPIRED'"));
         assertTrue(xml.contains("package_type"));
         assertTrue(xml.contains("delivery_pin_code"));
         assertTrue(xml.contains("delivered_at &lt;="));

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

@@ -13,6 +13,8 @@
 - `vehicleType`:`1`=机车、`2`=轿车;报价和创建均必填并必须一致。历史订单、骑手资料和运价配置的空值按机车处理。
 - 路线距离和 `estimatedDurationSeconds` 按 `vehicleType` 获取:机车使用 Google Routes 的 `TWO_WHEELER`,轿车使用 `DRIVE`。地图路线不可用时,`distanceSource=STRAIGHT_LINE`,`estimatedDurationSeconds` 仍必返:机车以 25 km/h、轿车以 30 km/h 的保守速度按直线距离估算,向上取整且最少 60 秒;该时长不参与金额计算。两轮路线是 Google Beta 能力;本期不修改用户 App 的提示文案。
 - `deliveryMode`:`NOW`、`SCHEDULED`。预约起止时间使用 ISO 日期时间,时段固定 30 分钟且开始时间不晚于三天后。
+
+预约单只在 `scheduledPickupStartAt <= 当前时间 < scheduledPickupEndAt` 的窗口内向骑手开放;结束时仍为待接单的订单由 Quartz 任务自动取消,取消原因代码为 `SCHEDULE_EXPIRED`。
 - `status`:`WAITING_ACCEPTANCE`、`ACCEPTED`、`PICKED_UP`、`DELIVERED`、`COMPLETED`、`CANCELLED`。
 
 ## 地址对象
@@ -160,3 +162,9 @@
 - 同一小费请求成功后重发旧版本会被拒绝,不会重复加款。网络超时应刷新详情核对小费和总金额,不应自动换新版本重复追加。
 - 地址报价后如追加过小费,旧地址确认失效;刷新详情、重新报价并让用户确认。
 - 当前取消规则、支付和退款范围不变;无新增数据库字段或迁移 SQL。
+
+## 2026-09-17:订单详情返回对端完整用户资料
+
+- `GET /system/flashDelivery/orders/{id}`:骑手接单后 `rider` 直接返回骑手完整 `info_user` 资料(含联系方式、车型、车牌和坐标),未接单时为空;不再按状态裁剪位置字段,也不再是精简摘要对象。
+- `GET /system/flashDelivery/rider/orders/{id}` 及接单成功响应:接单后新增 `user` 字段,直接返回寄件用户完整 `info_user` 资料;抢单前脱敏详情不返回 `user`,用户端详情不返回寄件用户资料。
+

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

@@ -212,6 +212,7 @@
 - **FR-039**:本功能的数据库变更只能记录在 `updatesql/sql.md`,不得直接执行。
 - **FR-040**:订单必须支持 `NOW` 和 `SCHEDULED` 两种配送方式;预约时段固定为 30 分钟且不得晚于创建时间后三天。
 - **FR-041**:预约订单在预约开始时间前不得出现在可抢列表,也不得被骑手通过订单 ID 提前抢取。
+- **FR-103**:预约订单仅能在预约开始时间(含)至结束时间(不含)之间被骑手接取;结束时仍未接单的订单必须由系统任务自动取消,列表和接单原子更新均不得允许过期订单继续接取。
 - **FR-042**:用户可选择是否启用 4 位数字交付 PIN;启用后骑手必须同时提交正确 PIN 和至少一张送达图片。
 - **FR-043**:PIN 只对订单用户和有权限的平台管理员可见,不得返回给待接单或已接单骑手,也不得写入业务日志。
 - **FR-044**:创建订单可提交 1 至 9 张可选寄件图片;凭证类型扩展为 `SENDER`、`PICKUP`、`DELIVERY` 并记录操作人类型。
@@ -225,6 +226,7 @@
 - **FR-052**:价格表单必须校验时间范围有效、全部时段互不重叠、距离为正数、起送价格和计价金额为正整数、加急比例和最低加急费为非负数;配置保存即生效,删除后立即不可用于报价,操作成功后必须重新读取服务端列表。
 - **FR-053**:订单页面必须按服务端分页,并支持状态、服务类型、订单号、用户 ID 和骑手 ID 组合筛选及一键重置。
 - **FR-054**:平台订单详情必须展示订单概况、取送地址、路线与费用、预约与 PIN、骑手 ID 与履约时间、三类图片凭证及状态日志;不得要求管理员从列表字段拼接详情。
+- **FR-104**:平台订单概况对预约订单必须直接展示预约取件开始及结束时间,不得只显示“预约配送”。
 - **FR-055**:平台取消必须要求非空原因和二次确认;平台完成必须二次确认。操作成功后刷新服务端数据,操作失败时保留当前页面并显示后端错误。
 - **FR-056**:平台前端新增的全部用户可见文本必须通过 Vue i18n 提供简体中文、繁体中文、英文和越南文,不得硬编码单一语言文本。
 - **FR-057**:平台前端必须沿用现有 Vue 2、Element UI、若依请求封装、动态菜单和 `v-hasPermi` 体系,不新增重复的状态管理或 UI 框架。

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

@@ -182,3 +182,4 @@
 
 - [x] T101 【2026-09-17】按 `vehicleType`(1=机车、2=轿车)扩展闪送运价、报价、订单快照、骑手待抢/详情/接单校验、Google Routes(机车 TWO_WHEELER、轿车 DRIVE)与平台运价配置;历史数据默认机车;重量范围和规格改为选填。本期不修改用户 App,详见 spec.md「按配送车型计价」。
 - [x] T102 【2026-09-17】闪送与外卖共用 `sys_googlemap_key`;Google Routes 降级日志仅记录车型和失败原因,不记录地址、坐标或 Key;同步契约、快速开始和研究文档。
+- [x] T103 【2026-09-17】预约单结束时间到达后自动取消:列表/接单限制结束边界,Quartz 每分钟以条件更新收口,平台详情概况显示预约取件时段,补 SQL、注释和定向测试;SQL 仅已登记,尚未执行。

+ 14 - 0
updatesql/sql.md

@@ -1355,6 +1355,20 @@ WHERE NOT EXISTS (
 );
 ```
 
+## 2026-09-17 闪送预约超时自动取消(024-flash-delivery)
+
+> 用途:每分钟取消预约取件时间已结束、仍无人接单的闪送订单。仅记录脚本,由开发者手动执行;接单 SQL 同时校验预约窗口,任务延迟不会允许过期接单。
+
+```sql
+INSERT INTO sys_job
+  (job_name, job_group, invoke_target, cron_expression, misfire_policy, concurrent, status, create_by, create_time, remark)
+SELECT '闪送预约超时取消', 'DEFAULT', 'flashDeliveryAutoCompleteTask.cancelExpiredScheduledOrders()',
+       '0 0/1 * * * ?', '3', '1', '0', 'admin', NOW(), '预约取件时段结束仍未接单的闪送订单自动取消'
+WHERE NOT EXISTS (
+  SELECT 1 FROM sys_job WHERE invoke_target = 'flashDeliveryAutoCompleteTask.cancelExpiredScheduledOrders()'
+);
+```
+
 ## 2026-09-02 骑手配送类型(025-rider-delivery-type)
 
 > 用途:骑手审核资料增加配送类型多选(美食外送 FOOD、闪送 FLASH),派单按骑手选择的配送类型过滤。只记录脚本,由开发者手动执行。