Răsfoiți Sursa

feat: add flash delivery availability notifications

qmj 1 zi în urmă
părinte
comite
4051099b17
37 a modificat fișierele cu 566 adăugiri și 222 ștergeri
  1. 1 7
      .gitignore
  2. BIN
      .idea/.cache/.Apifox_Helper/.toolWindow.db
  3. 0 10
      .idea/.gitignore
  4. 0 10
      .idea/ApifoxUploaderProjectSetting.xml
  5. 0 7
      .idea/MarsCodeWorkspaceAppSettings.xml
  6. 0 7
      .idea/ProjectStorage.xml
  7. 0 9
      .idea/inspectionProfiles/Project_Default.xml
  8. 0 124
      .idea/uiDesigner.xml
  9. 27 3
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryApplicationService.java
  10. 64 4
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryNotificationService.java
  11. 32 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/task/FlashDeliveryAvailabilityTask.java
  12. 1 1
      ruoyi-admin/src/main/java/com/ruoyi/app/order/DeliveryOrderNotificationService.java
  13. 2 0
      ruoyi-admin/src/main/resources/i18n/messages.properties
  14. 2 0
      ruoyi-admin/src/main/resources/i18n/messages_en_US.properties
  15. 2 0
      ruoyi-admin/src/main/resources/i18n/messages_th_TH.properties
  16. 2 0
      ruoyi-admin/src/main/resources/i18n/messages_vi.properties
  17. 2 0
      ruoyi-admin/src/main/resources/i18n/messages_zh_CN.properties
  18. 2 0
      ruoyi-admin/src/main/resources/i18n/messages_zh_TW.properties
  19. 65 12
      ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryApplicationServiceTest.java
  20. 1 0
      ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryI18nContractTest.java
  21. 39 0
      ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryNotificationServiceTest.java
  22. 63 0
      ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/task/FlashDeliveryAvailabilityTaskTest.java
  23. 2 0
      ruoyi-system/src/main/java/com/ruoyi/system/domain/flash/FlashDeliveryOrder.java
  24. 31 0
      ruoyi-system/src/main/java/com/ruoyi/system/mapper/RiderPositionMapper.java
  25. 7 0
      ruoyi-system/src/main/java/com/ruoyi/system/mapper/flash/FlashDeliveryOrderMapper.java
  26. 20 1
      ruoyi-system/src/main/resources/mapper/flash/FlashDeliveryOrderMapper.xml
  27. 61 0
      ruoyi-system/src/test/java/com/ruoyi/system/mapper/RiderPositionMapperContractTest.java
  28. 5 0
      ruoyi-system/src/test/java/com/ruoyi/system/mapper/flash/FlashDeliveryMapperXmlTest.java
  29. 1 1
      specs/028-flash-delivery-push/checklists/requirements.md
  30. 6 4
      specs/028-flash-delivery-push/contracts/push.md
  31. 11 2
      specs/028-flash-delivery-push/data-model.md
  32. 17 11
      specs/028-flash-delivery-push/plan.md
  33. 8 3
      specs/028-flash-delivery-push/quickstart.md
  34. 19 1
      specs/028-flash-delivery-push/research.md
  35. 29 3
      specs/028-flash-delivery-push/spec.md
  36. 19 2
      specs/028-flash-delivery-push/tasks.md
  37. 25 0
      updatesql/sql.md

+ 1 - 7
.gitignore

@@ -2,13 +2,7 @@
 # IntelliJ IDEA
 # Maven / Spring Boot
 *.class
-.idea/compiler.xml
-.idea/editorJumperProjectSettings.xml
-.idea/encodings.xml
-.idea/jarRepositories.xml
-.idea/jpa-buddy.xml
-.idea/misc.xml
-.idea/vcs.xml
+.idea/
 .superpowers/
 .worktrees/
 /.claude/*.json

BIN
.idea/.cache/.Apifox_Helper/.toolWindow.db


+ 0 - 10
.idea/.gitignore

@@ -1,10 +0,0 @@
-# 默认忽略的文件
-/shelf/
-/workspace.xml
-# Datasource local storage ignored files
-/dataSources/
-/dataSources.local.xml
-# 基于编辑器的 HTTP 客户端请求
-/httpRequests/
-# Zeppelin ignored files
-/ZeppelinRemoteNotebooks/

Fișier diff suprimat deoarece este prea mare
+ 0 - 10
.idea/ApifoxUploaderProjectSetting.xml


+ 0 - 7
.idea/MarsCodeWorkspaceAppSettings.xml

@@ -1,7 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<project version="4">
-  <component name="com.codeverse.userSettings.MarscodeWorkspaceAppSettingsState">
-    <option name="ckgOperationStatus" value="SUCCESS" />
-    <option name="progress" value="1.0" />
-  </component>
-</project>

+ 0 - 7
.idea/ProjectStorage.xml

@@ -1,7 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<project version="4">
-  <component name="ProjectStorage">
-    <option name="path" value="覃明健-&gt;foodie" />
-    <option name="projectId" value="4f55bfdb5070000" />
-  </component>
-</project>

+ 0 - 9
.idea/inspectionProfiles/Project_Default.xml

@@ -1,9 +0,0 @@
-<component name="InspectionProjectProfileManager">
-  <profile version="1.0">
-    <option name="myName" value="Project Default" />
-    <inspection_tool class="AutoCloseableResource" enabled="true" level="WARNING" enabled_by_default="true">
-      <option name="METHOD_MATCHER_CONFIG" value="java.util.Formatter,format,java.io.Writer,append,com.google.common.base.Preconditions,checkNotNull,org.hibernate.Session,close,java.io.PrintWriter,printf,java.io.PrintStream,printf,org.apache.http.impl.client.HttpClients,createDefault" />
-    </inspection_tool>
-    <inspection_tool class="XmlUnresolvedReference" enabled="false" level="ERROR" enabled_by_default="false" />
-  </profile>
-</component>

+ 0 - 124
.idea/uiDesigner.xml

@@ -1,124 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<project version="4">
-  <component name="Palette2">
-    <group name="Swing">
-      <item class="com.intellij.uiDesigner.HSpacer" tooltip-text="Horizontal Spacer" icon="/com/intellij/uiDesigner/icons/hspacer.svg" removable="false" auto-create-binding="false" can-attach-label="false">
-        <default-constraints vsize-policy="1" hsize-policy="6" anchor="0" fill="1" />
-      </item>
-      <item class="com.intellij.uiDesigner.VSpacer" tooltip-text="Vertical Spacer" icon="/com/intellij/uiDesigner/icons/vspacer.svg" removable="false" auto-create-binding="false" can-attach-label="false">
-        <default-constraints vsize-policy="6" hsize-policy="1" anchor="0" fill="2" />
-      </item>
-      <item class="javax.swing.JPanel" icon="/com/intellij/uiDesigner/icons/panel.svg" removable="false" auto-create-binding="false" can-attach-label="false">
-        <default-constraints vsize-policy="3" hsize-policy="3" anchor="0" fill="3" />
-      </item>
-      <item class="javax.swing.JScrollPane" icon="/com/intellij/uiDesigner/icons/scrollPane.svg" removable="false" auto-create-binding="false" can-attach-label="true">
-        <default-constraints vsize-policy="7" hsize-policy="7" anchor="0" fill="3" />
-      </item>
-      <item class="javax.swing.JButton" icon="/com/intellij/uiDesigner/icons/button.svg" removable="false" auto-create-binding="true" can-attach-label="false">
-        <default-constraints vsize-policy="0" hsize-policy="3" anchor="0" fill="1" />
-        <initial-values>
-          <property name="text" value="Button" />
-        </initial-values>
-      </item>
-      <item class="javax.swing.JRadioButton" icon="/com/intellij/uiDesigner/icons/radioButton.svg" removable="false" auto-create-binding="true" can-attach-label="false">
-        <default-constraints vsize-policy="0" hsize-policy="3" anchor="8" fill="0" />
-        <initial-values>
-          <property name="text" value="RadioButton" />
-        </initial-values>
-      </item>
-      <item class="javax.swing.JCheckBox" icon="/com/intellij/uiDesigner/icons/checkBox.svg" removable="false" auto-create-binding="true" can-attach-label="false">
-        <default-constraints vsize-policy="0" hsize-policy="3" anchor="8" fill="0" />
-        <initial-values>
-          <property name="text" value="CheckBox" />
-        </initial-values>
-      </item>
-      <item class="javax.swing.JLabel" icon="/com/intellij/uiDesigner/icons/label.svg" removable="false" auto-create-binding="false" can-attach-label="false">
-        <default-constraints vsize-policy="0" hsize-policy="0" anchor="8" fill="0" />
-        <initial-values>
-          <property name="text" value="Label" />
-        </initial-values>
-      </item>
-      <item class="javax.swing.JTextField" icon="/com/intellij/uiDesigner/icons/textField.svg" removable="false" auto-create-binding="true" can-attach-label="true">
-        <default-constraints vsize-policy="0" hsize-policy="6" anchor="8" fill="1">
-          <preferred-size width="150" height="-1" />
-        </default-constraints>
-      </item>
-      <item class="javax.swing.JPasswordField" icon="/com/intellij/uiDesigner/icons/passwordField.svg" removable="false" auto-create-binding="true" can-attach-label="true">
-        <default-constraints vsize-policy="0" hsize-policy="6" anchor="8" fill="1">
-          <preferred-size width="150" height="-1" />
-        </default-constraints>
-      </item>
-      <item class="javax.swing.JFormattedTextField" icon="/com/intellij/uiDesigner/icons/formattedTextField.svg" removable="false" auto-create-binding="true" can-attach-label="true">
-        <default-constraints vsize-policy="0" hsize-policy="6" anchor="8" fill="1">
-          <preferred-size width="150" height="-1" />
-        </default-constraints>
-      </item>
-      <item class="javax.swing.JTextArea" icon="/com/intellij/uiDesigner/icons/textArea.svg" removable="false" auto-create-binding="true" can-attach-label="true">
-        <default-constraints vsize-policy="6" hsize-policy="6" anchor="0" fill="3">
-          <preferred-size width="150" height="50" />
-        </default-constraints>
-      </item>
-      <item class="javax.swing.JTextPane" icon="/com/intellij/uiDesigner/icons/textPane.svg" removable="false" auto-create-binding="true" can-attach-label="true">
-        <default-constraints vsize-policy="6" hsize-policy="6" anchor="0" fill="3">
-          <preferred-size width="150" height="50" />
-        </default-constraints>
-      </item>
-      <item class="javax.swing.JEditorPane" icon="/com/intellij/uiDesigner/icons/editorPane.svg" removable="false" auto-create-binding="true" can-attach-label="true">
-        <default-constraints vsize-policy="6" hsize-policy="6" anchor="0" fill="3">
-          <preferred-size width="150" height="50" />
-        </default-constraints>
-      </item>
-      <item class="javax.swing.JComboBox" icon="/com/intellij/uiDesigner/icons/comboBox.svg" removable="false" auto-create-binding="true" can-attach-label="true">
-        <default-constraints vsize-policy="0" hsize-policy="2" anchor="8" fill="1" />
-      </item>
-      <item class="javax.swing.JTable" icon="/com/intellij/uiDesigner/icons/table.svg" removable="false" auto-create-binding="true" can-attach-label="false">
-        <default-constraints vsize-policy="6" hsize-policy="6" anchor="0" fill="3">
-          <preferred-size width="150" height="50" />
-        </default-constraints>
-      </item>
-      <item class="javax.swing.JList" icon="/com/intellij/uiDesigner/icons/list.svg" removable="false" auto-create-binding="true" can-attach-label="false">
-        <default-constraints vsize-policy="6" hsize-policy="2" anchor="0" fill="3">
-          <preferred-size width="150" height="50" />
-        </default-constraints>
-      </item>
-      <item class="javax.swing.JTree" icon="/com/intellij/uiDesigner/icons/tree.svg" removable="false" auto-create-binding="true" can-attach-label="false">
-        <default-constraints vsize-policy="6" hsize-policy="6" anchor="0" fill="3">
-          <preferred-size width="150" height="50" />
-        </default-constraints>
-      </item>
-      <item class="javax.swing.JTabbedPane" icon="/com/intellij/uiDesigner/icons/tabbedPane.svg" removable="false" auto-create-binding="true" can-attach-label="false">
-        <default-constraints vsize-policy="3" hsize-policy="3" anchor="0" fill="3">
-          <preferred-size width="200" height="200" />
-        </default-constraints>
-      </item>
-      <item class="javax.swing.JSplitPane" icon="/com/intellij/uiDesigner/icons/splitPane.svg" removable="false" auto-create-binding="false" can-attach-label="false">
-        <default-constraints vsize-policy="3" hsize-policy="3" anchor="0" fill="3">
-          <preferred-size width="200" height="200" />
-        </default-constraints>
-      </item>
-      <item class="javax.swing.JSpinner" icon="/com/intellij/uiDesigner/icons/spinner.svg" removable="false" auto-create-binding="true" can-attach-label="true">
-        <default-constraints vsize-policy="0" hsize-policy="6" anchor="8" fill="1" />
-      </item>
-      <item class="javax.swing.JSlider" icon="/com/intellij/uiDesigner/icons/slider.svg" removable="false" auto-create-binding="true" can-attach-label="false">
-        <default-constraints vsize-policy="0" hsize-policy="6" anchor="8" fill="1" />
-      </item>
-      <item class="javax.swing.JSeparator" icon="/com/intellij/uiDesigner/icons/separator.svg" removable="false" auto-create-binding="false" can-attach-label="false">
-        <default-constraints vsize-policy="6" hsize-policy="6" anchor="0" fill="3" />
-      </item>
-      <item class="javax.swing.JProgressBar" icon="/com/intellij/uiDesigner/icons/progressbar.svg" removable="false" auto-create-binding="true" can-attach-label="false">
-        <default-constraints vsize-policy="0" hsize-policy="6" anchor="0" fill="1" />
-      </item>
-      <item class="javax.swing.JToolBar" icon="/com/intellij/uiDesigner/icons/toolbar.svg" removable="false" auto-create-binding="false" can-attach-label="false">
-        <default-constraints vsize-policy="0" hsize-policy="6" anchor="0" fill="1">
-          <preferred-size width="-1" height="20" />
-        </default-constraints>
-      </item>
-      <item class="javax.swing.JToolBar$Separator" icon="/com/intellij/uiDesigner/icons/toolbarSeparator.svg" removable="false" auto-create-binding="false" can-attach-label="false">
-        <default-constraints vsize-policy="0" hsize-policy="0" anchor="0" fill="1" />
-      </item>
-      <item class="javax.swing.JScrollBar" icon="/com/intellij/uiDesigner/icons/scrollbar.svg" removable="false" auto-create-binding="true" can-attach-label="false">
-        <default-constraints vsize-policy="6" hsize-policy="0" anchor="0" fill="2" />
-      </item>
-    </group>
-  </component>
-</project>

+ 27 - 3
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryApplicationService.java

@@ -179,6 +179,7 @@ public class FlashDeliveryApplicationService {
         order.setDeliveryType(request.getDeliveryType());
         order.setVehicleType(request.getVehicleType());
         order.setStatus(WAITING_ACCEPTANCE);
+        order.setIsDisplay("NOW".equals(deliveryMode));
         order.setPackageType(request.getPackageType());
         order.setQuantity(request.getQuantity());
         order.setWeightRange(request.getWeightRange());
@@ -226,6 +227,9 @@ public class FlashDeliveryApplicationService {
         }
         saveImages(order.getId(), "SENDER", "USER", userId, senderImages, now);
         writeLog(order.getId(), null, WAITING_ACCEPTANCE, "USER", userId, null, now);
+        if (Boolean.TRUE.equals(order.getIsDisplay())) {
+            notificationService.notifyAvailable(order);
+        }
         return participantDetail(order, true, false);
     }
 
@@ -458,7 +462,8 @@ public class FlashDeliveryApplicationService {
         FlashDeliveryOrder order = requireOrder(orderId);
         if (WAITING_ACCEPTANCE.equals(order.getStatus()) && order.getRiderId() == null) {
             // 未开通闪送或车型不匹配时按订单不存在处理,避免泄露待抢任务信息。
-            if (!isAvailableAt(order, new Date()) || !rider.supportsDeliveryType("FLASH")
+            if (!Boolean.TRUE.equals(order.getIsDisplay()) || !isAvailableAt(order, new Date())
+                    || !rider.supportsDeliveryType("FLASH")
                     || !isRiderVehicleCompatible(rider, order)) {
                 throw fail("flash.delivery.order.not.found");
             }
@@ -481,7 +486,9 @@ public class FlashDeliveryApplicationService {
             if (!isRiderVehicleCompatible(rider, target)) throw fail("flash.delivery.rider.vehicle.not.match");
             Date now = new Date();
             // 预约单必须仍处在取件窗口内;即使骑手持有订单 ID,也不能绕过列表限制提前或逾期接单。
-            if (!isAvailableAt(target, now)) throw fail("flash.delivery.schedule.not.open");
+            if (!Boolean.TRUE.equals(target.getIsDisplay()) || !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) {
@@ -649,6 +656,23 @@ public class FlashDeliveryApplicationService {
         return orderMapper.selectExpiredScheduled(now, Math.min(Math.max(limit, 1), 500));
     }
 
+    /** 查询已进入预约窗口但仍未向骑手开放的订单,供独立 Quartz 任务分批处理。 */
+    public List<FlashDeliveryOrder> scheduledAvailabilityCandidates(Date now, int limit) {
+        return orderMapper.selectScheduledAvailabilityCandidates(now, Math.min(Math.max(limit, 1), 500));
+    }
+
+    /** 原子开放预约订单;仅 false -> true 成功的一方触发一次骑手新单通知。 */
+    @Transactional
+    public boolean openScheduledOrderForRiders(Long orderId, Date now) {
+        if (orderId == null || now == null) return false;
+        FlashDeliveryOrder order = orderMapper.selectById(orderId);
+        if (order == null || orderMapper.openScheduledForRiders(orderId, now) != 1) return false;
+        order.setIsDisplay(true);
+        order.setUpdateTime(now);
+        notificationService.notifyAvailable(order);
+        return true;
+    }
+
     /** 原子取消过期预约单;条件更新失败表示订单已被抢、取消或状态已变化,无需重复写日志。 */
     @Transactional
     public boolean cancelExpiredScheduledOrder(Long orderId, Date now) {
@@ -1234,7 +1258,7 @@ public class FlashDeliveryApplicationService {
         Date now = new Date();
         switch (tab) {
             case "newTask" -> {
-                query.eq("status", WAITING_ACCEPTANCE).isNull("rider_id")
+                query.eq("status", WAITING_ACCEPTANCE).isNull("rider_id").eq("is_display", true)
                         .eq("vehicle_type", riderVehicleType)
                         .and(item -> item.eq("delivery_mode", "NOW")
                                 .or(nested -> nested.le("scheduled_pickup_start_at", now)

+ 64 - 4
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryNotificationService.java

@@ -3,10 +3,14 @@ package com.ruoyi.app.flashdelivery.service;
 import com.ruoyi.app.order.dto.OrderPushBodyDto;
 import com.ruoyi.app.utils.PayPush;
 import com.ruoyi.app.utils.event.PushEventService;
+import com.ruoyi.common.core.domain.entity.SysDictData;
+import com.ruoyi.common.utils.DictUtils;
 import com.ruoyi.framework.manager.AsyncManager;
 import com.ruoyi.system.domain.InfoUser;
+import com.ruoyi.system.domain.RiderPosition;
 import com.ruoyi.system.domain.flash.FlashDeliveryOrder;
 import com.ruoyi.system.mapper.InfoUserMapper;
+import com.ruoyi.system.mapper.RiderPositionMapper;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import org.springframework.beans.factory.annotation.Autowired;
@@ -14,15 +18,17 @@ import org.springframework.stereotype.Service;
 import org.springframework.transaction.support.TransactionSynchronization;
 import org.springframework.transaction.support.TransactionSynchronizationManager;
 
+import java.util.List;
 import java.util.TimerTask;
 
 import static com.ruoyi.system.domain.flash.FlashDeliveryStatus.ACCEPTED;
 import static com.ruoyi.system.domain.flash.FlashDeliveryStatus.CANCELLED;
 import static com.ruoyi.system.domain.flash.FlashDeliveryStatus.DELIVERED;
 import static com.ruoyi.system.domain.flash.FlashDeliveryStatus.PICKED_UP;
+import static com.ruoyi.system.domain.flash.FlashDeliveryStatus.WAITING_ACCEPTANCE;
 
 /**
- * 闪送订单状态变更后的消息推送:履约事件(接单、取件、送达)推寄件用户,取消事件推接单骑手。
+ * 闪送订单消息推送:可抢新单推符合条件的骑手,履约事件推寄件用户,取消事件推接单骑手。
  * 推送统一在事务提交后异步执行,失败仅记日志,不影响订单状态流转本身。
  */
 @Service
@@ -34,18 +40,27 @@ public class FlashDeliveryNotificationService {
     private static final String CONTENT_PICKED_UP = "no.message.push.delivery.personnel.qspsz.order";
     private static final String CONTENT_DELIVERED = "no.message.push.delivery.personnel.qsysd.order";
     private static final String CONTENT_CANCELLED = "no.message.push.order.cancelled";
+    private static final String CONTENT_AVAILABLE = "no.message.push.new.flash.order";
+    private static final int RIDER_NOTIFICATION_LIMIT = 20;
     /** 推送 payload 的业务类型:1=外卖、2=充值,3=闪送。 */
     private static final int PUSH_TYPE_FLASH = 3;
-    /** -1 表示非语音提醒(0 仅用于外卖骑手新单语音)。 */
+    /** -1 表示非语音提醒;0 表示骑手新单提醒。 */
     private static final int PUSH_PAYLOAD_TYPE_NONE = -1;
 
     private final InfoUserMapper userMapper;
     private final PushEventService pushEventService;
+    private final RiderPositionMapper riderPositionMapper;
 
-    @Autowired
     public FlashDeliveryNotificationService(InfoUserMapper userMapper, PushEventService pushEventService) {
+        this(userMapper, pushEventService, null);
+    }
+
+    @Autowired
+    public FlashDeliveryNotificationService(InfoUserMapper userMapper, PushEventService pushEventService,
+                                            RiderPositionMapper riderPositionMapper) {
         this.userMapper = userMapper;
         this.pushEventService = pushEventService;
+        this.riderPositionMapper = riderPositionMapper;
     }
 
     /** 按订单取寄件用户;订单或用户缺失时静默跳过。 */
@@ -80,9 +95,14 @@ public class FlashDeliveryNotificationService {
 
     /** 向接单骑手推送指定状态文案;与用户端一致,失败仅记日志。 */
     void sendToRider(InfoUser recipient, FlashDeliveryOrder order, String status, String contentKey) {
+        sendToRider(recipient, order, status, contentKey, PUSH_PAYLOAD_TYPE_NONE);
+    }
+
+    private void sendToRider(InfoUser recipient, FlashDeliveryOrder order, String status,
+                             String contentKey, int payloadType) {
         if (recipient == null || recipient.getUserId() == null) return;
         String orderNo = order.getOrderNo();
-        String body = OrderPushBodyDto.getJson(orderNo, status, PUSH_PAYLOAD_TYPE_NONE, PUSH_TYPE_FLASH);
+        String body = OrderPushBodyDto.getJson(orderNo, status, payloadType, PUSH_TYPE_FLASH);
         AsyncManager.me().execute(new TimerTask() {
             @Override
             public void run() {
@@ -96,6 +116,33 @@ public class FlashDeliveryNotificationService {
         });
     }
 
+    /** 向一名可接单骑手发送闪送新单提醒;type=0 保留骑手端新单语音语义。 */
+    void sendAvailableOrderToRider(RiderPosition rider, FlashDeliveryOrder order) {
+        if (rider == null || rider.getRiderId() == null) return;
+        InfoUser recipient = new InfoUser();
+        recipient.setUserId(rider.getRiderId());
+        recipient.setCid(rider.getCid());
+        sendToRider(recipient, order, WAITING_ACCEPTANCE, CONTENT_AVAILABLE, 0);
+    }
+
+    /** 订单已向骑手开放后,通知附近最多 20 名符合闪送接单条件的骑手。 */
+    public void notifyAvailable(FlashDeliveryOrder order) {
+        if (order == null || riderPositionMapper == null || !WAITING_ACCEPTANCE.equals(order.getStatus())
+                || !Boolean.TRUE.equals(order.getIsDisplay()) || order.getRiderId() != null
+                || order.getPickupLongitude() == null || order.getPickupLatitude() == null) {
+            return;
+        }
+        runAfterCommit(() -> {
+            int vehicleType = Integer.valueOf(2).equals(order.getVehicleType()) ? 2 : 1;
+            List<RiderPosition> riders = riderPositionMapper.getFlashRiderNotificationList(
+                    order.getPickupLongitude(), order.getPickupLatitude(), vehicleType,
+                    order.getDeliveryType(), newTaskDistanceLimit(), RIDER_NOTIFICATION_LIMIT);
+            if (riders != null) {
+                riders.forEach(rider -> sendAvailableOrderToRider(rider, order));
+            }
+        });
+    }
+
     /** 骑手抢单成功后通知寄件用户;未解析到寄件用户时跳过。 */
     public void notifyAccepted(FlashDeliveryOrder order) {
         runAfterCommit(() -> {
@@ -141,4 +188,17 @@ public class FlashDeliveryNotificationService {
             }
         });
     }
+
+    private Integer newTaskDistanceLimit() {
+        try {
+            List<SysDictData> values = DictUtils.getDictCache("sys_qs_newtask_distance");
+            if (values != null && !values.isEmpty()) {
+                int limit = Integer.parseInt(values.get(0).getDictValue());
+                return limit > 0 ? limit : null;
+            }
+        } catch (Exception ignored) {
+            // 与骑手新任务列表保持一致:缺失或非法配置时不限制距离。
+        }
+        return null;
+    }
 }

+ 32 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/task/FlashDeliveryAvailabilityTask.java

@@ -0,0 +1,32 @@
+package com.ruoyi.app.flashdelivery.task;
+
+import com.ruoyi.app.flashdelivery.service.FlashDeliveryApplicationService;
+import com.ruoyi.system.domain.flash.FlashDeliveryOrder;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Component;
+
+import java.util.Date;
+
+/** 到达预约窗口时向骑手开放闪送订单并发送新单通知。 */
+@Component("flashDeliveryAvailabilityTask")
+public class FlashDeliveryAvailabilityTask {
+    private static final Logger log = LoggerFactory.getLogger(FlashDeliveryAvailabilityTask.class);
+    private final FlashDeliveryApplicationService service;
+
+    public FlashDeliveryAvailabilityTask(FlashDeliveryApplicationService service) {
+        this.service = service;
+    }
+
+    /** 由若依 Quartz 每秒调用;原子开放保证多实例和重复执行不会重复推送。 */
+    public void openScheduledOrders() {
+        Date now = new Date();
+        for (FlashDeliveryOrder order : service.scheduledAvailabilityCandidates(now, 200)) {
+            try {
+                service.openScheduledOrderForRiders(order.getId(), now);
+            } catch (RuntimeException exception) {
+                log.error("Flash delivery scheduled availability failed, orderId={}", order.getId(), exception);
+            }
+        }
+    }
+}

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

@@ -114,7 +114,7 @@ public class DeliveryOrderNotificationService {
             @Override
             public void run() {
                 PayPush.qsPushHandleLocal(new PayPush(), pushEventService, rider.getRiderId(), rider.getCid(),
-                        "no.message.push.message", "no.message.push.new.order", body, "", ddId);
+                        "no.message.push.message", "no.message.push.new.food.order", body, "", ddId);
             }
         });
     }

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

@@ -110,6 +110,8 @@ no.order.rider.accept.required=Vui lòng đợi tài xế nhận đơn trước
 no.message.push.delivery.personnel.qspsz.order=Shipper đang giao đến
 no.message.push.delivery.personnel.qsysd.order=Shipper đã giao xong.
 no.message.push.new.order=có lệnh mới
+no.message.push.new.food.order=Có đơn giao đồ ăn mới
+no.message.push.new.flash.order=Có đơn giao hàng nhanh mới
 no.message.push.recharge.success=nạp tiền thành công
 no.message.push.payment.success=thanh toán thành công
 no.message.push.payment.fail=thanh toán thất bại

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

@@ -108,6 +108,8 @@ no.message.push.rider.accepted.merchant=A rider has accepted the order. Please c
 no.message.push.order.cancelled=The order has been cancelled
 no.order.rider.accept.required=Please wait for a rider to accept the order before operating it
 no.message.push.new.order=New order
+no.message.push.new.food.order=New food delivery order
+no.message.push.new.flash.order=New flash delivery order
 no.message.push.recharge.success=Recharge successful
 no.message.push.payment.success=Payment successful
 no.message.push.payment.fail=Payment failed

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

@@ -108,6 +108,8 @@ no.message.push.rider.accepted.merchant=ไรเดอร์รับคำส
 no.message.push.order.cancelled=ยกเลิกคำสั่งซื้อแล้ว
 no.order.rider.accept.required=โปรดรอให้ไรเดอร์รับคำสั่งซื้อก่อนดำเนินการ
 no.message.push.new.order=มีคำสั่งซื้อใหม่
+no.message.push.new.food.order=มีออเดอร์ส่งอาหารใหม่
+no.message.push.new.flash.order=มีออเดอร์ส่งด่วนใหม่
 no.message.push.recharge.success=เติมเงินสำเร็จ
 no.message.push.payment.success=ชำระเงินสำเร็จ
 no.message.push.payment.fail=ชำระเงินไม่สำเร็จ

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

@@ -108,6 +108,8 @@ no.message.push.rider.accepted.merchant=Tài xế đã nhận đơn, vui lòng x
 no.message.push.order.cancelled=Đơn hàng đã bị hủy
 no.order.rider.accept.required=Vui lòng đợi tài xế nhận đơn trước khi thao tác
 no.message.push.new.order=có lệnh mới
+no.message.push.new.food.order=Có đơn giao đồ ăn mới
+no.message.push.new.flash.order=Có đơn giao hàng nhanh mới
 no.message.push.recharge.success=nạp tiền thành công
 no.message.push.payment.success=thanh toán thành công
 no.message.push.payment.fail=thanh toán thất bại

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

@@ -108,6 +108,8 @@ no.message.push.rider.accepted.merchant=骑手已接单,请及时确认并备
 no.message.push.order.cancelled=订单已取消
 no.order.rider.accept.required=请等待骑手接单后再操作订单
 no.message.push.new.order=有新订单
+no.message.push.new.food.order=有新的外卖订单
+no.message.push.new.flash.order=有新的闪送订单
 no.message.push.recharge.success=充值成功
 no.message.push.payment.success=支付成功
 no.message.push.payment.fail=支付失败

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

@@ -108,6 +108,8 @@ no.message.push.rider.accepted.merchant=騎手已接單,請及時確認並備
 no.message.push.order.cancelled=訂單已取消
 no.order.rider.accept.required=請等待騎手接單後再操作訂單
 no.message.push.new.order=有新訂單
+no.message.push.new.food.order=有新的外送訂單
+no.message.push.new.flash.order=有新的閃送訂單
 no.message.push.recharge.success=充值成功
 no.message.push.payment.success=支付成功
 no.message.push.payment.fail=支付失敗

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

@@ -339,6 +339,7 @@ class FlashDeliveryApplicationServiceTest {
         var parameters = query.getValue().getParamNameValuePairs().values();
         assertTrue(sql.contains("status"));
         assertTrue(sql.contains("rider_id is null"));
+        assertTrue(sql.contains("is_display"));
         assertTrue(sql.contains("vehicle_type"));
         assertTrue(sql.contains("scheduled_pickup_start_at"));
         assertTrue(sql.contains("scheduled_pickup_end_at"));
@@ -817,19 +818,8 @@ class FlashDeliveryApplicationServiceTest {
 
     @Test
     void excludedEventsNeverTriggerNotification() {
-        // FR-010:创建、加小费、完成、过期预约自动取消四类事件不产生任何推送。
+        // 加小费、完成、过期预约自动取消三类事件不产生任何推送。
         Fixture fixture = new Fixture();
-        when(fixture.pricingMapper.selectAtTime(anyString(), anyInt())).thenReturn(List.of(pricing()));
-        when(fixture.routeService.calculate(any(), any(), anyInt())).thenReturn(new RouteDistance(3200, 600, "ROUTE"));
-        when(fixture.userMapper.selectOrdinaryUserIdsByNormalizedPhone("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());
-
         editableOrder(fixture);
         when(fixture.orderMapper.updateWaitingTip(any(), eq(2))).thenReturn(1);
         fixture.service.addTip(7L, 10L, tipRequest(20L, 2));
@@ -844,6 +834,56 @@ class FlashDeliveryApplicationServiceTest {
         verifyNoInteractions(fixture.notificationService);
     }
 
+    @Test
+    void immediateOrderIsVisibleAndNotifiesAvailableRiders() {
+        Fixture fixture = new Fixture();
+        prepareCreate(fixture);
+        FlashDeliveryCreateRequest request = createRequest();
+        request.setDeliveryMode("NOW");
+        request.setScheduledPickupStartAt(null);
+        request.setScheduledPickupEndAt(null);
+
+        fixture.service.create(7L, request);
+
+        ArgumentCaptor<FlashDeliveryOrder> saved = ArgumentCaptor.forClass(FlashDeliveryOrder.class);
+        verify(fixture.orderMapper).insert(saved.capture());
+        assertTrue(saved.getValue().getIsDisplay());
+        verify(fixture.notificationService).notifyAvailable(saved.getValue());
+    }
+
+    @Test
+    void scheduledOrderStartsHiddenAndDoesNotNotifyBeforeWindow() {
+        Fixture fixture = new Fixture();
+        prepareCreate(fixture);
+
+        fixture.service.create(7L, createRequest());
+
+        ArgumentCaptor<FlashDeliveryOrder> saved = ArgumentCaptor.forClass(FlashDeliveryOrder.class);
+        verify(fixture.orderMapper).insert(saved.capture());
+        assertFalse(saved.getValue().getIsDisplay());
+        verify(fixture.notificationService, never()).notifyAvailable(any());
+    }
+
+    @Test
+    void scheduledOrderBecomesVisibleAndNotifiesOnlyAfterAtomicOpenSucceeds() {
+        Fixture fixture = new Fixture();
+        FlashDeliveryOrder scheduled = order(10L, "WAITING_ACCEPTANCE");
+        scheduled.setDeliveryMode("SCHEDULED");
+        scheduled.setIsDisplay(false);
+        Date now = new Date();
+        when(fixture.orderMapper.selectById(10L)).thenReturn(scheduled);
+        when(fixture.orderMapper.openScheduledForRiders(10L, now)).thenReturn(1);
+
+        assertTrue(fixture.service.openScheduledOrderForRiders(10L, now));
+
+        assertTrue(scheduled.getIsDisplay());
+        verify(fixture.notificationService).notifyAvailable(scheduled);
+
+        when(fixture.orderMapper.openScheduledForRiders(10L, now)).thenReturn(0);
+        assertFalse(fixture.service.openScheduledOrderForRiders(10L, now));
+        verify(fixture.notificationService).notifyAvailable(scheduled);
+    }
+
     private FlashDeliveryReasonRequest reason(String text) {
         FlashDeliveryReasonRequest request = new FlashDeliveryReasonRequest();
         request.setReason(text);
@@ -1242,6 +1282,18 @@ class FlashDeliveryApplicationServiceTest {
         return request;
     }
 
+    private void prepareCreate(Fixture fixture) {
+        when(fixture.pricingMapper.selectAtTime(anyString(), anyInt())).thenReturn(List.of(pricing()));
+        when(fixture.routeService.calculate(any(), any(), anyInt()))
+                .thenReturn(new RouteDistance(3200, 600, "ROUTE"));
+        when(fixture.userMapper.selectOrdinaryUserIdsByNormalizedPhone("0922222222")).thenReturn(List.of());
+        doAnswer(invocation -> {
+            FlashDeliveryOrder order = invocation.getArgument(0);
+            order.setId(99L);
+            return 1;
+        }).when(fixture.orderMapper).insert(any(FlashDeliveryOrder.class));
+    }
+
     private FlashDeliveryAddressRequest address(String name, String phone, String lat, String lon) {
         FlashDeliveryAddressRequest address = new FlashDeliveryAddressRequest();
         address.setName(name);
@@ -1320,6 +1372,7 @@ class FlashDeliveryApplicationServiceTest {
         order.setServiceType("HELP_SEND");
         order.setDeliveryType("NORMAL");
         order.setStatus(status);
+        order.setIsDisplay(true);
         order.setPackageType("DOCUMENT");
         order.setQuantity(1);
         order.setWeightRange("UP_TO_5_KG");

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

@@ -32,6 +32,7 @@ class FlashDeliveryI18nContractTest {
             "flash.delivery.edit.not.allowed", "flash.delivery.order.version.invalid",
             "flash.delivery.tip.additional.invalid",
             "flash.delivery.rider.exclusive.conflict", "flash.delivery.schedule.not.open",
+            "no.message.push.new.food.order", "no.message.push.new.flash.order",
             "address.access.denied", "address.data.invalid");
 
     @Test

+ 39 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryNotificationServiceTest.java

@@ -2,9 +2,17 @@ package com.ruoyi.app.flashdelivery.service;
 
 import com.ruoyi.app.utils.event.PushEventService;
 import com.ruoyi.system.domain.InfoUser;
+import com.ruoyi.system.domain.RiderPosition;
+import com.ruoyi.common.core.domain.entity.SysDictData;
 import com.ruoyi.system.domain.flash.FlashDeliveryOrder;
 import com.ruoyi.system.mapper.InfoUserMapper;
+import com.ruoyi.system.mapper.RiderPositionMapper;
+import com.ruoyi.common.utils.DictUtils;
 import org.junit.jupiter.api.Test;
+import org.mockito.MockedStatic;
+
+import java.math.BigDecimal;
+import java.util.List;
 
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.anyString;
@@ -19,6 +27,37 @@ import static org.mockito.Mockito.when;
 
 class FlashDeliveryNotificationServiceTest {
 
+    @Test
+    void availableOrderRoutesNewFlashOrderToNearbyEligibleRiders() {
+        InfoUserMapper userMapper = mock(InfoUserMapper.class);
+        RiderPositionMapper positions = mock(RiderPositionMapper.class);
+        FlashDeliveryNotificationService service = spy(new FlashDeliveryNotificationService(
+                userMapper, mock(PushEventService.class), positions));
+        RiderPosition rider = new RiderPosition();
+        rider.setRiderId(9L);
+        rider.setCid("rider-cid");
+        FlashDeliveryOrder order = order();
+        order.setStatus("WAITING_ACCEPTANCE");
+        order.setIsDisplay(true);
+        order.setVehicleType(1);
+        order.setDeliveryType("NORMAL");
+        order.setPickupLongitude(new BigDecimal("121.5000"));
+        order.setPickupLatitude(new BigDecimal("25.0300"));
+        SysDictData distance = new SysDictData();
+        distance.setDictValue("5");
+        when(positions.getFlashRiderNotificationList(order.getPickupLongitude(), order.getPickupLatitude(),
+                1, "NORMAL", 5, 20)).thenReturn(List.of(rider));
+        doNothing().when(service).sendAvailableOrderToRider(rider, order);
+
+        try (MockedStatic<DictUtils> dictionary = org.mockito.Mockito.mockStatic(DictUtils.class)) {
+            dictionary.when(() -> DictUtils.getDictCache("sys_qs_newtask_distance"))
+                    .thenReturn(List.of(distance));
+            service.notifyAvailable(order);
+        }
+
+        verify(service).sendAvailableOrderToRider(rider, order);
+    }
+
     @Test
     void acceptedNotificationRoutesToSender() {
         InfoUserMapper userMapper = mock(InfoUserMapper.class);

+ 63 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/task/FlashDeliveryAvailabilityTaskTest.java

@@ -0,0 +1,63 @@
+package com.ruoyi.app.flashdelivery.task;
+
+import com.ruoyi.app.flashdelivery.service.FlashDeliveryApplicationService;
+import com.ruoyi.system.domain.flash.FlashDeliveryOrder;
+import org.junit.jupiter.api.Test;
+import org.springframework.stereotype.Component;
+
+import java.util.Date;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+class FlashDeliveryAvailabilityTaskTest {
+
+    @Test
+    void exposesDedicatedQuartzBeanAndOpensEveryDueScheduledOrder() {
+        Component component = FlashDeliveryAvailabilityTask.class.getAnnotation(Component.class);
+        assertEquals("flashDeliveryAvailabilityTask", component.value());
+        FlashDeliveryApplicationService service = mock(FlashDeliveryApplicationService.class);
+        FlashDeliveryOrder first = new FlashDeliveryOrder();
+        first.setId(1L);
+        FlashDeliveryOrder second = new FlashDeliveryOrder();
+        second.setId(2L);
+        when(service.scheduledAvailabilityCandidates(any(Date.class), anyInt()))
+                .thenReturn(List.of(first, second));
+
+        new FlashDeliveryAvailabilityTask(service).openScheduledOrders();
+
+        verify(service).openScheduledOrderForRiders(org.mockito.ArgumentMatchers.eq(1L), any(Date.class));
+        verify(service).openScheduledOrderForRiders(org.mockito.ArgumentMatchers.eq(2L), any(Date.class));
+    }
+
+    @Test
+    void oneFailedScheduledOrderDoesNotStopRemainingOrders() {
+        FlashDeliveryApplicationService service = mock(FlashDeliveryApplicationService.class);
+        FlashDeliveryOrder first = new FlashDeliveryOrder();
+        first.setId(1L);
+        FlashDeliveryOrder second = new FlashDeliveryOrder();
+        second.setId(2L);
+        when(service.scheduledAvailabilityCandidates(any(Date.class), anyInt()))
+                .thenReturn(List.of(first, second));
+        doThrow(new IllegalStateException("one bad row"))
+                .when(service).openScheduledOrderForRiders(org.mockito.ArgumentMatchers.eq(1L), any(Date.class));
+
+        ch.qos.logback.classic.Logger logger = (ch.qos.logback.classic.Logger)
+                org.slf4j.LoggerFactory.getLogger(FlashDeliveryAvailabilityTask.class);
+        ch.qos.logback.classic.Level previous = logger.getLevel();
+        logger.setLevel(ch.qos.logback.classic.Level.OFF);
+        try {
+            new FlashDeliveryAvailabilityTask(service).openScheduledOrders();
+        } finally {
+            logger.setLevel(previous);
+        }
+
+        verify(service).openScheduledOrderForRiders(org.mockito.ArgumentMatchers.eq(2L), any(Date.class));
+    }
+}

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

@@ -33,6 +33,8 @@ public class FlashDeliveryOrder {
     private Integer vehicleType;
     /** 订单状态,取值见 FlashDeliveryStatus。 */
     private String status;
+    /** 是否已向骑手开放展示;立即单创建即开放,预约单到达时间窗后开放。 */
+    private Boolean isDisplay;
     /** 用户申报的包裹类别和重量范围。 */
     private String packageType;
     private Integer quantity;

+ 31 - 0
ruoyi-system/src/main/java/com/ruoyi/system/mapper/RiderPositionMapper.java

@@ -70,4 +70,35 @@ public interface RiderPositionMapper  extends BaseMapper<RiderPosition>
             + "and rp.rider_id not in (select user_id from info_user where offline='1' and user_type='2') "
             + "order by juli asc limit #{num}")
     public List<RiderPosition> getAcceptRiderList(@Param("longitude") BigDecimal longitude, @Param("latitude")BigDecimal latitude,@Param("num")int num);
+
+    /**
+     * 查询可接指定闪送订单的附近骑手:在线、闪送资质、车型、距离与独占规则必须同时满足。
+     */
+    @Select({"<script>",
+            "select rp.* from rider_position rp join info_user iu on iu.user_id=rp.rider_id ",
+            "where iu.user_type='2' and (iu.offline is null or iu.offline&lt;&gt;'1') ",
+            "and (iu.delivery_type is null or trim(iu.delivery_type)='' ",
+            "or find_in_set('FLASH', replace(upper(iu.delivery_type), ' ', '')) &gt; 0) ",
+            "and (case when iu.vehicle_type='2' then 2 else 1 end)=#{vehicleType} ",
+            "<if test='distanceLimitKm != null'>",
+            "and ST_Distance_Sphere(point(rp.longitude,rp.latitude), point(#{longitude},#{latitude})) ",
+            "&lt;= #{distanceLimitKm} * 1000 ",
+            "</if>",
+            "and ((#{deliveryType}='URGENT' ",
+            "and not exists (select 1 from pos_order po where po.qs_id=rp.rider_id ",
+            "and po.type=0 and po.state in (0,1,2) and po.delivery_status in (1,2) and po.after_sale_status=0) ",
+            "and not exists (select 1 from flash_delivery_order fdo where fdo.rider_id=rp.rider_id ",
+            "and fdo.status in ('ACCEPTED','PICKED_UP'))) ",
+            "or (#{deliveryType}&lt;&gt;'URGENT' and not exists ",
+            "(select 1 from flash_delivery_order fdo where fdo.rider_id=rp.rider_id ",
+            "and fdo.status in ('ACCEPTED','PICKED_UP') and fdo.delivery_type='URGENT'))) ",
+            "order by ST_Distance_Sphere(point(rp.longitude,rp.latitude), point(#{longitude},#{latitude})) asc ",
+            "limit #{num}",
+            "</script>"})
+    List<RiderPosition> getFlashRiderNotificationList(@Param("longitude") BigDecimal longitude,
+                                                      @Param("latitude") BigDecimal latitude,
+                                                      @Param("vehicleType") int vehicleType,
+                                                      @Param("deliveryType") String deliveryType,
+                                                      @Param("distanceLimitKm") Integer distanceLimitKm,
+                                                      @Param("num") int num);
 }

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

@@ -58,4 +58,11 @@ public interface FlashDeliveryOrderMapper extends BaseMapper<FlashDeliveryOrder>
 
     /** 原子取消过期预约单;抢单或人工取消已先完成时返回 0,避免覆盖正常状态。 */
     int cancelExpiredScheduled(@Param("id") Long id, @Param("now") Date now);
+
+    /** 查询已进入预约窗口但尚未向骑手开放的订单。 */
+    List<FlashDeliveryOrder> selectScheduledAvailabilityCandidates(@Param("now") Date now,
+                                                                   @Param("limit") int limit);
+
+    /** 原子开放预约单;并发任务、取消或抢单已先完成时返回 0。 */
+    int openScheduledForRiders(@Param("id") Long id, @Param("now") Date now);
 }

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

@@ -12,6 +12,7 @@
         <result property="deliveryType" column="delivery_type"/>
         <result property="vehicleType" column="vehicle_type"/>
         <result property="status" column="status"/>
+        <result property="isDisplay" column="is_display"/>
         <result property="packageType" column="package_type"/>
         <result property="quantity" column="quantity"/>
         <result property="weightRange" column="weight_range"/>
@@ -81,7 +82,7 @@
     <update id="accept">
         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
+        where id=#{id} and status = 'WAITING_ACCEPTANCE' and rider_id is null and is_display = true
           and (delivery_mode = 'NOW' or (scheduled_pickup_start_at &lt;= #{now}
                and scheduled_pickup_end_at &gt; #{now}))
     </update>
@@ -178,4 +179,22 @@
         where id=#{id} and status='WAITING_ACCEPTANCE' and rider_id is null and delivery_mode='SCHEDULED'
           and scheduled_pickup_end_at &lt;= #{now}
     </update>
+
+    <!-- 预约单只有进入取件窗口后才允许向骑手开放;is_display=false 同时充当未领取标记。 -->
+    <select id="selectScheduledAvailabilityCandidates" resultMap="FlashDeliveryOrderResult">
+        select * from flash_delivery_order
+        where status='WAITING_ACCEPTANCE' and rider_id is null and delivery_mode='SCHEDULED'
+          and is_display = false and scheduled_pickup_start_at &lt;= #{now}
+          and scheduled_pickup_end_at &gt; #{now}
+        order by scheduled_pickup_start_at asc limit #{limit}
+    </select>
+
+    <!-- false -> true 的条件更新保证多实例任务只由一个实例开放并推送。 -->
+    <update id="openScheduledForRiders">
+        update flash_delivery_order
+        set is_display = true, update_time = #{now}
+        where id=#{id} and status='WAITING_ACCEPTANCE' and rider_id is null and delivery_mode='SCHEDULED'
+          and is_display = false and scheduled_pickup_start_at &lt;= #{now}
+          and scheduled_pickup_end_at &gt; #{now}
+    </update>
 </mapper>

+ 61 - 0
ruoyi-system/src/test/java/com/ruoyi/system/mapper/RiderPositionMapperContractTest.java

@@ -0,0 +1,61 @@
+package com.ruoyi.system.mapper;
+
+import org.apache.ibatis.annotations.Select;
+import org.apache.ibatis.mapping.BoundSql;
+import org.apache.ibatis.session.Configuration;
+import org.junit.jupiter.api.Test;
+
+import java.lang.reflect.Method;
+import java.math.BigDecimal;
+import java.util.Arrays;
+import java.util.Locale;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class RiderPositionMapperContractTest {
+
+    @Test
+    void flashNotificationCandidatesMatchEligibilityAndExclusivityRules() throws Exception {
+        Method method = RiderPositionMapper.class.getMethod("getFlashRiderNotificationList",
+                BigDecimal.class, BigDecimal.class, int.class, String.class, Integer.class, int.class);
+        String sql = Arrays.stream(method.getAnnotation(Select.class).value())
+                .reduce("", (left, right) -> left + " " + right)
+                .replaceAll("\\s+", " ")
+                .toLowerCase(Locale.ROOT);
+
+        assertTrue(sql.contains("join info_user iu on iu.user_id=rp.rider_id"));
+        assertTrue(sql.contains("iu.user_type='2'"));
+        assertTrue(sql.contains("find_in_set('flash'"));
+        assertTrue(sql.contains("#{vehicletype}"));
+        assertTrue(sql.contains("st_distance_sphere"));
+        assertTrue(sql.contains("#{distancelimitkm} * 1000"));
+        assertTrue(sql.contains("from pos_order po"));
+        assertTrue(sql.contains("from flash_delivery_order fdo"));
+        assertTrue(sql.contains("fdo.delivery_type='urgent'"));
+        assertTrue(sql.contains("limit #{num}"));
+    }
+
+    @Test
+    void flashNotificationQueryParsesAsDynamicMyBatisSql() {
+        Configuration configuration = new Configuration();
+        configuration.addMapper(RiderPositionMapper.class);
+        Map<String, Object> parameters = Map.of(
+                "longitude", BigDecimal.valueOf(121.5),
+                "latitude", BigDecimal.valueOf(25.0),
+                "vehicleType", 1,
+                "deliveryType", "NORMAL",
+                "distanceLimitKm", 5,
+                "num", 20);
+
+        BoundSql boundSql = configuration.getMappedStatement(
+                        RiderPositionMapper.class.getName() + ".getFlashRiderNotificationList")
+                .getBoundSql(parameters);
+        String sql = boundSql.getSql().replaceAll("\\s+", " ").toLowerCase(Locale.ROOT);
+
+        assertTrue(sql.contains("iu.offline"));
+        assertTrue(sql.contains("st_distance_sphere"));
+        assertTrue(sql.contains("<= ? * 1000"));
+        assertTrue(sql.contains("order by"));
+    }
+}

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

@@ -108,6 +108,7 @@ class FlashDeliveryMapperXmlTest {
                 .collect(Collectors.toSet());
         assertTrue(mappedProperties.containsAll(Set.of(
                 "receiverUserId",
+                "isDisplay",
                 "deliveryType", "quantity", "weightRange", "specification",
                 "pricingId", "pricingVersion", "pricingStartTime", "pricingEndTime",
                 "startingDistance", "startingFare", "distance", "freight", "distanceFee",
@@ -120,6 +121,10 @@ class FlashDeliveryMapperXmlTest {
         assertTrue(xml.contains("scheduled_pickup_end_at &gt; #{now}"));
         assertTrue(xml.contains("selectExpiredScheduled"));
         assertTrue(xml.contains("cancelExpiredScheduled"));
+        assertTrue(xml.contains("selectScheduledAvailabilityCandidates"));
+        assertTrue(xml.contains("openScheduledForRiders"));
+        assertTrue(xml.contains("is_display = false"));
+        assertTrue(xml.contains("is_display = true"));
         assertTrue(xml.contains("scheduled_pickup_end_at &lt;= #{now}"));
         assertTrue(xml.contains("cancel_reason='SCHEDULE_EXPIRED'"));
         assertTrue(xml.contains("package_type"));

+ 1 - 1
specs/028-flash-delivery-push/checklists/requirements.md

@@ -33,6 +33,6 @@
 
 - 校验说明:
   - 实现细节检查:spec 仅引用业务状态名(WAITING_ACCEPTANCE 等,为产品已有订单状态术语)与"现有推送通道/消息中心机制"的存在性约束,未规定语言、框架、接口形态;具体通道选型留给 plan 阶段。
-  - 范围边界:FR-010 明确列出四类不推送事件,双向(要什么/不要什么)都已成文
+  - 范围边界:2026-09-18 变更后 FR-010 明确列出预约单创建等不推送事件,FR-012~FR-017 定义立即单通知与预约单开放通知
   - 无 [NEEDS CLARIFICATION]:用户输入已明确事件范围与排除项;其余未决点(平台取消是否通知用户、自动完成是否推送)均按用户口径落入 FR-005/FR-010 与 Assumptions,采用合理默认。
   - 可测性:FR-001~FR-011 均可映射到 User Story 的 Given/When/Then 场景逐一验证;SC-001~SC-004 为可度量指标。

+ 6 - 4
specs/028-flash-delivery-push/contracts/push.md

@@ -9,8 +9,8 @@
 ```json
 {
   "ddId": "FD7A3F9C2E1B4D5A6F7C8D9E",
-  "state": "ACCEPTED",
-  "type": -1,
+  "state": "WAITING_ACCEPTANCE",
+  "type": 0,
   "pushType": 3
 }
 ```
@@ -18,8 +18,8 @@
 | 字段 | 取值 | 说明 |
 |------|------|------|
 | `ddId` | 闪送 `orderNo`(`FD` + 24 位大写十六进制) | 订单标识;沿用历史字段名以兼容客户端既有解析 |
-| `state` | `ACCEPTED` / `PICKED_UP` / `DELIVERED` / `CANCELLED` | 推送对应的目标状态(闪送状态枚举字符串,非外卖数字状态) |
-| `type` | `-1` | -1=非语音提醒(0 仅用于外卖骑手新单语音) |
+| `state` | `WAITING_ACCEPTANCE` / `ACCEPTED` / `PICKED_UP` / `DELIVERED` / `CANCELLED` | 推送对应的目标状态(闪送状态枚举字符串,非外卖数字状态) |
+| `type` | `0`(新单)/ `-1`(状态通知) | 0 表示骑手新单提醒,-1 表示非语音状态提醒 |
 | `pushType` | `3` | **新增取值**:3=闪送订单(既有:1=外卖、2=充值)。客户端按此路由到闪送订单详情页 |
 
 ## 通知正文
@@ -30,10 +30,12 @@
 | 骑手取件 | 用户 App | 同上 | `骑手配送中,NO:{orderNo}` |
 | 骑手送达 | 用户 App | 同上 | `骑手已送达,NO:{orderNo}` |
 | 订单取消 | 骑手 App | 同上 | `订单已取消,NO:{orderNo}` |
+| 可抢闪送新单 | 骑手 App | 同上 | `有新的闪送订单,NO:{orderNo}`(`no.message.push.new.flash.order`) |
 
 - 文案按接收方 locale 渲染(vi/zh/tw/en/th,复用既有 6 份 i18n 资源)。
 - 取消通知只发给接单骑手;寄件用户不发(spec FR-004/FR-005)。
 - 每条推送同时经 `PushEventService` 写入 `push_message`(消息中心可回看)。
+- 外卖骑手新单改用 `no.message.push.new.food.order`(“有新的外卖订单”),避免与闪送文案混淆;两者 payload 的 `pushType` 仍分别按各自业务类型路由。
 
 ## 客户端适配注意(本仓库外)
 

+ 11 - 2
specs/028-flash-delivery-push/data-model.md

@@ -2,7 +2,7 @@
 
 **Date**: 2026-09-17 | **Spec**: [spec.md](spec.md)
 
-本特性**不新增任何表、字段或实体**。数据面完全复用既有结构:
+本特性不新增表;2026-09-18 的新单通知追加 `flash_delivery_order.is_display`,作为骑手可见性与预约开放通知资格的同一门闩。
 
 ## 消费的既有实体
 
@@ -16,6 +16,9 @@
 | `userId` | Long | 寄件用户 → ACCEPTED/PICKED_UP/DELIVERED 推送对象 |
 | `riderId` | Long | 接单骑手 → CANCELLED 推送对象;null 表示未接单,跳过推送 |
 | `status` | String | 仅用于触发点判定(由状态流转代码自身保证,推送不重复判状态) |
+| `isDisplay` | Boolean | `is_display`:立即单为 true;预约单创建为 false,进入有效窗口后以条件更新原子切换为 true |
+| `deliveryMode` / `scheduledPickupStartAt` / `scheduledPickupEndAt` | String / Date | 预约单开放时间窗判定 |
+| `pickupLongitude` / `pickupLatitude` / `vehicleType` / `deliveryType` | 坐标 / Integer / String | 新单候选骑手的距离、车型及普通/加急互斥筛选 |
 
 ### InfoUser(用户,既有)
 
@@ -37,8 +40,14 @@
 | PICKED_UP → DELIVERED | `deliver` → `transitionWithProof` | `notifyDelivered(order)` | userId | ...qsysd.order |
 | → CANCELLED(用户取消) | `userCancel` | `notifyCancelled(order)`(riderId 非空时) | riderId | order.cancelled |
 | → CANCELLED(平台取消) | `adminCancel` | `notifyCancelled(order)`(riderId 非空时) | riderId | order.cancelled |
+| 立即单创建(初始 WAITING_ACCEPTANCE、isDisplay=true) | `create` 提交后 | `notifyAvailable(order)` | 符合条件的在线骑手 | new.flash.order |
+| 预约单进入窗口(isDisplay false → true) | `FlashDeliveryAvailabilityTask` | `notifyAvailable(order)`(仅条件更新成功时) | 符合条件的在线骑手 | new.flash.order |
 
-**明确不触发**(FR-010):订单创建、加小费(`tipAdd`)、DELIVERED → COMPLETED(`complete` / 自动完成 task)、WAITING_ACCEPTANCE 过期自动取消(`cancelExpiredScheduledOrder`)。
+**明确不触发**(FR-010):预约单创建、加小费(`tipAdd`)、DELIVERED → COMPLETED(`complete` / 自动完成 task)、WAITING_ACCEPTANCE 过期自动取消(`cancelExpiredScheduledOrder`)。
+
+## 原子开放约束
+
+预约任务只选择 WAITING_ACCEPTANCE、无人接单、SCHEDULED、`is_display=false` 且当前时间位于半开区间 `[start, end)` 的订单。更新语句重复全部条件,并执行 `is_display=true`;影响行数为 1 才获得推送资格。这样列表可见性和通知资格在同一次数据库写入生效,多实例竞争也不会重复推送。
 
 ## 传递给推送通道的 payload 契约
 

+ 17 - 11
specs/028-flash-delivery-push/plan.md

@@ -6,7 +6,7 @@
 
 ## Summary
 
-在闪送订单五个状态变更点(骑手抢单、取件、送达、用户取消、平台取消)接入既有推送通道:新建 `FlashDeliveryNotificationService`(镜像 `DeliveryOrderNotificationService` 的 afterCommit + AsyncManager + PayPush 模式),由 `FlashDeliveryApplicationService` 在条件更新成功后调用。履约事件推寄件用户、取消事件推接单骑手;复用现有 i18n 文案 key 与 `OrderPushBodyDto` payload(pushType=3)。零新表、零新接口、零新翻译(详见 [research.md](research.md))。
+在闪送订单状态变更点接入既有推送通道,并追加新单通知:立即单以 `is_display=true` 创建后通知骑手;预约单以 `is_display=false` 创建,由独立 `FlashDeliveryAvailabilityTask` 每秒检查时间窗,以条件更新原子开放展示并取得唯一通知资格。履约事件推寄件用户、取消事件推接单骑手;骑手候选按在线状态、FLASH 配送类型、车型、距离和接单互斥规则筛选。外卖和闪送分别使用专属新单 i18n key,payload 继续复用 `OrderPushBodyDto`(pushType=3)。
 
 ## Technical Context
 
@@ -14,7 +14,7 @@
 
 **Primary Dependencies**: Spring 事务同步(`TransactionSynchronizationManager`)、`AsyncManager`、`PayPush`(uni 云函数推送)、`PushEventService`(push_message 入库)、fastjson(payload 序列化)
 
-**Storage**: MySQL(只读 `flash_delivery_order`、`info_user`;只写既有 `push_message`,经由 PushEventService
+**Storage**: MySQL(`flash_delivery_order` 新增 `is_display`;原子开放预约单;推送消息经 PushEventService 写入既有 `push_message`
 
 **Testing**: JUnit 5 + Mockito(`spy`+`doNothing` 打断网络发送,验证路由决策;仓库先例 `DeliveryOrderNotificationServiceTest`)
 
@@ -26,7 +26,7 @@
 
 **Constraints**: 推送失败不得影响状态流转(FR-009);仅事务提交后触发(FR-011);不新增 Android 通道
 
-**Scale/Scope**: 1 个新服务类 + 1 个应用服务的 5 个方法加钩子 + 2 个测试类
+**Scale/Scope**: 通知服务和应用服务增量 + 1 个独立任务类 + Entity/Mapper/XML + 后端 i18n + SQL/spec 测试
 
 ## Constitution Check
 
@@ -38,10 +38,10 @@
 |------|------|
 | Controller 参数规范 | 不涉及(无新 Controller/接口改动) |
 | 模块边界:集成代码放 ruoyi-admin | ✅ 通知服务放 `ruoyi-admin` 的 `com.ruoyi.app.flashdelivery.service`,不进 ruoyi-system |
-| 数据库变更须写 updatesql/sql.md | ✅ 本特性零数据库变更 |
-| 全栈字段清单 | ✅ 无新字段 |
+| 数据库变更须写 updatesql/sql.md | ✅ `is_display`、索引和 `sys_job` 仅记录于 `updatesql/sql.md`,不直接执行 |
+| 全栈字段清单 | ✅ 更新 Entity、Mapper XML、查询/条件更新及 SQL;该字段不对前端表单开放 |
 | Java 注释安全(无 `*/` 序列) | ✅ 实现时遵守 |
-| 前端 i18n 规范 | 不涉及(复用后端既有文案 key,无前端改动) |
+| 前端 i18n 规范 | 不涉及前端;后端 6 份资源新增外卖/闪送专属新单 key |
 | CRLF 保留、不格式化整文件 | ✅ 实现时遵守 |
 
 无违规,无需 Complexity Tracking 条目。
@@ -65,9 +65,11 @@ specs/028-flash-delivery-push/
 
 ```text
 ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/
-└── service/
-    ├── FlashDeliveryApplicationService.java   # 修改:构造器 + 5 个方法的成功路径加通知钩子
-    └── FlashDeliveryNotificationService.java  # 新增:推送路由(afterCommit + AsyncManager + PayPush)
+├── service/
+│   ├── FlashDeliveryApplicationService.java   # 修改:状态通知、新单可见性及原子开放
+│   └── FlashDeliveryNotificationService.java  # 修改:状态通知与闪送新单骑手路由
+└── task/
+    └── FlashDeliveryAvailabilityTask.java     # 新增:每秒开放进入预约窗口的订单
 
 ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/
 └── service/
@@ -75,7 +77,7 @@ ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/
     └── FlashDeliveryNotificationServiceTest.java      # 新增:接收人判定与跳过逻辑
 ```
 
-**Structure Decision**: 完全落在闪送模块既有目录结构内,不新增包、不动 `ruoyi-system`、不动 Controller 层。
+**Structure Decision**: 集成和任务仍在 `ruoyi-admin`;订单字段、候选骑手查询和原子更新落在 `ruoyi-system` 的既有 Entity/Mapper/XML;不动 Controller 层。
 
 ## 关键设计点(供 tasks 生成引用)
 
@@ -86,7 +88,11 @@ ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/
    - `userCancel`/`adminCancel`:`writeLog(...)` 之后,`notifyCancelled` 内部判 `riderId` 判空。
 3. **构造器**:`FlashDeliveryApplicationService` 追加第 10 个依赖 `FlashDeliveryNotificationService`;同步更新既有测试的构造调用。
 4. **发送实现**:`runAfterCommit` → `userMapper.selectById` 取 cid → `AsyncManager.me().execute` → `PayPush.userPushHandleLocal / qsPushHandleLocal`(title=`no.message.push.message`,content key 见 research.md D2,body=`OrderPushBodyDto.getJson(orderNo, status, -1, 3)`)。
-5. **不触发清单**(对应 FR-010):`create`、`tipAdd`、`complete`、`FlashDeliveryAutoCompleteTask`、`cancelExpiredScheduledOrder` 一律不调用通知服务。
+5. **新单开放门闩**:立即单创建时 `isDisplay=true` 并在提交后通知;预约单创建时为 false,骑手列表、详情和抢单均以该字段为前置条件。独立任务调用 Mapper 条件更新,在有效时间窗内将 `false -> true`;只有影响 1 行的执行发送通知。
+6. **独立调度**:`FlashDeliveryAvailabilityTask.openScheduledOrders()` 通过 `sys_job` 使用 `0/1 * * * * ?` 每秒执行,`concurrent='1'` 禁止同一任务并发;数据库条件更新继续提供跨实例幂等保护。
+7. **候选骑手**:在线、用户类型为骑手、支持 FLASH、车型匹配、配置半径内;加急单排除所有进行中的外卖与闪送,普通单排除进行中的加急闪送;按距离取最近 20 人。
+8. **文案区分**:外卖使用 `no.message.push.new.food.order`,闪送使用 `no.message.push.new.flash.order`。
+9. **不触发清单**(对应 FR-010):`tipAdd`、`complete`、`FlashDeliveryAutoCompleteTask.completeDeliveredOrders()`、`cancelExpiredScheduledOrder`、预约单创建不调用新单通知。
 
 ## Complexity Tracking
 

+ 8 - 3
specs/028-flash-delivery-push/quickstart.md

@@ -5,7 +5,8 @@
 ## 前置条件
 
 - JDK 21 构建(本机用 `C:\Users\qmj\.jdks\graalvm-jdk-21.0.7`,仅命令内临时设置 `JAVA_HOME`/`PATH`,不改全局)。
-- 无需数据库变更、无需新增 i18n key;服务端部署后即生效。
+- 先由开发者手动执行 `updatesql/sql.md` 中 2026-09-18 段,新增 `is_display`、索引和 `flashDeliveryAvailabilityTask.openScheduledOrders()` 每秒调度;不要由应用自动执行迁移。
+- 6 份后端 i18n 资源已包含外卖/闪送专属新单 key。
 
 ## 自动验证(单测)
 
@@ -21,11 +22,13 @@ mvn -pl ruoyi-admin -am test -Dtest='FlashDeliveryNotificationServiceTest,FlashD
 - 三类履约事件(抢单/取件/送达)成功时调用对应用户推送;操作失败(条件更新 0 行)不推送。
 - 用户/平台取消且 `riderId` 非空时调用骑手推送;`riderId` 为空跳过。
 - 排除事件(complete、cancelExpiredScheduledOrder、加小费)零推送调用。
+- 立即单创建后可见并触发闪送新单通知;预约单创建时隐藏且不推送,时间窗内原子开放后只通知一次。
+- 新单候选骑手按 FLASH 资质、在线状态、车型、距离与接单互斥规则筛选。
 
 ## 手动端到端验证(联调环境)
 
 1. 用户 App(寄件人)与骑手 App 各登录一个测试账号;骑手账号具备闪送(FLASH)配送资质。
-2. 寄件人创建立即单(货到付款或沙箱支付),记下 `orderNo`。
+2. 寄件人创建立即单(货到付款或沙箱支付),记下 `orderNo`;符合条件的骑手应立即看到订单并收到“有新的闪送订单”
 3. 骑手在任务列表抢单 → 寄件人应收到「骑手已接单,NO:{orderNo}」推送,消息中心出现对应记录。
 4. 骑手提交取件凭证 → 寄件人收到「骑手配送中,NO:{orderNo}」。
 5. 骑手提交送达凭证(含 PIN 时输入 PIN)→ 寄件人收到「骑手已送达,NO:{orderNo}」。
@@ -34,8 +37,10 @@ mvn -pl ruoyi-admin -am test -Dtest='FlashDeliveryNotificationServiceTest,FlashD
 8. 反例:待接单状态直接平台取消(无人接单)→ 无任何骑手推送。
 9. 反例:等待超过自动完成时限(或平台手动完成)→ 用户无「已完成」推送(FR-010)。
 10. 切换寄件人 App 语言(vi/en/tw/zh)重复第 3 步 → 推送文案语言随之变化。
+11. 创建未来时间的预约单 → 创建后骑手列表不可见、无新单推送;到达 `scheduledPickupStartAt` 后约 1 秒内订单变为可见并收到一次“有新的闪送订单”,持续观察不得重复推送。
+12. 发布一笔外卖单 → 骑手推送应为“有新的外卖订单”,与第 2/11 步的闪送文案可区分。
 
 ## 判定
 
-- 第 3~7 步全部按期收到、第 8~9 步确实无推送、第 10 步语言正确 → 特性验证通过(对应 spec SC-001~SC-003)。
+- 第 2~7、11~12 步全部按期收到且展示同步,第 8~9 步确实无推送、第 10 步语言正确 → 特性验证通过(对应 spec SC-001~SC-005)。
 - 推送期间人为停掉推送云函数 → 订单操作仍全部成功(SC-004 零传导)。

+ 19 - 1
specs/028-flash-delivery-push/research.md

@@ -75,4 +75,22 @@
 
 ## 结论
 
-无 NEEDS CLARIFICATION 遗留;无新表、无新 i18n key、无新接口端点。实现落点集中在:1 个新通知服务 + `FlashDeliveryApplicationService` 5 个方法内的钩子调用 + 构造器新增依赖 + 测试。
+无 NEEDS CLARIFICATION 遗留;不新增接口端点。2026-09-18 追加的新单推送需要 1 个字段、2 个专属 i18n key、1 个独立任务与骑手候选查询,具体见 D8-D10。
+
+## D8: `is_display` 同时控制列表可见性与通知资格
+
+**Decision**: 立即单创建为 true;预约单创建为 false。预约任务在半开时间窗 `[scheduledPickupStartAt, scheduledPickupEndAt)` 内执行带完整业务条件的 `false -> true` 更新,只有影响 1 行者发送通知。
+
+**Rationale**: 若列表仅依赖时间表达式,而推送由分钟任务触发,骑手会先看到订单、最多一分钟后才收到通知。统一使用数据库门闩后,展示与通知资格由同一次原子写入开启;每秒调度把正常延迟控制在约一秒内,数据库条件更新承担多实例幂等。
+
+## D9: 独立每秒任务
+
+**Decision**: 新建 `FlashDeliveryAvailabilityTask.openScheduledOrders()`,Quartz 表达式 `0/1 * * * * ?`,禁止同任务并发;不把逻辑放入既有 `FlashDeliveryAutoCompleteTask`。
+
+**Rationale**: 开放展示属于可抢单生命周期,和送达自动完成、预约过期取消的职责及频率不同。独立 Bean 便于单独调整、监控和停用;批内单笔失败隔离,下一秒可重试尚未成功开放的订单。
+
+## D10: 候选骑手与专属新单文案
+
+**Decision**: 候选骑手须为在线骑手,支持 FLASH(空值按历史兼容)、车型匹配、处于配置半径内,并复用普通/加急闪送接单互斥规则;按距离选最近 20 人。外卖和闪送分别使用 `no.message.push.new.food.order`、`no.message.push.new.flash.order`。
+
+**Rationale**: 推送对象与实际能抢单的人群一致,减少无效提醒;专属文案让骑手无需打开 App 即可区分业务类型。

+ 29 - 3
specs/028-flash-delivery-push/spec.md

@@ -6,7 +6,7 @@
 
 **Status**: Draft
 
-**Input**: User description: "闪送订单状态变更消息推送:骑手抢单、已取件、已送达推送给用户端;用户/平台取消推送给骑手端。新任务发布(待抢单)不推送,加小费不推送。"
+**Input**: User description: "闪送订单状态变更消息推送:骑手抢单、已取件、已送达推送给用户端;用户/平台取消推送给骑手端。2026-09-18 追加:立即闪送单创建后通知骑手;预约闪送单创建时不通知,进入可抢单时间窗口后再开放展示并通知;外卖和闪送使用不同的新单文案。加小费不推送。"
 
 ## User Scenarios & Testing *(mandatory)*
 
@@ -56,6 +56,24 @@
 3. **Given** 订单尚未被任何骑手接单(WAITING_ACCEPTANCE,含过期预约单自动取消),**When** 订单被取消,**Then** 不产生骑手推送(无推送对象)。
 4. **Given** 用户取消请求因状态校验失败,**When** 取消未生效,**Then** 不产生推送。
 
+---
+
+### User Story 4 - 骑手及时收到可抢闪送新单通知 (Priority: P1)
+
+符合配送资格且在线的骑手在闪送订单真正可抢时收到新单推送。立即单创建后直接开放;预约单创建时隐藏,进入预约取件时间窗后再开放并推送。
+
+**Why this priority**: 新单通知决定骑手是否能及时发现任务;预约单必须保证“列表可见”和“推送触发”使用同一个原子开放动作,避免骑手先看到订单、稍后才收到推送。
+
+**Independent Test**: 分别创建立即单和预约单,验证立即单 `isDisplay=true` 且创建后推送;预约单初始 `isDisplay=false` 且不推送,在时间窗内由任务原子切为 `true` 后只推送一次。
+
+**Acceptance Scenarios**:
+
+1. **Given** 创建一笔立即闪送单,**When** 订单保存事务提交,**Then** 订单立即可见,并向符合配送类型、车型、距离和接单互斥规则的在线骑手发送“有新的闪送订单”。
+2. **Given** 创建一笔尚未进入时间窗的预约闪送单,**When** 创建事务提交,**Then** `isDisplay=false`,骑手列表不可见且不产生新单推送。
+3. **Given** 预约单进入 `scheduledPickupStartAt <= 当前时间 < scheduledPickupEndAt`,**When** 独立任务执行,**Then** 以条件更新原子切换 `isDisplay=false -> true`,同一次成功切换后发送新单推送。
+4. **Given** 多个任务实例或连续执行同时处理同一预约单,**When** 竞争开放资格,**Then** 只有条件更新成功的一次触发推送,其他执行不重复发送。
+5. **Given** 外卖单与闪送单分别发布,**When** 骑手收到新单推送,**Then** 文案分别为“有新的外卖订单”和“有新的闪送订单”。
+
 ### Edge Cases
 
 - 接收方没有有效的设备推送标识(未登录过 App / 标识为空)时:跳过云端推送,但消息记录仍正常生成,且不影响订单操作结果。
@@ -63,6 +81,7 @@
 - 接收方语言设置为越南语/繁中/英语/简中之外或未设置:按平台默认语言回落(与现有订单推送行为一致)。
 - 骑手抢单与用户取消并发(一方成功另一方必然失败):只对实际改变了订单状态的一方产生推送。
 - 平台连续取消多笔订单:每笔独立推送,互不影响。
+- 预约单进入窗口后的任务存在亚秒级调度延迟:在 `isDisplay` 原子切换前订单保持不可见,因此不会出现先看到订单、后收到推送的一分钟级错位。
 
 ## Requirements *(mandatory)*
 
@@ -77,8 +96,14 @@
 - **FR-007**: 每条推送内容 MUST 包含订单标识(订单号),使接收方无需打开 App 搜索即可定位订单。
 - **FR-008**: 每条推送 MUST 同步生成消息记录(复用现有消息中心机制),用户/骑手可在消息列表中回看。
 - **FR-009**: 推送环节的任何失败(无设备标识、推送服务异常)MUST NOT 影响订单状态流转本身,也 MUST NOT 向操作方返回错误。
-- **FR-010**: 以下事件明确不推送:新订单发布(待抢单)不向骑手推送;加小费不产生推送;超时自动完成(DELIVERED → COMPLETED)不推送;过期预约单自动取消不推送。
+- **FR-010**: 以下事件明确不推送:加小费不产生推送;超时自动完成(DELIVERED → COMPLETED)不推送;过期预约单自动取消不推送;预约单创建时不推送。
 - **FR-011**: 推送 MUST 仅在对应状态变更实际成功后触发;操作失败或未改变状态时不得产生推送。
+- **FR-012**: 立即闪送单创建时 `isDisplay` MUST 为 true,并在事务提交后触发一次骑手新单通知。
+- **FR-013**: 预约闪送单创建时 `isDisplay` MUST 为 false,且在开放前不得出现在骑手可抢列表、详情或抢单接口中。
+- **FR-014**: 预约单 MUST 仅在 `scheduledPickupStartAt <= 当前时间 < scheduledPickupEndAt` 时通过条件更新原子切换 `isDisplay=false -> true`;仅更新成功者有资格发送通知。
+- **FR-015**: 预约开放任务 MUST 使用独立任务类每秒执行,不与自动完成或预约过期取消任务耦合。
+- **FR-016**: 新单候选骑手 MUST 在线、具备 FLASH 配送类型、车型匹配、位于配置距离内,并遵守普通/加急闪送现有接单互斥规则。
+- **FR-017**: 外卖和闪送的新单推送 MUST 使用不同文案,分别为 `no.message.push.new.food.order` 与 `no.message.push.new.flash.order`。
 
 ### Key Entities *(include if feature involves data)*
 
@@ -92,12 +117,13 @@
 - **SC-002**: 四种语言设置的用户各抽检一批推送,文案语言与用户设置一致的比例为 100%。
 - **SC-003**: 抽检的全部推送均含订单号,接收方可凭推送内容直接定位订单,达标率 100%。
 - **SC-004**: 推送服务整体不可用时,抢单/取件/送达/取消接口的成功率与推送上线前持平(推送故障零传导)。
+- **SC-005**: 预约单进入可抢窗口后 2 秒内开放展示并开始异步推送,且同一订单开放通知最多触发一次。
 
 ## Assumptions
 
 - 复用项目现有推送通道(App 端云函数推送 + 消息入库),不新建推送基础设施,不新增 Android 通道。
 - 推送在订单状态变更成功后异步触发,不放在订单操作的事务内。
 - 平台取消按需求仅推送骑手端;寄件用户通过订单列表感知取消结果,如需同时通知用户可后续追加需求。
-- 超时自动完成、过期预约单自动取消、新单发布、加小费均在范围外(FR-010)
+- 超时自动完成、过期预约单自动取消、预约单创建和加小费均不推送(FR-010);立即单创建和预约单开放属于新单通知触发点
 - 推送点击后的客户端跳转(payload 路由到闪送订单详情页)由客户端处理;服务端只保证推送携带订单标识与业务类型。
 - 报文文案沿用现有订单推送的文案风格(简短状态描述 + 订单号后缀)。

+ 19 - 2
specs/028-flash-delivery-push/tasks.md

@@ -106,9 +106,25 @@ description: "Task list for feature 028-flash-delivery-push"
 
 **Purpose**: 排除事件防回归 + 全量验证
 
-- [x] T013 反例断言(FR-010):`FlashDeliveryApplicationServiceTest.java` 对 `complete`、`cancelExpiredScheduledOrder`、`tipAdd`、`create` 增加 notificationService 零交互(`verifyNoInteractions` 或 `never`)断言;`FlashDeliveryAutoCompleteTaskTest.java` 如涉及应用服务 mock 一并核对
+- [x] T013 反例断言(原始 FR-010):`FlashDeliveryApplicationServiceTest.java` 对 `complete`、`cancelExpiredScheduledOrder`、`tipAdd`、`create` 增加 notificationService 零交互断言。**2026-09-18 需求变更后,`create` 仅对预约单保持零新单通知;立即单改为通知,见 Phase 7。**
 - [x] T014 全量回归:`mvn -pl ruoyi-admin -am test` 455 测试仅 1 个已知存量失败(`createRejectsUnsupportedPayType`,在途 payType 特性所致);编译通过;git status 确认本特性改动仅 4 个代码文件(2 新建 2 增量)+ specs/028 + CLAUDE.md + feature.json,其余为并行在途特性(预约单窗口)的既有改动`mvn -pl ruoyi-admin -am test`(全部模块测试)+ `mvn -pl ruoyi-admin -am compile` 通过;grep 确认闪送模块外无文件改动(`git status` 仅闪送相关文件与 specs/028)
-- [x] T015 快照核对:本特性无 `updatesql/sql.md` 新条目(零数据库变更)、无 i18n 文件改动(零新 key,全部复用 `no.message.push.*`)、`ruoyi-system` 零改动;工作树中 i18n/sql.md/system 变更均属并行在途特性,与 028 无关确认无 `updatesql/sql.md` 新条目需求(零数据库变更)、无 i18n 文件改动(零新 key)、`ruoyi-system` 零改动;如有偏差回填 `specs/028-flash-delivery-push/research.md`
+- [x] T015 原始范围快照:当时零数据库变更、零新 i18n key、`ruoyi-system` 零改动。**该结论已被 2026-09-18 新单推送需求取代,见 Phase 7。**
+
+---
+
+## Phase 7: 骑手新单通知与预约单原子开放(2026-09-18 追加)
+
+**Goal**: 立即闪送单创建后可见并通知;预约单创建时隐藏,在有效时间窗内由独立每秒任务原子开放且只通知一次;外卖与闪送新单文案区分。
+
+- [x] T016 [US4] 先补失败测试:覆盖 `isDisplay` Mapper 映射、立即/预约创建差异、原子开放成功与重复执行、任务批处理、新单候选路由和新 i18n key。
+- [x] T017 [US4] 在 `FlashDeliveryOrder`、Mapper/XML 与查询条件中加入 `is_display`;骑手新任务列表、预接单详情和抢单接口仅允许 `is_display=true`。
+- [x] T018 [US4] 立即单创建为 `isDisplay=true` 并提交后通知;预约单创建为 false;实现 `false -> true` 的带状态、无人接单及时间窗条件更新,只有更新成功时通知。
+- [x] T019 [US4] 新增独立 `FlashDeliveryAvailabilityTask`,批量处理候选预约单并隔离单笔失败;在 `updatesql/sql.md` 注册每秒 Quartz 调度与禁止同任务并发。
+- [x] T020 [US4] 新增闪送候选骑手查询:在线、FLASH 资质、车型、半径、普通/加急接单互斥,按距离取最近 20 人。
+- [x] T021 [US4] 外卖/闪送新单分别使用 `no.message.push.new.food.order`、`no.message.push.new.flash.order`,同步维护 6 份后端 i18n 资源。
+- [x] T022 [US4] 在 `updatesql/sql.md` 记录 `is_display`、索引、存量预约单初始化与 `sys_job`;不直接执行数据库变更。
+- [x] T023 [US4] 更新 spec、plan、tasks、research、data-model、push contract 与 quickstart,说明每秒独立任务和原子展示门闩。
+- [x] T024 [US4] 运行定向测试(ruoyi-system 5 个、ruoyi-admin 14 个,均零失败)、`mvn -pl ruoyi-admin -am compile` 与 `git diff --check`;数据库迁移和真实推送联调仍需部署环境人工验证。
 
 ---
 
@@ -120,6 +136,7 @@ description: "Task list for feature 028-flash-delivery-push"
 - **Foundational (Phase 2)**: 依赖 Phase 1;**阻塞全部 user story**(T003 之后才能写各 story 的钩子与 verify 断言)
 - **User Stories (Phase 3-5)**: 依赖 Phase 2;三个 story 共享同一对文件(`FlashDeliveryApplicationService.java` 与两个测试类),**必须按 US1 → US2 → US3 顺序串行执行**,不可并行
 - **Polish (Phase 6)**: 依赖 Phase 3-5 全部完成
+- **New-order availability (Phase 7)**: 依赖既有通知服务;字段、原子更新、任务、候选骑手查询和推送按 T016 → T023 顺序实现
 
 ### Within Each User Story
 

+ 25 - 0
updatesql/sql.md

@@ -1567,3 +1567,28 @@ ALTER TABLE flash_delivery_order
   ADD COLUMN vehicle_type TINYINT NOT NULL DEFAULT 1 COMMENT '下单车型快照:1=机车,2=轿车' AFTER delivery_type,
   ADD KEY idx_flash_available_vehicle (status, vehicle_type, delivery_mode, scheduled_pickup_start_at);
 ```
+
+## 2026-09-18 闪送订单开放展示与新单推送(028-flash-delivery-push)
+
+> 用途:立即单创建后直接向符合条件的骑手开放并推送;预约单创建时隐藏,由独立定时任务在 `scheduled_pickup_start_at <= NOW() < scheduled_pickup_end_at` 时原子切换为可展示并推送一次。脚本只记录,由开发者备份后手动执行。
+
+```sql
+ALTER TABLE flash_delivery_order
+  ADD COLUMN is_display TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否向骑手开放展示:0否,1是' AFTER status,
+  ADD KEY idx_flash_scheduled_visibility (status, delivery_mode, is_display, scheduled_pickup_start_at);
+
+-- 存量待接预约单先隐藏;处于有效时间窗的订单会在任务下一次执行时开放并推送。
+UPDATE flash_delivery_order
+SET is_display = 0
+WHERE status = 'WAITING_ACCEPTANCE'
+  AND rider_id IS NULL
+  AND delivery_mode = 'SCHEDULED';
+
+INSERT INTO sys_job
+  (job_name, job_group, invoke_target, cron_expression, misfire_policy, concurrent, status, create_by, create_time, remark)
+SELECT '闪送预约单开放展示', 'DEFAULT', 'flashDeliveryAvailabilityTask.openScheduledOrders()',
+       '0/1 * * * * ?', '3', '1', '0', 'admin', NOW(), '每秒开放已进入预约取件窗口的闪送订单并通知骑手'
+WHERE NOT EXISTS (
+  SELECT 1 FROM sys_job WHERE invoke_target = 'flashDeliveryAvailabilityTask.openScheduledOrders()'
+);
+```

Unele fișiere nu au fost afișate deoarece prea multe fișiere au fost modificate în acest diff