Browse Source

Merge branch 'test-202609v1' into test-202609v2

# Conflicts:
#	.specify/feature.json
#	CLAUDE.md
#	ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryApplicationService.java
#	ruoyi-admin/src/main/resources/i18n/messages.properties
#	ruoyi-admin/src/main/resources/i18n/messages_vi.properties
#	ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryApplicationServiceTest.java
#	ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryMultiPickupTest.java
#	specs/024-flash-delivery/tasks.md
qmj 5 days ago
parent
commit
e6a5c3be2e
53 changed files with 2262 additions and 240 deletions
  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. 1 1
      .specify/feature.json
  10. 7 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/controller/FlashDeliveryRiderController.java
  11. 16 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/controller/FlashDeliveryUserController.java
  12. 9 2
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryOrderDetailView.java
  13. 12 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryRatingRequest.java
  14. 0 22
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryRiderSummaryView.java
  15. 131 29
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryApplicationService.java
  16. 204 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryNotificationService.java
  17. 17 2
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/task/FlashDeliveryAutoCompleteTask.java
  18. 32 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/task/FlashDeliveryAvailabilityTask.java
  19. 1 1
      ruoyi-admin/src/main/java/com/ruoyi/app/order/DeliveryOrderNotificationService.java
  20. 1 1
      ruoyi-admin/src/main/java/com/ruoyi/app/order/dto/OrderPushBodyDto.java
  21. 8 0
      ruoyi-admin/src/main/resources/i18n/messages.properties
  22. 6 0
      ruoyi-admin/src/main/resources/i18n/messages_en_US.properties
  23. 6 0
      ruoyi-admin/src/main/resources/i18n/messages_th_TH.properties
  24. 6 0
      ruoyi-admin/src/main/resources/i18n/messages_zh_CN.properties
  25. 6 0
      ruoyi-admin/src/main/resources/i18n/messages_zh_TW.properties
  26. 29 0
      ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/controller/FlashDeliveryControllerContractTest.java
  27. 1 1
      ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryOrderViewTest.java
  28. 482 1
      ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryApplicationServiceTest.java
  29. 2 0
      ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryI18nContractTest.java
  30. 4 1
      ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryMultiPickupTest.java
  31. 161 0
      ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryNotificationServiceTest.java
  32. 18 0
      ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/task/FlashDeliveryAutoCompleteTaskTest.java
  33. 63 0
      ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/task/FlashDeliveryAvailabilityTaskTest.java
  34. 5 0
      ruoyi-system/src/main/java/com/ruoyi/system/domain/flash/FlashDeliveryOrder.java
  35. 31 0
      ruoyi-system/src/main/java/com/ruoyi/system/mapper/RiderPositionMapper.java
  36. 20 0
      ruoyi-system/src/main/java/com/ruoyi/system/mapper/flash/FlashDeliveryOrderMapper.java
  37. 23 2
      ruoyi-system/src/main/java/com/ruoyi/system/utils/AuthAspect.java
  38. 55 2
      ruoyi-system/src/main/resources/mapper/flash/FlashDeliveryOrderMapper.xml
  39. 61 0
      ruoyi-system/src/test/java/com/ruoyi/system/mapper/RiderPositionMapperContractTest.java
  40. 27 0
      ruoyi-system/src/test/java/com/ruoyi/system/mapper/flash/FlashDeliveryMapperXmlTest.java
  41. 63 1
      ruoyi-system/src/test/java/com/ruoyi/system/utils/AuthAspectTest.java
  42. 8 0
      specs/024-flash-delivery/contracts/api.md
  43. 2 0
      specs/024-flash-delivery/spec.md
  44. 6 0
      specs/024-flash-delivery/tasks.md
  45. 38 0
      specs/028-flash-delivery-push/checklists/requirements.md
  46. 43 0
      specs/028-flash-delivery-push/contracts/push.md
  47. 54 0
      specs/028-flash-delivery-push/data-model.md
  48. 99 0
      specs/028-flash-delivery-push/plan.md
  49. 46 0
      specs/028-flash-delivery-push/quickstart.md
  50. 96 0
      specs/028-flash-delivery-push/research.md
  51. 129 0
      specs/028-flash-delivery-push/spec.md
  52. 186 0
      specs/028-flash-delivery-push/tasks.md
  53. 46 0
      updatesql/sql.md

+ 1 - 7
.gitignore

@@ -2,13 +2,7 @@
 # IntelliJ IDEA
 # IntelliJ IDEA
 # Maven / Spring Boot
 # Maven / Spring Boot
 *.class
 *.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/
 .superpowers/
 .worktrees/
 .worktrees/
 /.claude/*.json
 /.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/

File diff suppressed because it is too large
+ 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>

+ 1 - 1
.specify/feature.json

@@ -1,3 +1,3 @@
 {
 {
   "feature_directory": "specs/031-pay-method-config"
   "feature_directory": "specs/031-pay-method-config"
-}
+}

+ 7 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/controller/FlashDeliveryRiderController.java

@@ -49,6 +49,13 @@ public class FlashDeliveryRiderController extends BaseController {
         return success(service.riderDetail(userId(token), id));
         return success(service.riderDetail(userId(token), id));
     }
     }
 
 
+    /** 按订单编号查询订单详情;脱敏与归属校验和数字 ID 查询一致。 */
+    @GetMapping("/orders/byOrderNo")
+    @Auth
+    public AjaxResult detailByOrderNo(@RequestHeader String token, @RequestParam String orderNo) {
+        return success(service.riderDetailByOrderNo(userId(token), orderNo));
+    }
+
     /** 原子抢单;同一订单只能由一个骑手成功接取。 */
     /** 原子抢单;同一订单只能由一个骑手成功接取。 */
     @PostMapping("/orders/{id}/accept")
     @PostMapping("/orders/{id}/accept")
     @Auth
     @Auth

+ 16 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/controller/FlashDeliveryUserController.java

@@ -68,6 +68,13 @@ public class FlashDeliveryUserController extends BaseController {
         return success(service.userDetail(userId(token), id));
         return success(service.userDetail(userId(token), id));
     }
     }
 
 
+    /** 按订单编号查询本人订单详情;App 点击推送消息打开订单详情时使用。 */
+    @GetMapping("/orders/byOrderNo")
+    @Auth
+    public AjaxResult detailByOrderNo(@RequestHeader String token, @RequestParam String orderNo) {
+        return success(service.userDetailByOrderNo(userId(token), orderNo));
+    }
+
     /** 修改取送信息的预报价,不写订单;沿用该订单计价快照。 */
     /** 修改取送信息的预报价,不写订单;沿用该订单计价快照。 */
     @PostMapping("/orders/{id}/address/quote")
     @PostMapping("/orders/{id}/address/quote")
     @Auth
     @Auth
@@ -114,5 +121,14 @@ public class FlashDeliveryUserController extends BaseController {
         return success();
         return success();
     }
     }
 
 
+    /** 寄件人对已完成订单评价骑手,星级 1-5 且仅可评一次。 */
+    @PostMapping("/orders/rate")
+    @Auth
+    public AjaxResult rate(@RequestHeader String token,
+                           @RequestBody FlashDeliveryRatingRequest request) {
+        service.rateRider(userId(token), request == null ? null : request.getOrderId(), request);
+        return success();
+    }
+
     private Long userId(String token) { return Long.valueOf(new JwtUtil().getusid(token)); }
     private Long userId(String token) { return Long.valueOf(new JwtUtil().getusid(token)); }
 }
 }

+ 9 - 2
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryOrderDetailView.java

@@ -2,6 +2,7 @@ package com.ruoyi.app.flashdelivery.dto;
 
 
 import com.fasterxml.jackson.annotation.JsonInclude;
 import com.fasterxml.jackson.annotation.JsonInclude;
 import com.ruoyi.system.domain.flash.FlashDeliveryTargetAdjustment;
 import com.ruoyi.system.domain.flash.FlashDeliveryTargetAdjustment;
+import com.ruoyi.system.domain.InfoUser;
 import lombok.Data;
 import lombok.Data;
 import lombok.EqualsAndHashCode;
 import lombok.EqualsAndHashCode;
 
 
@@ -20,9 +21,15 @@ public class FlashDeliveryOrderDetailView extends FlashDeliveryOrderView {
     private List<String> pickupImageUrls = List.of();
     private List<String> pickupImageUrls = List.of();
     /** 送达凭证图片 URL 列表,骑手上传。 */
     /** 送达凭证图片 URL 列表,骑手上传。 */
     private List<String> deliveryImageUrls = List.of();
     private List<String> deliveryImageUrls = List.of();
-    /** 骑手公开资料,未接单时为空。 */
+    /** 接单骑手完整用户资料,未接单时为空。 */
     @JsonInclude(JsonInclude.Include.NON_NULL)
     @JsonInclude(JsonInclude.Include.NON_NULL)
-    private FlashDeliveryRiderSummaryView rider;
+    private InfoUser rider;
+    /** 寄件人对骑手的评分 1-5,未评价时不返回。 */
+    @JsonInclude(JsonInclude.Include.NON_NULL)
+    private Double riderStars;
+    /** 寄件用户完整资料,仅中单骑手接单后的详情返回。 */
+    @JsonInclude(JsonInclude.Include.NON_NULL)
+    private InfoUser user;
     /** 交付 PIN,仅启用 PIN 的用户详情返回,骑手端始终为空。 */
     /** 交付 PIN,仅启用 PIN 的用户详情返回,骑手端始终为空。 */
     @JsonInclude(JsonInclude.Include.NON_NULL)
     @JsonInclude(JsonInclude.Include.NON_NULL)
     private String deliveryPinCode;
     private String deliveryPinCode;

+ 12 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryRatingRequest.java

@@ -0,0 +1,12 @@
+package com.ruoyi.app.flashdelivery.dto;
+
+import lombok.Data;
+
+/** 寄件人评价骑手的请求数据;骑手身份由订单绑定,客户端不可指定。 */
+@Data
+public class FlashDeliveryRatingRequest {
+    /** 订单 ID。 */
+    private Long orderId;
+    /** 骑手评分,1 至 5。 */
+    private Double stars;
+}

+ 0 - 22
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryRiderSummaryView.java

@@ -1,22 +0,0 @@
-package com.ruoyi.app.flashdelivery.dto;
-
-import lombok.Data;
-
-import java.math.BigDecimal;
-
-/** 订单用户可见的骑手公开资料;位置仅在进行中订单返回。 */
-@Data
-public class FlashDeliveryRiderSummaryView {
-    /** 骑手昵称,未设置时使用用户名。 */
-    private String name;
-    /** 骑手头像。 */
-    private String avatar;
-    /** 骑手评分。 */
-    private Double rating;
-    /** IM 用户 ID(字符串形式),用于发起聊天。 */
-    private String imUserId;
-    /** 骑手实时位置经度,仅已接单或已取件状态返回。 */
-    private BigDecimal longitude;
-    /** 骑手实时位置纬度,仅已接单或已取件状态返回。 */
-    private BigDecimal latitude;
-}

+ 131 - 29
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryApplicationService.java

@@ -56,6 +56,9 @@ public class FlashDeliveryApplicationService {
     private static final Set<String> DELIVERY_MODES = Set.of("NOW", "SCHEDULED");
     private static final Set<String> DELIVERY_MODES = Set.of("NOW", "SCHEDULED");
     /** 支付方式取值与主订单系统同域:4=现金、6=线下转账(027 线下转账支付)。 */
     /** 支付方式取值与主订单系统同域:4=现金、6=线下转账(027 线下转账支付)。 */
     private static final String PAY_TYPE_CASH = "4";
     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 DateTimeFormatter PRICING_TIME_FORMAT = DateTimeFormatter.ofPattern("HH:mm");
     private static final ZoneId TAIPEI = ZoneId.of("Asia/Taipei");
     private static final ZoneId TAIPEI = ZoneId.of("Asia/Taipei");
     private static final int MAX_DISTANCE_METERS = 40_000;
     private static final int MAX_DISTANCE_METERS = 40_000;
@@ -76,6 +79,7 @@ public class FlashDeliveryApplicationService {
     private final RiderDeliveryAcceptanceService riderDeliveryAcceptanceService;
     private final RiderDeliveryAcceptanceService riderDeliveryAcceptanceService;
     /** 031 支付闸门:闪送下单、报价展示和骑手待抢列表使用同一可用性规则。 */
     /** 031 支付闸门:闪送下单、报价展示和骑手待抢列表使用同一可用性规则。 */
     private final PaymentMethodGateService paymentMethodGateService;
     private final PaymentMethodGateService paymentMethodGateService;
+    private final FlashDeliveryNotificationService notificationService;
 
 
     public FlashDeliveryApplicationService(FlashDeliveryOrderMapper orderMapper,
     public FlashDeliveryApplicationService(FlashDeliveryOrderMapper orderMapper,
                                            FlashDeliveryPricingMapper pricingMapper,
                                            FlashDeliveryPricingMapper pricingMapper,
@@ -88,7 +92,8 @@ public class FlashDeliveryApplicationService {
                                            RiderDeliveryAcceptanceService riderDeliveryAcceptanceService,
                                            RiderDeliveryAcceptanceService riderDeliveryAcceptanceService,
                                            FlashDeliveryOrderStopMapper stopMapper,
                                            FlashDeliveryOrderStopMapper stopMapper,
                                            FlashDeliveryTargetAdjustmentMapper adjustmentMapper,
                                            FlashDeliveryTargetAdjustmentMapper adjustmentMapper,
-                                           PaymentMethodGateService paymentMethodGateService) {
+                                           PaymentMethodGateService paymentMethodGateService,
+                                           FlashDeliveryNotificationService notificationService) {
         this.orderMapper = orderMapper;
         this.orderMapper = orderMapper;
         this.stopMapper = stopMapper;
         this.stopMapper = stopMapper;
         this.adjustmentMapper = adjustmentMapper;
         this.adjustmentMapper = adjustmentMapper;
@@ -101,6 +106,7 @@ public class FlashDeliveryApplicationService {
         this.riderDeliveryLockService = riderDeliveryLockService;
         this.riderDeliveryLockService = riderDeliveryLockService;
         this.riderDeliveryAcceptanceService = riderDeliveryAcceptanceService;
         this.riderDeliveryAcceptanceService = riderDeliveryAcceptanceService;
         this.paymentMethodGateService = paymentMethodGateService;
         this.paymentMethodGateService = paymentMethodGateService;
+        this.notificationService = notificationService;
     }
     }
 
 
     /** 闪送首页:返回开放的业务场景、配送等级,以及当前时段各服务的公开运价摘要。 */
     /** 闪送首页:返回开放的业务场景、配送等级,以及当前时段各服务的公开运价摘要。 */
@@ -195,6 +201,7 @@ public class FlashDeliveryApplicationService {
         order.setStatus(WAITING_ACCEPTANCE);
         order.setStatus(WAITING_ACCEPTANCE);
         copyPickupSummary(order, quote.pickups());
         copyPickupSummary(order, quote.pickups());
         order.setPickedUpStopCount(0);
         order.setPickedUpStopCount(0);
+        order.setIsDisplay("NOW".equals(deliveryMode));
         order.setDeliveryMode(deliveryMode);
         order.setDeliveryMode(deliveryMode);
         order.setScheduledPickupStartAt("SCHEDULED".equals(deliveryMode) ? request.getScheduledPickupStartAt() : null);
         order.setScheduledPickupStartAt("SCHEDULED".equals(deliveryMode) ? request.getScheduledPickupStartAt() : null);
         order.setScheduledPickupEndAt("SCHEDULED".equals(deliveryMode) ? request.getScheduledPickupEndAt() : null);
         order.setScheduledPickupEndAt("SCHEDULED".equals(deliveryMode) ? request.getScheduledPickupEndAt() : null);
@@ -240,6 +247,9 @@ public class FlashDeliveryApplicationService {
         saveStops(order, quote.pickups(), request.getDelivery(), now);
         saveStops(order, quote.pickups(), request.getDelivery(), now);
         saveImages(order.getId(), "SENDER", "USER", userId, senderImages, now);
         saveImages(order.getId(), "SENDER", "USER", userId, senderImages, now);
         writeLog(order.getId(), null, WAITING_ACCEPTANCE, "USER", userId, null, 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);
         return participantDetail(order, true, false);
     }
     }
 
 
@@ -261,7 +271,15 @@ public class FlashDeliveryApplicationService {
 
 
     /** 查询本人订单详情,仅寄件人和匹配到的收件人可见。 */
     /** 查询本人订单详情,仅寄件人和匹配到的收件人可见。 */
     public FlashDeliveryOrderDetailView userDetail(Long userId, Long orderId) {
     public FlashDeliveryOrderDetailView userDetail(Long userId, Long orderId) {
-        FlashDeliveryOrder order = requireOrder(orderId);
+        return userDetail(userId, requireOrder(orderId));
+    }
+
+    /** 按订单编号查询本人订单详情;App 点击推送消息打开订单详情时使用。 */
+    public FlashDeliveryOrderDetailView userDetailByOrderNo(Long userId, String orderNo) {
+        return userDetail(userId, requireOrderByNo(orderNo));
+    }
+
+    private FlashDeliveryOrderDetailView userDetail(Long userId, FlashDeliveryOrder order) {
         if (!isParticipant(userId, order)) throw fail("flash.delivery.order.not.found");
         if (!isParticipant(userId, order)) throw fail("flash.delivery.order.not.found");
         FlashDeliveryOrderDetailView view = participantDetail(order, true, false);
         FlashDeliveryOrderDetailView view = participantDetail(order, true, false);
         view.setCanUserCancel(Objects.equals(userId, order.getUserId()) && canUserCancel(order));
         view.setCanUserCancel(Objects.equals(userId, order.getUserId()) && canUserCancel(order));
@@ -416,6 +434,8 @@ public class FlashDeliveryApplicationService {
             throw fail("flash.delivery.state.changed");
             throw fail("flash.delivery.state.changed");
         }
         }
         writeLog(orderId, order.getStatus(), CANCELLED, "USER", userId, reason, now);
         writeLog(orderId, order.getStatus(), CANCELLED, "USER", userId, reason, now);
+        // 取消成功后通知接单骑手;未接单订单由通知服务内部跳过。
+        notificationService.notifyCancelled(order);
     }
     }
 
 
     /** 用户(寄件人或收件人)确认收货:已送达推进为已完成。 */
     /** 用户(寄件人或收件人)确认收货:已送达推进为已完成。 */
@@ -431,6 +451,23 @@ public class FlashDeliveryApplicationService {
         writeLog(orderId, DELIVERED, COMPLETED, "USER", userId, null, now);
         writeLog(orderId, DELIVERED, COMPLETED, "USER", userId, null, now);
     }
     }
 
 
+    /** 寄件人对已完成订单评价骑手:星级 1-5 仅一次,条件更新原子防并发重复提交。 */
+    @Transactional
+    public void rateRider(Long userId, Long orderId, FlashDeliveryRatingRequest request) {
+        if (request == null) throw fail("flash.delivery.request.required");
+        Double stars = request.getStars();
+        if (stars == null || stars < 1 || stars > 5) throw fail("flash.delivery.rating.stars.invalid");
+        FlashDeliveryOrder order = requireOrder(orderId);
+        if (!Objects.equals(userId, order.getUserId())) throw fail("flash.delivery.order.not.found");
+        if (!COMPLETED.equals(order.getStatus()) || order.getRiderId() == null) {
+            throw fail("flash.delivery.rating.not.allowed");
+        }
+        if (order.getRiderStars() != null) throw fail("flash.delivery.rating.already");
+        if (orderMapper.rateRider(orderId, userId, stars, new Date()) != 1) {
+            throw fail("flash.delivery.rating.already");
+        }
+    }
+
     /**
     /**
      * 骑手任务列表:按页签查询(newTask 可抢任务、toPickup 待取件、delivering 配送中、
      * 骑手任务列表:按页签查询(newTask 可抢任务、toPickup 待取件、delivering 配送中、
      * completed 已送达/已完成、cancelled 已取消)。newTask 页签额外按骑手坐标做距离过滤和就近排序,
      * completed 已送达/已完成、cancelled 已取消)。newTask 页签额外按骑手坐标做距离过滤和就近排序,
@@ -472,11 +509,20 @@ public class FlashDeliveryApplicationService {
 
 
     /** 骑手任务详情:抢单前返回脱敏视图(隐藏联系人、电话、坐标和 PIN);接单后仅中单骑手可见完整信息。 */
     /** 骑手任务详情:抢单前返回脱敏视图(隐藏联系人、电话、坐标和 PIN);接单后仅中单骑手可见完整信息。 */
     public FlashDeliveryOrderDetailView riderDetail(Long riderId, Long orderId) {
     public FlashDeliveryOrderDetailView riderDetail(Long riderId, Long orderId) {
+        return riderDetail(riderId, requireOrder(orderId));
+    }
+
+    /** 按订单编号查询骑手视角订单详情;脱敏与归属校验和数字 ID 查询一致。 */
+    public FlashDeliveryOrderDetailView riderDetailByOrderNo(Long riderId, String orderNo) {
+        return riderDetail(riderId, requireOrderByNo(orderNo));
+    }
+
+    private FlashDeliveryOrderDetailView riderDetail(Long riderId, FlashDeliveryOrder order) {
         InfoUser rider = requireRider(riderId);
         InfoUser rider = requireRider(riderId);
-        FlashDeliveryOrder order = requireOrder(orderId);
         if (WAITING_ACCEPTANCE.equals(order.getStatus()) && order.getRiderId() == null) {
         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)) {
                     || !isRiderVehicleCompatible(rider, order)) {
                 throw fail("flash.delivery.order.not.found");
                 throw fail("flash.delivery.order.not.found");
             }
             }
@@ -493,15 +539,21 @@ public class FlashDeliveryApplicationService {
         return riderDeliveryLockService.withLock(riderId, () -> {
         return riderDeliveryLockService.withLock(riderId, () -> {
             FlashDeliveryOrder target = requireOrder(orderId);
             FlashDeliveryOrder target = requireOrder(orderId);
             InfoUser rider = requireRider(riderId);
             InfoUser rider = requireRider(riderId);
-            if (!isRiderVehicleCompatible(rider, target)) throw fail("flash.delivery.order.not.found");
+            // 列表过滤不能替代锁内校验,防止骑手绕过列表直接提交不同车型订单。
+            if (!isRiderVehicleCompatible(rider, target)) throw fail("flash.delivery.rider.vehicle.not.match");
+            Date now = new Date();
+            // 预约单必须仍处在取件窗口内;即使骑手持有订单 ID,也不能绕过列表限制提前或逾期接单。
+            if (!Boolean.TRUE.equals(target.getIsDisplay()) || !isAvailableAt(target, now)) {
+                throw fail("flash.delivery.schedule.not.open");
+            }
             // 账号资格、闪送/总任务容量与急送独占统一在骑手锁内复核,列表结果不能替代接单校验。
             // 账号资格、闪送/总任务容量与急送独占统一在骑手锁内复核,列表结果不能替代接单校验。
             riderDeliveryAcceptanceService.assertCanAcceptFlash(riderId, target.getDeliveryType());
             riderDeliveryAcceptanceService.assertCanAcceptFlash(riderId, target.getDeliveryType());
-            Date now = new Date();
             // Mapper 使用“待接单且 rider_id 为空”的条件更新,受影响行数为 1 才表示抢单成功。
             // Mapper 使用“待接单且 rider_id 为空”的条件更新,受影响行数为 1 才表示抢单成功。
             if (orderMapper.accept(orderId, riderId, now) != 1) {
             if (orderMapper.accept(orderId, riderId, now) != 1) {
                 throw fail("flash.delivery.order.already.accepted");
                 throw fail("flash.delivery.order.already.accepted");
             }
             }
             writeLog(orderId, WAITING_ACCEPTANCE, ACCEPTED, "RIDER", riderId, null, now);
             writeLog(orderId, WAITING_ACCEPTANCE, ACCEPTED, "RIDER", riderId, null, now);
+            notificationService.notifyAccepted(target);
             return participantDetail(requireOrder(orderId), false, false);
             return participantDetail(requireOrder(orderId), false, false);
         });
         });
     }
     }
@@ -537,6 +589,8 @@ public class FlashDeliveryApplicationService {
         if (stopMapper.updateById(delivery) != 1) throw fail("flash.delivery.state.changed");
         if (stopMapper.updateById(delivery) != 1) throw fail("flash.delivery.state.changed");
         saveStationImages(orderId, delivery.getId(), "DELIVERY", "RIDER", riderId, urls, now);
         saveStationImages(orderId, delivery.getId(), "DELIVERY", "RIDER", riderId, urls, now);
         writeLog(orderId, PICKED_UP, DELIVERED, "RIDER", riderId, null, now);
         writeLog(orderId, PICKED_UP, DELIVERED, "RIDER", riderId, null, now);
+        // 送达成功后通知寄件用户;重读订单以携带最新状态。
+        notificationService.notifyDelivered(requireOrder(orderId));
     }
     }
 
 
     /** 平台运价配置列表,按时段开始时间排序。 */
     /** 平台运价配置列表,按时段开始时间排序。 */
@@ -673,6 +727,8 @@ public class FlashDeliveryApplicationService {
         saveStationImages(orderId, request.getProblemStopId(), "HANDOFF", "ADMIN", adminId, proofs, now);
         saveStationImages(orderId, request.getProblemStopId(), "HANDOFF", "ADMIN", adminId, proofs, now);
         if (orderMapper.cancel(orderId, order.getStatus(), "ADMIN", adminId, reason, now) != 1) throw fail("flash.delivery.state.changed");
         if (orderMapper.cancel(orderId, order.getStatus(), "ADMIN", adminId, reason, now) != 1) throw fail("flash.delivery.state.changed");
         writeLog(orderId, order.getStatus(), CANCELLED, "ADMIN", adminId, reason, now);
         writeLog(orderId, order.getStatus(), CANCELLED, "ADMIN", adminId, reason, now);
+        // 平台取消同样只通知接单骑手;寄件用户按规格不推送。
+        notificationService.notifyCancelled(order);
     }
     }
 
 
     /** 将已送达订单推进为已完成;平台手动与系统自动完成共用此入口,条件更新失败返回 false。 */
     /** 将已送达订单推进为已完成;平台手动与系统自动完成共用此入口,条件更新失败返回 false。 */
@@ -709,6 +765,36 @@ public class FlashDeliveryApplicationService {
         return orderMapper.selectAutoCompletable(deadline, Math.min(Math.max(limit, 1), 500));
         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));
+    }
+
+    /** 查询已进入预约窗口但仍未向骑手开放的订单,供独立 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) {
+        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) {
     private QuoteContext calculateQuote(FlashDeliveryQuoteRequest request) {
         if (request == null) throw fail("flash.delivery.request.required");
         if (request == null) throw fail("flash.delivery.request.required");
@@ -853,6 +939,12 @@ public class FlashDeliveryApplicationService {
         return urls;
         return urls;
     }
     }
 
 
+    /** 骑手闪送平均分;尚无评价时按平台骑手展示惯例回退 4.5。 */
+    private Double riderAverageStars(Long riderId) {
+        Double average = orderMapper.selectRiderAverageStars(riderId);
+        return average == null ? 4.5 : average;
+    }
+
     /** 组装用户或骑手视角详情;hideSensitiveFields 为 true 时按抢单前脱敏返回。 */
     /** 组装用户或骑手视角详情;hideSensitiveFields 为 true 时按抢单前脱敏返回。 */
     private FlashDeliveryOrderDetailView participantDetail(FlashDeliveryOrder order,
     private FlashDeliveryOrderDetailView participantDetail(FlashDeliveryOrder order,
                                                            boolean userView,
                                                            boolean userView,
@@ -870,7 +962,19 @@ public class FlashDeliveryApplicationService {
         }
         }
         if (userView) {
         if (userView) {
             view.setDeliveryPinCode(Boolean.TRUE.equals(order.getPinRequired()) ? order.getDeliveryPinCode() : null);
             view.setDeliveryPinCode(Boolean.TRUE.equals(order.getPinRequired()) ? order.getDeliveryPinCode() : null);
-            view.setRider(riderSummary(order));
+            // 接单后直接返回骑手完整用户资料;star 填充该骑手的闪送平均分供前端展示。
+            if (order.getRiderId() != null) {
+                InfoUser rider = userMapper.selectInfoUserByUserId(order.getRiderId());
+                if (rider != null) {
+                    rider.setStar(riderAverageStars(order.getRiderId()));
+                }
+                view.setRider(rider);
+            }
+        } else if (!hideSensitiveFields) {
+            // 接单后仅中单骑手可见寄件用户完整资料;抢单前脱敏视图不返回用户身份信息。
+            if (order.getUserId() != null) {
+                view.setUser(userMapper.selectInfoUserByUserId(order.getUserId()));
+            }
         }
         }
         if (!hideSensitiveFields && order.getId() != null) {
         if (!hideSensitiveFields && order.getId() != null) {
             groupImageUrls(view, stationImages);
             groupImageUrls(view, stationImages);
@@ -958,6 +1062,7 @@ public class FlashDeliveryApplicationService {
     private FlashDeliveryOrderDetailView detailOrderView(FlashDeliveryOrder order, boolean includeSensitiveFields) {
     private FlashDeliveryOrderDetailView detailOrderView(FlashDeliveryOrder order, boolean includeSensitiveFields) {
         FlashDeliveryOrderDetailView view = new FlashDeliveryOrderDetailView();
         FlashDeliveryOrderDetailView view = new FlashDeliveryOrderDetailView();
         populateOrderView(view, order, includeSensitiveFields);
         populateOrderView(view, order, includeSensitiveFields);
+        view.setRiderStars(order.getRiderStars());
         return view;
         return view;
     }
     }
 
 
@@ -1062,23 +1167,6 @@ public class FlashDeliveryApplicationService {
         return view;
         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) {
     private void applyBreakdown(FlashDeliveryQuoteView view, FlashDeliveryPriceBreakdown breakdown) {
         view.setBillableDistance(breakdown.getBillableDistance());
         view.setBillableDistance(breakdown.getBillableDistance());
@@ -1161,6 +1249,15 @@ public class FlashDeliveryApplicationService {
         return order;
         return order;
     }
     }
 
 
+    /** 按订单编号加载订单;编号为空或不存在时按订单不存在处理。 */
+    private FlashDeliveryOrder requireOrderByNo(String orderNo) {
+        if (orderNo == null || orderNo.isBlank()) throw fail("flash.delivery.order.not.found");
+        FlashDeliveryOrder order = orderMapper.selectOne(new QueryWrapper<FlashDeliveryOrder>()
+                .eq("order_no", orderNo.trim()).last("limit 1"));
+        if (order == null) throw fail("flash.delivery.order.not.found");
+        return order;
+    }
+
     /** 写入状态变更审计日志。 */
     /** 写入状态变更审计日志。 */
     private void writeLog(Long orderId, String from, String to, String operatorType,
     private void writeLog(Long orderId, String from, String to, String operatorType,
                           Long operatorId, String reason, Date now) {
                           Long operatorId, String reason, Date now) {
@@ -1241,10 +1338,11 @@ public class FlashDeliveryApplicationService {
         Date now = new Date();
         Date now = new Date();
         switch (tab) {
         switch (tab) {
             case "newTask" -> {
             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)
                         .eq("vehicle_type", riderVehicleType)
                         .and(item -> item.eq("delivery_mode", "NOW")
                         .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 (acceptedPayTypes.isEmpty()) query.apply("1 = 0");
                 if (acceptedPayTypes.isEmpty()) query.apply("1 = 0");
                 else query.in("pay_type", acceptedPayTypes);
                 else query.in("pay_type", acceptedPayTypes);
@@ -1370,12 +1468,14 @@ public class FlashDeliveryApplicationService {
         }
         }
     }
     }
 
 
-    /** 判断订单当前是否开放抢单:立即单随时可抢,预约单到点开放。 */
+    /** 判断订单当前是否开放抢单:立即单随时可抢,预约单仅在开始(含)至结束(不含)之间可抢。 */
     private boolean isAvailableAt(FlashDeliveryOrder order, Date now) {
     private boolean isAvailableAt(FlashDeliveryOrder order, Date now) {
         return "NOW".equals(order.getDeliveryMode())
         return "NOW".equals(order.getDeliveryMode())
                 || "SCHEDULED".equals(order.getDeliveryMode())
                 || "SCHEDULED".equals(order.getDeliveryMode())
                 && order.getScheduledPickupStartAt() != null
                 && 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) {
     private void optionalEq(QueryWrapper<FlashDeliveryOrder> query, String column, String value) {
@@ -1682,6 +1782,8 @@ public class FlashDeliveryApplicationService {
         if (allPicked(rows)) { order.setStatus(PICKED_UP); order.setPickedUpAt(now); }
         if (allPicked(rows)) { order.setStatus(PICKED_UP); order.setPickedUpAt(now); }
         if (orderMapper.updateFulfilment(order) != 1) throw fail("flash.delivery.state.changed");
         if (orderMapper.updateFulfilment(order) != 1) throw fail("flash.delivery.state.changed");
         writeLog(orderId, ACCEPTED, order.getStatus(), "RIDER", riderId, "PICKUP_STOP:" + stopId, now);
         writeLog(orderId, ACCEPTED, order.getStatus(), "RIDER", riderId, "PICKUP_STOP:" + stopId, now);
+        // 全部取货站完成、订单推进为已取件时通知寄件用户。
+        if (PICKED_UP.equals(order.getStatus())) notificationService.notifyPickedUp(order);
     }
     }
 
 
     private void saveStationImages(Long orderId, Long stopId, String proofType, String operatorType,
     private void saveStationImages(Long orderId, Long stopId, String proofType, String operatorType,

+ 204 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryNotificationService.java

@@ -0,0 +1,204 @@
+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;
+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
+public class FlashDeliveryNotificationService {
+    private static final Logger log = LoggerFactory.getLogger(FlashDeliveryNotificationService.class);
+
+    private static final String TITLE_KEY = "no.message.push.message";
+    private static final String CONTENT_ACCEPTED = "no.message.push.delivery.personnel.receiving.order";
+    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 表示骑手新单提醒。 */
+    private static final int PUSH_PAYLOAD_TYPE_NONE = -1;
+
+    private final InfoUserMapper userMapper;
+    private final PushEventService pushEventService;
+    private final RiderPositionMapper riderPositionMapper;
+
+    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;
+    }
+
+    /** 按订单取寄件用户;订单或用户缺失时静默跳过。 */
+    InfoUser senderOf(FlashDeliveryOrder order) {
+        if (order == null || order.getUserId() == null) return null;
+        return userMapper.selectById(order.getUserId());
+    }
+
+    /** 按订单取接单骑手;未接单(riderId 为空)或骑手缺失时静默跳过。 */
+    InfoUser riderOf(FlashDeliveryOrder order) {
+        if (order == null || order.getRiderId() == null) return null;
+        return userMapper.selectById(order.getRiderId());
+    }
+
+    /** 向寄件用户推送指定状态文案;消息入库由通道内 PublisherEvent 完成,cid 为空时仅入库。 */
+    void sendToUser(InfoUser recipient, FlashDeliveryOrder order, String status, String contentKey) {
+        if (recipient == null || recipient.getUserId() == null) return;
+        String orderNo = order.getOrderNo();
+        String body = OrderPushBodyDto.getJson(orderNo, status, PUSH_PAYLOAD_TYPE_NONE, PUSH_TYPE_FLASH);
+        AsyncManager.me().execute(new TimerTask() {
+            @Override
+            public void run() {
+                try {
+                    PayPush.userPushHandleLocal(new PayPush(), pushEventService, recipient.getUserId(),
+                            recipient.getCid(), TITLE_KEY, contentKey, body, "", orderNo);
+                } catch (Exception e) {
+                    log.error("闪送用户推送失败,userId:{}, orderNo:{}", recipient.getUserId(), orderNo, e);
+                }
+            }
+        });
+    }
+
+    /** 向接单骑手推送指定状态文案;与用户端一致,失败仅记日志。 */
+    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, payloadType, PUSH_TYPE_FLASH);
+        AsyncManager.me().execute(new TimerTask() {
+            @Override
+            public void run() {
+                try {
+                    PayPush.qsPushHandleLocal(new PayPush(), pushEventService, recipient.getUserId(),
+                            recipient.getCid(), TITLE_KEY, contentKey, body, "", orderNo);
+                } catch (Exception e) {
+                    log.error("闪送骑手推送失败,riderId:{}, orderNo:{}", recipient.getUserId(), orderNo, e);
+                }
+            }
+        });
+    }
+
+    /** 向一名可接单骑手发送闪送新单提醒;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(() -> {
+            InfoUser sender = senderOf(order);
+            if (sender != null) sendToUser(sender, order, ACCEPTED, CONTENT_ACCEPTED);
+        });
+    }
+
+    /** 骑手取件成功后通知寄件用户;未解析到寄件用户时跳过。 */
+    public void notifyPickedUp(FlashDeliveryOrder order) {
+        runAfterCommit(() -> {
+            InfoUser sender = senderOf(order);
+            if (sender != null) sendToUser(sender, order, PICKED_UP, CONTENT_PICKED_UP);
+        });
+    }
+
+    /** 骑手送达后通知寄件用户;未解析到寄件用户时跳过。 */
+    public void notifyDelivered(FlashDeliveryOrder order) {
+        runAfterCommit(() -> {
+            InfoUser sender = senderOf(order);
+            if (sender != null) sendToUser(sender, order, DELIVERED, CONTENT_DELIVERED);
+        });
+    }
+
+    /** 订单取消后通知接单骑手;未接单(无骑手归属)或骑手缺失时跳过,寄件用户不推。 */
+    public void notifyCancelled(FlashDeliveryOrder order) {
+        runAfterCommit(() -> {
+            InfoUser rider = riderOf(order);
+            if (rider != null) sendToRider(rider, order, CANCELLED, CONTENT_CANCELLED);
+        });
+    }
+
+    /** 仅事务提交后执行推送准备;无事务同步时(单测)直接执行。 */
+    static void runAfterCommit(Runnable action) {
+        if (!TransactionSynchronizationManager.isSynchronizationActive()) {
+            action.run();
+            return;
+        }
+        TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
+            @Override
+            public void afterCommit() {
+                action.run();
+            }
+        });
+    }
+
+    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;
+    }
+}

+ 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;
 import java.util.Date;
 
 
 /**
 /**
- * 将送达超过 24 小时且用户未确认的订单自动收口为已完成。
- * 由若依任务调度调用 {@code flashDeliveryAutoCompleteTask.completeDeliveredOrders()}。
+ * 提供闪送订单的 Quartz 收口任务:送达超时自动完成、预约取件超时自动取消。
+ * 由若依任务调度分别调用本 Bean 的公开方法,避免使用应用内 {@code @Scheduled} 造成重复执行。
  */
  */
 @Component("flashDeliveryAutoCompleteTask")
 @Component("flashDeliveryAutoCompleteTask")
 public class 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);
+            }
+        }
+    }
 }
 }

+ 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

@@ -121,7 +121,7 @@ public class DeliveryOrderNotificationService {
             @Override
             @Override
             public void run() {
             public void run() {
                 PayPush.qsPushHandleLocal(new PayPush(), pushEventService, rider.getRiderId(), rider.getCid(),
                 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);
             }
             }
         });
         });
     }
     }

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

@@ -13,7 +13,7 @@ public class OrderPushBodyDto {
     private String state;
     private String state;
     //0:语音提醒新订单
     //0:语音提醒新订单
     private int type=-1;
     private int type=-1;
-    //1 外卖订单 2 充值订单
+    //1 外卖订单 2 充值订单  3 闪送订单
     private int pushType=1;
     private int pushType=1;
 
 
     private String title;
     private String title;

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

@@ -388,3 +388,11 @@ flash.delivery.target.mismatch=請在目前目標站取貨;變更目標須先
 flash.delivery.stop.id.required=多取貨點訂單必須指定取貨站點編號。
 flash.delivery.stop.id.required=多取貨點訂單必須指定取貨站點編號。
 flash.delivery.intervention.required=請選擇問題站點並記錄平台處理結果。
 flash.delivery.intervention.required=請選擇問題站點並記錄平台處理結果。
 flash.delivery.handoff.required=請記錄已取物品去向及交接憑證。
 flash.delivery.handoff.required=請記錄已取物品去向及交接憑證。
+
+# 闪送评分/预约窗口/新订单推送(合并自 test-202609v1)
+flash.delivery.rating.already=該訂單騎手已評價過,不能重複評價
+flash.delivery.rating.not.allowed=訂單完成後才能評價騎手
+flash.delivery.rating.stars.invalid=騎手評分必須是1到5之間的數值
+flash.delivery.schedule.not.open=預約取件時間未開始或已結束,暫不能接單
+no.message.push.new.flash.order=有新的閃送訂單
+no.message.push.new.food.order=有新的外送訂單

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

@@ -115,6 +115,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.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.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.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.recharge.success=Recharge successful
 no.message.push.payment.success=Payment successful
 no.message.push.payment.success=Payment successful
 no.message.push.payment.fail=Payment failed
 no.message.push.payment.fail=Payment failed
@@ -327,6 +329,9 @@ flash.delivery.transition.not.allowed=The current order status does not allow th
 flash.delivery.cancel.reason.required=A cancellation reason is required
 flash.delivery.cancel.reason.required=A cancellation reason is required
 flash.delivery.cancel.not.allowed=The current order status cannot be cancelled this way
 flash.delivery.cancel.not.allowed=The current order status cannot be cancelled this way
 flash.delivery.complete.not.allowed=The order is not ready to complete
 flash.delivery.complete.not.allowed=The order is not ready to complete
+flash.delivery.rating.stars.invalid=Rider rating must be between 1 and 5
+flash.delivery.rating.not.allowed=The rider can only be rated after the order is completed
+flash.delivery.rating.already=The rider of this order has already been rated
 flash.delivery.state.changed=The order status changed; please refresh and retry
 flash.delivery.state.changed=The order status changed; please refresh and retry
 flash.delivery.pricing.invalid=Pricing values must be valid positive numbers
 flash.delivery.pricing.invalid=Pricing values must be valid positive numbers
 flash.delivery.pricing.not.available=No pricing is available for the current time period
 flash.delivery.pricing.not.available=No pricing is available for the current time period
@@ -387,3 +392,4 @@ flash.delivery.target.mismatch=Confirm pickup at the current target; save an agr
 flash.delivery.stop.id.required=A pickup station ID is required for this order.
 flash.delivery.stop.id.required=A pickup station ID is required for this order.
 flash.delivery.intervention.required=Select the problem station and record the intervention result.
 flash.delivery.intervention.required=Select the problem station and record the intervention result.
 flash.delivery.handoff.required=Record the destination of collected goods and handoff evidence.
 flash.delivery.handoff.required=Record the destination of collected goods and handoff evidence.
+flash.delivery.schedule.not.open=The scheduled pickup window has not started or has already ended

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

@@ -114,6 +114,8 @@ no.message.push.rider.accepted.merchant=ไรเดอร์รับคำส
 no.message.push.order.cancelled=ยกเลิกคำสั่งซื้อแล้ว
 no.message.push.order.cancelled=ยกเลิกคำสั่งซื้อแล้ว
 no.order.rider.accept.required=โปรดรอให้ไรเดอร์รับคำสั่งซื้อก่อนดำเนินการ
 no.order.rider.accept.required=โปรดรอให้ไรเดอร์รับคำสั่งซื้อก่อนดำเนินการ
 no.message.push.new.order=มีคำสั่งซื้อใหม่
 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.recharge.success=เติมเงินสำเร็จ
 no.message.push.payment.success=ชำระเงินสำเร็จ
 no.message.push.payment.success=ชำระเงินสำเร็จ
 no.message.push.payment.fail=ชำระเงินไม่สำเร็จ
 no.message.push.payment.fail=ชำระเงินไม่สำเร็จ
@@ -320,6 +322,9 @@ flash.delivery.transition.not.allowed=สถานะคำสั่งซื้
 flash.delivery.cancel.reason.required=ต้องระบุเหตุผลในการยกเลิก
 flash.delivery.cancel.reason.required=ต้องระบุเหตุผลในการยกเลิก
 flash.delivery.cancel.not.allowed=สถานะคำสั่งซื้อปัจจุบันไม่อนุญาตให้ยกเลิกด้วยวิธีนี้
 flash.delivery.cancel.not.allowed=สถานะคำสั่งซื้อปัจจุบันไม่อนุญาตให้ยกเลิกด้วยวิธีนี้
 flash.delivery.complete.not.allowed=คำสั่งซื้อยังไม่สามารถดำเนินการให้เสร็จสิ้นได้
 flash.delivery.complete.not.allowed=คำสั่งซื้อยังไม่สามารถดำเนินการให้เสร็จสิ้นได้
+flash.delivery.rating.stars.invalid=คะแนนไรเดอร์ต้องอยู่ระหว่าง 1 ถึง 5
+flash.delivery.rating.not.allowed=สามารถให้คะแนนไรเดอร์ได้หลังคำสั่งซื้อเสร็จสิ้นแล้วเท่านั้น
+flash.delivery.rating.already=ไรเดอร์ของคำสั่งซื้อนี้ถูกให้คะแนนแล้ว
 flash.delivery.state.changed=สถานะคำสั่งซื้อเปลี่ยนแปลงแล้ว โปรดรีเฟรชและลองอีกครั้ง
 flash.delivery.state.changed=สถานะคำสั่งซื้อเปลี่ยนแปลงแล้ว โปรดรีเฟรชและลองอีกครั้ง
 flash.delivery.pricing.invalid=ค่ากำหนดราคาต้องเป็นจำนวนบวกที่ถูกต้อง
 flash.delivery.pricing.invalid=ค่ากำหนดราคาต้องเป็นจำนวนบวกที่ถูกต้อง
 flash.delivery.pricing.not.available=ไม่มีอัตราค่าบริการที่ใช้ได้ในช่วงเวลานี้
 flash.delivery.pricing.not.available=ไม่มีอัตราค่าบริการที่ใช้ได้ในช่วงเวลานี้
@@ -388,3 +393,4 @@ flash.delivery.target.mismatch=ยืนยันรับสินค้าท
 flash.delivery.stop.id.required=คำสั่งซื้อหลายจุดต้องระบุรหัสจุดรับสินค้า
 flash.delivery.stop.id.required=คำสั่งซื้อหลายจุดต้องระบุรหัสจุดรับสินค้า
 flash.delivery.intervention.required=เลือกจุดที่มีปัญหาและบันทึกผลการจัดการของแพลตฟอร์ม
 flash.delivery.intervention.required=เลือกจุดที่มีปัญหาและบันทึกผลการจัดการของแพลตฟอร์ม
 flash.delivery.handoff.required=บันทึกปลายทางของสินค้าที่รับแล้วและหลักฐานส่งมอบ
 flash.delivery.handoff.required=บันทึกปลายทางของสินค้าที่รับแล้วและหลักฐานส่งมอบ
+flash.delivery.schedule.not.open=ยังไม่ถึงช่วงเวลานัดรับสินค้า หรือช่วงเวลาดังกล่าวสิ้นสุดแล้ว จึงยังรับออเดอร์นี้ไม่ได้

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

@@ -115,6 +115,8 @@ no.message.push.rider.accepted.merchant=骑手已接单,请及时确认并备
 no.message.push.order.cancelled=订单已取消
 no.message.push.order.cancelled=订单已取消
 no.order.rider.accept.required=请等待骑手接单后再操作订单
 no.order.rider.accept.required=请等待骑手接单后再操作订单
 no.message.push.new.order=有新订单
 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.recharge.success=充值成功
 no.message.push.payment.success=支付成功
 no.message.push.payment.success=支付成功
 no.message.push.payment.fail=支付失败
 no.message.push.payment.fail=支付失败
@@ -328,6 +330,9 @@ flash.delivery.transition.not.allowed=当前订单状态不允许此操作
 flash.delivery.cancel.reason.required=取消原因不能为空
 flash.delivery.cancel.reason.required=取消原因不能为空
 flash.delivery.cancel.not.allowed=当前订单状态不允许这样取消
 flash.delivery.cancel.not.allowed=当前订单状态不允许这样取消
 flash.delivery.complete.not.allowed=当前订单尚不能完成
 flash.delivery.complete.not.allowed=当前订单尚不能完成
+flash.delivery.rating.stars.invalid=骑手评分必须是1到5之间的数值
+flash.delivery.rating.not.allowed=订单完成后才能评价骑手
+flash.delivery.rating.already=该订单骑手已评价过,不能重复评价
 flash.delivery.state.changed=订单状态已变化,请刷新后重试
 flash.delivery.state.changed=订单状态已变化,请刷新后重试
 flash.delivery.pricing.invalid=计价配置必须为有效正数
 flash.delivery.pricing.invalid=计价配置必须为有效正数
 flash.delivery.pricing.not.available=当前时段暂无可用运价
 flash.delivery.pricing.not.available=当前时段暂无可用运价
@@ -388,3 +393,4 @@ flash.delivery.target.mismatch=请在当前目标站取货;变更目标需先
 flash.delivery.stop.id.required=多取货点订单必须指定取货站点编号。
 flash.delivery.stop.id.required=多取货点订单必须指定取货站点编号。
 flash.delivery.intervention.required=请选择问题站点并记录平台处理结果。
 flash.delivery.intervention.required=请选择问题站点并记录平台处理结果。
 flash.delivery.handoff.required=请记录已取物品去向及交接凭证。
 flash.delivery.handoff.required=请记录已取物品去向及交接凭证。
+flash.delivery.schedule.not.open=预约取件时间未开始或已结束,暂不能接单

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

@@ -115,6 +115,8 @@ no.message.push.rider.accepted.merchant=騎手已接單,請及時確認並備
 no.message.push.order.cancelled=訂單已取消
 no.message.push.order.cancelled=訂單已取消
 no.order.rider.accept.required=請等待騎手接單後再操作訂單
 no.order.rider.accept.required=請等待騎手接單後再操作訂單
 no.message.push.new.order=有新訂單
 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.recharge.success=充值成功
 no.message.push.payment.success=支付成功
 no.message.push.payment.success=支付成功
 no.message.push.payment.fail=支付失敗
 no.message.push.payment.fail=支付失敗
@@ -328,6 +330,9 @@ flash.delivery.transition.not.allowed=目前訂單狀態不允許此操作
 flash.delivery.cancel.reason.required=取消原因不能為空
 flash.delivery.cancel.reason.required=取消原因不能為空
 flash.delivery.cancel.not.allowed=目前訂單狀態不允許這樣取消
 flash.delivery.cancel.not.allowed=目前訂單狀態不允許這樣取消
 flash.delivery.complete.not.allowed=目前訂單尚不能完成
 flash.delivery.complete.not.allowed=目前訂單尚不能完成
+flash.delivery.rating.stars.invalid=騎手評分必須是1到5之間的數值
+flash.delivery.rating.not.allowed=訂單完成後才能評價騎手
+flash.delivery.rating.already=該訂單騎手已評價過,不能重複評價
 flash.delivery.state.changed=訂單狀態已變更,請重新整理後再試
 flash.delivery.state.changed=訂單狀態已變更,請重新整理後再試
 flash.delivery.pricing.invalid=計價設定必須為有效正數
 flash.delivery.pricing.invalid=計價設定必須為有效正數
 flash.delivery.pricing.not.available=目前時段暫無可用運價
 flash.delivery.pricing.not.available=目前時段暫無可用運價
@@ -388,3 +393,4 @@ flash.delivery.target.mismatch=請在目前目標站取貨;變更目標須先
 flash.delivery.stop.id.required=多取貨點訂單必須指定取貨站點編號。
 flash.delivery.stop.id.required=多取貨點訂單必須指定取貨站點編號。
 flash.delivery.intervention.required=請選擇問題站點並記錄平台處理結果。
 flash.delivery.intervention.required=請選擇問題站點並記錄平台處理結果。
 flash.delivery.handoff.required=請記錄已取物品去向及交接憑證。
 flash.delivery.handoff.required=請記錄已取物品去向及交接憑證。
+flash.delivery.schedule.not.open=預約取件時間未開始或已結束,暫不能接單

+ 29 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/controller/FlashDeliveryControllerContractTest.java

@@ -34,6 +34,7 @@ import static org.junit.jupiter.api.Assertions.assertSame;
 import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
 import static org.mockito.Mockito.when;
 
 
 class FlashDeliveryControllerContractTest {
 class FlashDeliveryControllerContractTest {
@@ -179,6 +180,34 @@ class FlashDeliveryControllerContractTest {
         assertSame(detail, controller.addTip(token, tip).get(AjaxResult.DATA_TAG));
         assertSame(detail, controller.addTip(token, tip).get(AjaxResult.DATA_TAG));
     }
     }
 
 
+    @Test
+    void rateEndpointDelegatesSenderAndStarsToService() {
+        FlashDeliveryApplicationService service = mock(FlashDeliveryApplicationService.class);
+        var request = new com.ruoyi.app.flashdelivery.dto.FlashDeliveryRatingRequest();
+        request.setOrderId(10L);
+        request.setStars(4.5);
+
+        new FlashDeliveryUserController(service).rate(JwtUtil.token("7", "user"), request);
+
+        verify(service).rateRider(7L, 10L, request);
+    }
+
+    @Test
+    void detailByOrderNoEndpointsDelegateToService() {
+        FlashDeliveryApplicationService service = mock(FlashDeliveryApplicationService.class);
+        FlashDeliveryOrderDetailView detail = new FlashDeliveryOrderDetailView();
+        when(service.userDetailByOrderNo(7L, "FL-1")).thenReturn(detail);
+        when(service.riderDetailByOrderNo(8L, "FL-1")).thenReturn(detail);
+
+        AjaxResult userResult = new FlashDeliveryUserController(service)
+                .detailByOrderNo(JwtUtil.token("7", "user"), "FL-1");
+        AjaxResult riderResult = new FlashDeliveryRiderController(service)
+                .detailByOrderNo(JwtUtil.token("8", "rider"), "FL-1");
+
+        assertSame(detail, userResult.get(AjaxResult.DATA_TAG));
+        assertSame(detail, riderResult.get(AjaxResult.DATA_TAG));
+    }
+
     @Test
     @Test
     void addressConfirmationReturnsLatestQuoteForAnotherUserConfirmation() {
     void addressConfirmationReturnsLatestQuoteForAnotherUserConfirmation() {
         FlashDeliveryApplicationService service = mock(FlashDeliveryApplicationService.class);
         FlashDeliveryApplicationService service = mock(FlashDeliveryApplicationService.class);

+ 1 - 1
ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryOrderViewTest.java

@@ -34,7 +34,7 @@ class FlashDeliveryOrderViewTest {
             assertFalse(fields.contains(forbidden), forbidden);
             assertFalse(fields.contains(forbidden), forbidden);
         }
         }
         for (String required : Set.of("senderImageUrls", "pickupImageUrls",
         for (String required : Set.of("senderImageUrls", "pickupImageUrls",
-                "deliveryImageUrls", "rider", "deliveryPinCode")) {
+                "deliveryImageUrls", "rider", "user", "deliveryPinCode")) {
             assertTrue(fields.contains(required), required);
             assertTrue(fields.contains(required), required);
         }
         }
         assertTrue(FlashDeliveryOrderView.class.isAssignableFrom(FlashDeliveryOrderDetailView.class));
         assertTrue(FlashDeliveryOrderView.class.isAssignableFrom(FlashDeliveryOrderDetailView.class));

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

@@ -11,6 +11,7 @@ import com.ruoyi.app.flashdelivery.dto.FlashDeliveryCreateRequest;
 import com.ruoyi.app.flashdelivery.dto.FlashDeliveryProofRequest;
 import com.ruoyi.app.flashdelivery.dto.FlashDeliveryProofRequest;
 import com.ruoyi.app.flashdelivery.dto.FlashDeliveryPricingRequest;
 import com.ruoyi.app.flashdelivery.dto.FlashDeliveryPricingRequest;
 import com.ruoyi.app.flashdelivery.dto.FlashDeliveryQuoteRequest;
 import com.ruoyi.app.flashdelivery.dto.FlashDeliveryQuoteRequest;
+import com.ruoyi.app.flashdelivery.dto.FlashDeliveryReasonRequest;
 import com.ruoyi.app.flashdelivery.dto.FlashDeliveryDeliverRequest;
 import com.ruoyi.app.flashdelivery.dto.FlashDeliveryDeliverRequest;
 import com.ruoyi.app.flashdelivery.dto.FlashDeliveryRiderOrderPageView;
 import com.ruoyi.app.flashdelivery.dto.FlashDeliveryRiderOrderPageView;
 import com.ruoyi.app.flashdelivery.exception.FlashDeliveryQuoteChangedException;
 import com.ruoyi.app.flashdelivery.exception.FlashDeliveryQuoteChangedException;
@@ -51,6 +52,7 @@ import static com.ruoyi.system.domain.flash.FlashDeliveryStatus.ACCEPTED;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
 import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
 import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
 import static org.junit.jupiter.api.Assertions.assertNull;
 import static org.junit.jupiter.api.Assertions.assertNull;
 import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -69,6 +71,7 @@ import static org.mockito.Mockito.when;
 import static org.mockito.Mockito.doAnswer;
 import static org.mockito.Mockito.doAnswer;
 import static org.mockito.Mockito.inOrder;
 import static org.mockito.Mockito.inOrder;
 import static org.mockito.Mockito.doThrow;
 import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.verifyNoInteractions;
 
 
 class FlashDeliveryApplicationServiceTest {
 class FlashDeliveryApplicationServiceTest {
     private static ConfigurableListableBeanFactory originalBeanFactory;
     private static ConfigurableListableBeanFactory originalBeanFactory;
@@ -384,7 +387,10 @@ class FlashDeliveryApplicationServiceTest {
         var parameters = query.getValue().getParamNameValuePairs().values();
         var parameters = query.getValue().getParamNameValuePairs().values();
         assertTrue(sql.contains("status"));
         assertTrue(sql.contains("status"));
         assertTrue(sql.contains("rider_id is null"));
         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_start_at"));
+        assertTrue(sql.contains("scheduled_pickup_end_at"));
         assertTrue(sql.contains("st_distance_sphere"));
         assertTrue(sql.contains("st_distance_sphere"));
         assertTrue(parameters.contains("WAITING_ACCEPTANCE"));
         assertTrue(parameters.contains("WAITING_ACCEPTANCE"));
         assertTrue(parameters.contains("NOW"));
         assertTrue(parameters.contains("NOW"));
@@ -397,6 +403,7 @@ class FlashDeliveryApplicationServiceTest {
         var summaryParameters = summaryQuery.getValue().getParamNameValuePairs().values();
         var summaryParameters = summaryQuery.getValue().getParamNameValuePairs().values();
         assertTrue(summarySql.contains("rider_id is null"));
         assertTrue(summarySql.contains("rider_id is null"));
         assertTrue(summarySql.contains("scheduled_pickup_start_at"));
         assertTrue(summarySql.contains("scheduled_pickup_start_at"));
+        assertTrue(summarySql.contains("scheduled_pickup_end_at"));
         assertTrue(summarySql.contains("st_distance_sphere"));
         assertTrue(summarySql.contains("st_distance_sphere"));
         assertTrue(summaryParameters.contains("WAITING_ACCEPTANCE"));
         assertTrue(summaryParameters.contains("WAITING_ACCEPTANCE"));
     }
     }
@@ -549,9 +556,11 @@ class FlashDeliveryApplicationServiceTest {
         assertNull(result.getDelivery().getLatitude());
         assertNull(result.getDelivery().getLatitude());
         assertNull(result.getDeliveryPinCode());
         assertNull(result.getDeliveryPinCode());
         assertNull(result.getUserNote());
         assertNull(result.getUserNote());
+        assertNull(result.getUser());
         assertTrue(result.getSenderImageUrls().isEmpty());
         assertTrue(result.getSenderImageUrls().isEmpty());
         assertTrue(result.getPickupImageUrls().isEmpty());
         assertTrue(result.getPickupImageUrls().isEmpty());
         assertTrue(result.getDeliveryImageUrls().isEmpty());
         assertTrue(result.getDeliveryImageUrls().isEmpty());
+        verify(fixture.userMapper, never()).selectInfoUserByUserId(7L);
         verify(fixture.imageMapper, never()).selectList(any());
         verify(fixture.imageMapper, never()).selectList(any());
         verify(fixture.logMapper, never()).selectList(any());
         verify(fixture.logMapper, never()).selectList(any());
     }
     }
@@ -579,6 +588,53 @@ class FlashDeliveryApplicationServiceTest {
         verify(fixture.logMapper, never()).selectList(any());
         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
     @Test
     void matchedReceiverCanViewAndConfirmButCannotCancelOrder() {
     void matchedReceiverCanViewAndConfirmButCannotCancelOrder() {
         Fixture fixture = new Fixture();
         Fixture fixture = new Fixture();
@@ -597,6 +653,188 @@ class FlashDeliveryApplicationServiceTest {
                 eq("COMPLETED"), any(Date.class));
                 eq("COMPLETED"), any(Date.class));
     }
     }
 
 
+    @Test
+    void senderRatesRiderOnCompletedOrder() {
+        Fixture fixture = new Fixture();
+        FlashDeliveryOrder order = order(10L, "COMPLETED");
+        order.setRiderId(8L);
+        when(fixture.orderMapper.selectById(10L)).thenReturn(order);
+        when(fixture.orderMapper.rateRider(eq(10L), eq(7L), eq(4.5), any(Date.class))).thenReturn(1);
+
+        fixture.service.rateRider(7L, 10L, rating(4.5));
+
+        verify(fixture.orderMapper).rateRider(eq(10L), eq(7L), eq(4.5), any(Date.class));
+    }
+
+    @Test
+    void rateRiderRejectsUserOtherThanSender() {
+        Fixture fixture = new Fixture();
+        FlashDeliveryOrder order = order(10L, "COMPLETED");
+        order.setRiderId(8L);
+        when(fixture.orderMapper.selectById(10L)).thenReturn(order);
+
+        assertThrows(ServiceException.class, () -> fixture.service.rateRider(88L, 10L, rating(4.5)));
+        verify(fixture.orderMapper, never()).rateRider(any(), any(), any(), any());
+    }
+
+    @Test
+    void rateRiderRejectsOrderNotCompletedOrWithoutRider() {
+        Fixture fixture = new Fixture();
+        FlashDeliveryOrder delivered = order(10L, "DELIVERED");
+        delivered.setRiderId(8L);
+        when(fixture.orderMapper.selectById(10L)).thenReturn(delivered);
+        assertThrows(ServiceException.class, () -> fixture.service.rateRider(7L, 10L, rating(4.5)));
+
+        FlashDeliveryOrder completedWithoutRider = order(11L, "COMPLETED");
+        when(fixture.orderMapper.selectById(11L)).thenReturn(completedWithoutRider);
+        assertThrows(ServiceException.class, () -> fixture.service.rateRider(7L, 11L, rating(4.5)));
+
+        verify(fixture.orderMapper, never()).rateRider(any(), any(), any(), any());
+    }
+
+    @Test
+    void rateRiderRejectsAlreadyRatedOrder() {
+        Fixture fixture = new Fixture();
+        FlashDeliveryOrder order = order(10L, "COMPLETED");
+        order.setRiderId(8L);
+        order.setRiderStars(4.5);
+        when(fixture.orderMapper.selectById(10L)).thenReturn(order);
+
+        assertThrows(ServiceException.class, () -> fixture.service.rateRider(7L, 10L, rating(3.0)));
+        verify(fixture.orderMapper, never()).rateRider(any(), any(), any(), any());
+    }
+
+    @Test
+    void rateRiderRejectsConcurrentDuplicateSubmission() {
+        Fixture fixture = new Fixture();
+        FlashDeliveryOrder order = order(10L, "COMPLETED");
+        order.setRiderId(8L);
+        when(fixture.orderMapper.selectById(10L)).thenReturn(order);
+        when(fixture.orderMapper.rateRider(eq(10L), eq(7L), eq(4.5), any(Date.class))).thenReturn(0);
+
+        assertThrows(ServiceException.class, () -> fixture.service.rateRider(7L, 10L, rating(4.5)));
+    }
+
+    @Test
+    void rateRiderRejectsMissingOrOutOfRangeStars() {
+        Fixture fixture = new Fixture();
+
+        assertThrows(ServiceException.class, () -> fixture.service.rateRider(7L, 10L, null));
+        assertThrows(ServiceException.class, () -> fixture.service.rateRider(7L, 10L, rating(null)));
+        assertThrows(ServiceException.class, () -> fixture.service.rateRider(7L, 10L, rating(0.5)));
+        assertThrows(ServiceException.class, () -> fixture.service.rateRider(7L, 10L, rating(5.5)));
+
+        verify(fixture.orderMapper, never()).selectById(any());
+        verify(fixture.orderMapper, never()).rateRider(any(), any(), any(), any());
+    }
+
+    @Test
+    void userDetailReturnsRiderStars() {
+        Fixture fixture = new Fixture();
+        FlashDeliveryOrder order = order(10L, "COMPLETED");
+        order.setRiderId(8L);
+        order.setRiderStars(4.5);
+        when(fixture.orderMapper.selectById(10L)).thenReturn(order);
+        when(fixture.imageMapper.selectList(any(Wrapper.class))).thenReturn(List.of());
+
+        var view = fixture.service.userDetail(7L, 10L);
+
+        assertEquals(4.5, view.getRiderStars());
+    }
+
+    @Test
+    void userDetailRiderCarriesFlashAverageStars() {
+        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.setNickName("闪送骑手");
+        when(fixture.userMapper.selectInfoUserByUserId(8L)).thenReturn(rider);
+        when(fixture.orderMapper.selectRiderAverageStars(8L)).thenReturn(4.8);
+        when(fixture.imageMapper.selectList(any(Wrapper.class))).thenReturn(List.of());
+
+        var view = fixture.service.userDetail(7L, 10L);
+
+        assertEquals(4.8, view.getRider().getStar());
+    }
+
+    @Test
+    void userDetailRiderFallsBackToDefaultStarsWithoutRatings() {
+        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);
+        when(fixture.userMapper.selectInfoUserByUserId(8L)).thenReturn(rider);
+        when(fixture.orderMapper.selectRiderAverageStars(8L)).thenReturn(null);
+        when(fixture.imageMapper.selectList(any(Wrapper.class))).thenReturn(List.of());
+
+        var view = fixture.service.userDetail(7L, 10L);
+
+        assertEquals(4.5, view.getRider().getStar());
+    }
+
+    @Test
+    void userDetailByOrderNoReturnsParticipantDetail() {
+        Fixture fixture = new Fixture();
+        FlashDeliveryOrder order = order(10L, "COMPLETED");
+        order.setRiderId(8L);
+        order.setRiderStars(4.5);
+        when(fixture.orderMapper.selectOne(any(Wrapper.class))).thenReturn(order);
+        InfoUser rider = new InfoUser();
+        rider.setUserId(8L);
+        when(fixture.userMapper.selectInfoUserByUserId(8L)).thenReturn(rider);
+        when(fixture.imageMapper.selectList(any(Wrapper.class))).thenReturn(List.of());
+
+        var view = fixture.service.userDetailByOrderNo(7L, "FL-20260813-021");
+
+        assertEquals(10L, view.getId());
+        assertEquals(4.5, view.getRiderStars());
+    }
+
+    @Test
+    void userDetailByOrderNoRejectsNonParticipantAndUnknownOrderNo() {
+        Fixture fixture = new Fixture();
+        FlashDeliveryOrder order = order(10L, "COMPLETED");
+        order.setReceiverUserId(null);
+        when(fixture.orderMapper.selectOne(any(Wrapper.class))).thenReturn(order);
+        assertThrows(ServiceException.class,
+                () -> fixture.service.userDetailByOrderNo(88L, "FL-20260813-021"));
+
+        when(fixture.orderMapper.selectOne(any(Wrapper.class))).thenReturn(null);
+        assertThrows(ServiceException.class,
+                () -> fixture.service.userDetailByOrderNo(7L, "FL-UNKNOWN"));
+    }
+
+    @Test
+    void riderDetailByOrderNoReturnsAssignedOrderDetail() {
+        Fixture fixture = new Fixture();
+        rider(fixture, 8L);
+        FlashDeliveryOrder order = order(10L, "ACCEPTED");
+        order.setRiderId(8L);
+        when(fixture.orderMapper.selectOne(any(Wrapper.class))).thenReturn(order);
+        when(fixture.userMapper.selectInfoUserByUserId(7L)).thenReturn(new InfoUser());
+        when(fixture.imageMapper.selectList(any(Wrapper.class))).thenReturn(List.of());
+
+        var view = fixture.service.riderDetailByOrderNo(8L, "FL-20260813-021");
+
+        assertEquals(10L, view.getId());
+        assertNotNull(view.getUser());
+    }
+
+    @Test
+    void riderDetailByOrderNoRejectsUnknownOrderNo() {
+        Fixture fixture = new Fixture();
+        rider(fixture, 8L);
+        when(fixture.orderMapper.selectOne(any(Wrapper.class))).thenReturn(null);
+
+        assertThrows(ServiceException.class,
+                () -> fixture.service.riderDetailByOrderNo(8L, "FL-UNKNOWN"));
+    }
+
     @Test
     @Test
     void orderWithoutCreationTimeReceiverBindingIsNotClaimedLater() {
     void orderWithoutCreationTimeReceiverBindingIsNotClaimedLater() {
         Fixture fixture = new Fixture();
         Fixture fixture = new Fixture();
@@ -637,6 +875,22 @@ class FlashDeliveryApplicationServiceTest {
         assertThrows(RuntimeException.class, () -> fixture.service.accept(8L, 10L));
         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
     @Test
     void flashAcceptUsesRiderLockAndChecksAcceptancePolicyBeforeAtomicUpdate() {
     void flashAcceptUsesRiderLockAndChecksAcceptancePolicyBeforeAtomicUpdate() {
         Fixture fixture = new Fixture();
         Fixture fixture = new Fixture();
@@ -668,6 +922,212 @@ class FlashDeliveryApplicationServiceTest {
         verify(fixture.orderMapper, never()).accept(any(), any(), any());
         verify(fixture.orderMapper, never()).accept(any(), any(), any());
     }
     }
 
 
+    @Test
+    void acceptSuccessNotifiesSenderOfOrder() {
+        Fixture fixture = new Fixture();
+        rider(fixture, 8L);
+        FlashDeliveryOrder waiting = order(10L, "WAITING_ACCEPTANCE");
+        FlashDeliveryOrder accepted = order(10L, ACCEPTED);
+        accepted.setRiderId(8L);
+        when(fixture.orderMapper.selectById(10L)).thenReturn(waiting, accepted);
+        when(fixture.orderMapper.accept(eq(10L), eq(8L), any(Date.class))).thenReturn(1);
+
+        fixture.service.accept(8L, 10L);
+
+        verify(fixture.notificationService).notifyAccepted(waiting);
+    }
+
+    @Test
+    void acceptFailureDoesNotTriggerNotification() {
+        Fixture fixture = new Fixture();
+        rider(fixture, 8L);
+        when(fixture.orderMapper.selectById(10L)).thenReturn(order(10L, "WAITING_ACCEPTANCE"));
+        when(fixture.orderMapper.accept(eq(10L), eq(8L), any(Date.class))).thenReturn(0);
+
+        assertThrows(ServiceException.class, () -> fixture.service.accept(8L, 10L));
+
+        verify(fixture.notificationService, never()).notifyAccepted(any());
+    }
+
+    @Test
+    void pickupSuccessNotifiesSenderOfOrder() {
+        Fixture fixture = new Fixture();
+        rider(fixture, 8L);
+        FlashDeliveryOrder accepted = order(10L, ACCEPTED);
+        accepted.setRiderId(8L);
+        when(fixture.orderMapper.selectById(10L)).thenReturn(accepted);
+        // 逐站取件走 updateFulfilment 条件更新而非 transitionByRider。
+        when(fixture.orderMapper.updateFulfilment(any())).thenReturn(1);
+        FlashDeliveryProofRequest request = new FlashDeliveryProofRequest();
+        request.setImageUrls(List.of("https://example.com/pickup.jpg"));
+
+        fixture.service.pickup(8L, 10L, request);
+
+        verify(fixture.notificationService).notifyPickedUp(accepted);
+    }
+
+    @Test
+    void deliverSuccessNotifiesSenderOfOrder() {
+        Fixture fixture = new Fixture();
+        rider(fixture, 8L);
+        FlashDeliveryOrder pickedUp = order(10L, "PICKED_UP");
+        pickedUp.setRiderId(8L);
+        when(fixture.orderMapper.selectById(10L)).thenReturn(pickedUp);
+        when(fixture.orderMapper.transitionByRider(eq(10L), eq(8L), eq("PICKED_UP"),
+                eq("DELIVERED"), eq("delivered_at"), any())).thenReturn(1);
+        FlashDeliveryDeliverRequest request = new FlashDeliveryDeliverRequest();
+        request.setPinCode("4821");
+        request.setImageUrls(List.of("https://example.com/delivery.jpg"));
+
+        fixture.service.deliver(8L, 10L, request);
+
+        verify(fixture.notificationService).notifyDelivered(pickedUp);
+    }
+
+    @Test
+    void proofTransitionFailureDoesNotTriggerNotification() {
+        Fixture fixture = new Fixture();
+        rider(fixture, 8L);
+        FlashDeliveryOrder accepted = order(10L, ACCEPTED);
+        accepted.setRiderId(8L);
+        when(fixture.orderMapper.selectById(10L)).thenReturn(accepted);
+        when(fixture.orderMapper.transitionByRider(eq(10L), eq(8L), eq("ACCEPTED"),
+                eq("PICKED_UP"), eq("picked_up_at"), any())).thenReturn(0);
+        FlashDeliveryProofRequest request = new FlashDeliveryProofRequest();
+        request.setImageUrls(List.of("https://example.com/pickup.jpg"));
+
+        assertThrows(ServiceException.class, () -> fixture.service.pickup(8L, 10L, request));
+
+        verify(fixture.notificationService, never()).notifyPickedUp(any());
+        verify(fixture.notificationService, never()).notifyDelivered(any());
+    }
+
+    @Test
+    void userCancelSuccessNotifiesAssignedRider() {
+        Fixture fixture = new Fixture();
+        FlashDeliveryOrder accepted = order(10L, ACCEPTED);
+        accepted.setRiderId(9L);
+        when(fixture.orderMapper.selectById(10L)).thenReturn(accepted);
+        when(fixture.orderMapper.cancel(eq(10L), eq("ACCEPTED"), eq("USER"), eq(7L),
+                anyString(), any(Date.class))).thenReturn(1);
+
+        fixture.service.userCancel(7L, 10L, reason("改行程"));
+
+        verify(fixture.notificationService).notifyCancelled(accepted);
+    }
+
+    @Test
+    void adminCancelSuccessNotifiesAssignedRider() {
+        Fixture fixture = new Fixture();
+        FlashDeliveryOrder pickedUp = order(10L, "PICKED_UP");
+        pickedUp.setRiderId(9L);
+        when(fixture.orderMapper.selectById(10L)).thenReturn(pickedUp);
+        // 平台异常取消先写介入审计,再走条件取消。
+        when(fixture.orderMapper.update(any(), any())).thenReturn(1);
+        when(fixture.orderMapper.cancel(eq(10L), eq("PICKED_UP"), eq("ADMIN"), eq(1L),
+                anyString(), any(Date.class))).thenReturn(1);
+        FlashDeliveryReasonRequest request = reason("异常收口");
+        request.setProblemStopId(101L);
+        request.setHandlingResult("平台介入收口");
+        // 已取货订单必须记录物品去向和交接凭证。
+        request.setPickedGoodsDisposition("已取物品交回寄件人");
+        request.setHandoffImageUrls(List.of("https://example.com/handoff.jpg"));
+
+        fixture.service.adminCancel(1L, 10L, request);
+
+        verify(fixture.notificationService).notifyCancelled(pickedUp);
+    }
+
+    @Test
+    void cancelFailureDoesNotTriggerNotification() {
+        Fixture fixture = new Fixture();
+        FlashDeliveryOrder accepted = order(10L, ACCEPTED);
+        accepted.setRiderId(9L);
+        when(fixture.orderMapper.selectById(10L)).thenReturn(accepted);
+        when(fixture.orderMapper.cancel(eq(10L), eq("ACCEPTED"), eq("USER"), eq(7L),
+                anyString(), any(Date.class))).thenReturn(0);
+
+        assertThrows(ServiceException.class, () -> fixture.service.userCancel(7L, 10L, reason("改行程")));
+
+        verify(fixture.notificationService, never()).notifyCancelled(any());
+    }
+
+    @Test
+    void excludedEventsNeverTriggerNotification() {
+        // 加小费、完成、过期预约自动取消三类事件不产生任何推送。
+        Fixture fixture = new Fixture();
+        editableOrder(fixture);
+        when(fixture.orderMapper.updateWaitingTip(any(), eq(2))).thenReturn(1);
+        fixture.service.addTip(7L, 10L, tipRequest(20L, 2));
+
+        // 合并后的 complete 要求订单已送达且全部取货站完成。
+        when(fixture.orderMapper.selectById(10L)).thenReturn(order(10L, "DELIVERED"));
+        when(fixture.orderMapper.transitionByStatus(eq(10L), eq("DELIVERED"), eq("COMPLETED"),
+                eq("completed_at"), any(Date.class))).thenReturn(1);
+        assertTrue(fixture.service.complete(1L, "ADMIN", 10L));
+
+        when(fixture.orderMapper.cancelExpiredScheduled(eq(10L), any(Date.class))).thenReturn(1);
+        assertTrue(fixture.service.cancelExpiredScheduledOrder(10L, new Date()));
+
+        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);
+        return request;
+    }
+
     @Test
     @Test
     void oversizedProofUrlIsRejectedBeforeStateTransition() {
     void oversizedProofUrlIsRejectedBeforeStateTransition() {
         Fixture fixture = new Fixture();
         Fixture fixture = new Fixture();
@@ -1043,6 +1503,18 @@ class FlashDeliveryApplicationServiceTest {
         return request;
         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) {
     private FlashDeliveryAddressRequest address(String name, String phone, String lat, String lon) {
         FlashDeliveryAddressRequest address = new FlashDeliveryAddressRequest();
         FlashDeliveryAddressRequest address = new FlashDeliveryAddressRequest();
         address.setName(name);
         address.setName(name);
@@ -1111,6 +1583,13 @@ class FlashDeliveryApplicationServiceTest {
         when(fixture.userMapper.selectInfoUserByUserId(riderId)).thenReturn(rider);
         when(fixture.userMapper.selectInfoUserByUserId(riderId)).thenReturn(rider);
     }
     }
 
 
+    private com.ruoyi.app.flashdelivery.dto.FlashDeliveryRatingRequest rating(Double stars) {
+        com.ruoyi.app.flashdelivery.dto.FlashDeliveryRatingRequest request =
+                new com.ruoyi.app.flashdelivery.dto.FlashDeliveryRatingRequest();
+        request.setStars(stars);
+        return request;
+    }
+
     private FlashDeliveryOrder order(Long id, String status) {
     private FlashDeliveryOrder order(Long id, String status) {
         FlashDeliveryOrder order = new FlashDeliveryOrder();
         FlashDeliveryOrder order = new FlashDeliveryOrder();
         order.setId(id);
         order.setId(id);
@@ -1120,6 +1599,7 @@ class FlashDeliveryApplicationServiceTest {
         order.setServiceType("HELP_SEND");
         order.setServiceType("HELP_SEND");
         order.setDeliveryType("NORMAL");
         order.setDeliveryType("NORMAL");
         order.setStatus(status);
         order.setStatus(status);
+        order.setIsDisplay(true);
         order.setPackageType("DOCUMENT");
         order.setPackageType("DOCUMENT");
         order.setQuantity(1);
         order.setQuantity(1);
         order.setWeightRange("UP_TO_5_KG");
         order.setWeightRange("UP_TO_5_KG");
@@ -1478,10 +1958,11 @@ class FlashDeliveryApplicationServiceTest {
         final RiderDeliveryAcceptanceService acceptanceService = mock(RiderDeliveryAcceptanceService.class);
         final RiderDeliveryAcceptanceService acceptanceService = mock(RiderDeliveryAcceptanceService.class);
         final com.ruoyi.system.service.PaymentMethodGateService paymentMethodGateService =
         final com.ruoyi.system.service.PaymentMethodGateService paymentMethodGateService =
                 mock(com.ruoyi.system.service.PaymentMethodGateService.class);
                 mock(com.ruoyi.system.service.PaymentMethodGateService.class);
+        final FlashDeliveryNotificationService notificationService = mock(FlashDeliveryNotificationService.class);
         final FlashDeliveryApplicationService service = new FlashDeliveryApplicationService(
         final FlashDeliveryApplicationService service = new FlashDeliveryApplicationService(
                 orderMapper, pricingMapper, imageMapper, logMapper, userMapper,
                 orderMapper, pricingMapper, imageMapper, logMapper, userMapper,
                 routeService, new FlashDeliveryPricingCalculator(), lockService, acceptanceService, stopMapper, adjustmentMapper,
                 routeService, new FlashDeliveryPricingCalculator(), lockService, acceptanceService, stopMapper, adjustmentMapper,
-                paymentMethodGateService);
+                paymentMethodGateService, notificationService);
 
 
         Fixture() {
         Fixture() {
             when(paymentMethodGateService.listAvailable(any(), any(), any())).thenReturn(List.of(
             when(paymentMethodGateService.listAvailable(any(), any(), any())).thenReturn(List.of(

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

@@ -34,6 +34,8 @@ class FlashDeliveryI18nContractTest {
             "flash.delivery.rider.exclusive.conflict",
             "flash.delivery.rider.exclusive.conflict",
             "rider.accept.offline", "rider.accept.food.limit.reached",
             "rider.accept.offline", "rider.accept.food.limit.reached",
             "rider.accept.flash.limit.reached", "rider.accept.total.limit.reached",
             "rider.accept.flash.limit.reached", "rider.accept.total.limit.reached",
+            "flash.delivery.schedule.not.open",
+            "no.message.push.new.food.order", "no.message.push.new.flash.order",
             "address.access.denied", "address.data.invalid");
             "address.access.denied", "address.data.invalid");
 
 
     @Test
     @Test

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

@@ -415,13 +415,16 @@ class FlashDeliveryMultiPickupTest {
         final FlashDeliveryRouteService routes = mock(FlashDeliveryRouteService.class);
         final FlashDeliveryRouteService routes = mock(FlashDeliveryRouteService.class);
         final FlashDeliveryApplicationService service = new FlashDeliveryApplicationService(orders, prices, images, logs, users,
         final FlashDeliveryApplicationService service = new FlashDeliveryApplicationService(orders, prices, images, logs, users,
                 routes, new FlashDeliveryPricingCalculator(), mock(RiderDeliveryLockService.class), mock(RiderDeliveryAcceptanceService.class),
                 routes, new FlashDeliveryPricingCalculator(), mock(RiderDeliveryLockService.class), mock(RiderDeliveryAcceptanceService.class),
-                stops, adjustments, mock(com.ruoyi.system.service.PaymentMethodGateService.class));
+                stops, adjustments, mock(com.ruoyi.system.service.PaymentMethodGateService.class),
+                mock(FlashDeliveryNotificationService.class));
         final FlashDeliveryOrder order = new FlashDeliveryOrder();
         final FlashDeliveryOrder order = new FlashDeliveryOrder();
         final List<FlashDeliveryOrderStop> rows = new ArrayList<>(List.of(stop(11L, 0, "PICKUP"), stop(12L, 1, "PICKUP"), stop(13L, 2, "DELIVERY")));
         final List<FlashDeliveryOrderStop> rows = new ArrayList<>(List.of(stop(11L, 0, "PICKUP"), stop(12L, 1, "PICKUP"), stop(13L, 2, "DELIVERY")));
         final Map<String, FlashDeliveryTargetAdjustment> records = new HashMap<>();
         final Map<String, FlashDeliveryTargetAdjustment> records = new HashMap<>();
         Fixture() {
         Fixture() {
             order.setId(10L); order.setUserId(7L); order.setRiderId(8L); order.setStatus("ACCEPTED");
             order.setId(10L); order.setUserId(7L); order.setRiderId(8L); order.setStatus("ACCEPTED");
             order.setVersion(0); order.setPickupStopCount(2); order.setPickedUpStopCount(0); order.setAmount(100L); order.setDeliveryMode("NOW");
             order.setVersion(0); order.setPickupStopCount(2); order.setPickedUpStopCount(0); order.setAmount(100L); order.setDeliveryMode("NOW");
+            // is_display 数据库默认 1,立即单始终向骑手开放。
+            order.setIsDisplay(true);
             InfoUser rider = new InfoUser(); rider.setUserId(8L); rider.setUserType("2"); rider.setDeliveryType("FLASH");
             InfoUser rider = new InfoUser(); rider.setUserId(8L); rider.setUserType("2"); rider.setDeliveryType("FLASH");
             when(users.selectInfoUserByUserId(anyLong())).thenReturn(rider);
             when(users.selectInfoUserByUserId(anyLong())).thenReturn(rider);
             when(orders.selectById(10L)).thenReturn(order); when(orders.selectByIdForUpdate(10L)).thenReturn(order);
             when(orders.selectById(10L)).thenReturn(order); when(orders.selectByIdForUpdate(10L)).thenReturn(order);

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

@@ -0,0 +1,161 @@
+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;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.ArgumentMatchers.same;
+import static org.mockito.Mockito.doNothing;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.verify;
+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);
+        FlashDeliveryNotificationService service = spy(new FlashDeliveryNotificationService(
+                userMapper, mock(PushEventService.class)));
+        doNothing().when(service).sendToUser(any(), any(), anyString(), anyString());
+        InfoUser sender = new InfoUser();
+        sender.setUserId(7L);
+        FlashDeliveryOrder order = order();
+        when(userMapper.selectById(7L)).thenReturn(sender);
+
+        service.notifyAccepted(order);
+
+        verify(service).sendToUser(same(sender), same(order), eq("ACCEPTED"),
+                eq("no.message.push.delivery.personnel.receiving.order"));
+    }
+
+    @Test
+    void missingSenderSkipsNotification() {
+        InfoUserMapper userMapper = mock(InfoUserMapper.class);
+        FlashDeliveryNotificationService service = spy(new FlashDeliveryNotificationService(
+                userMapper, mock(PushEventService.class)));
+        when(userMapper.selectById(7L)).thenReturn(null);
+
+        service.notifyAccepted(order());
+
+        verify(service, never()).sendToUser(any(), any(), anyString(), anyString());
+    }
+
+    @Test
+    void pickedUpNotificationRoutesToSender() {
+        InfoUserMapper userMapper = mock(InfoUserMapper.class);
+        FlashDeliveryNotificationService service = spy(new FlashDeliveryNotificationService(
+                userMapper, mock(PushEventService.class)));
+        doNothing().when(service).sendToUser(any(), any(), anyString(), anyString());
+        InfoUser sender = new InfoUser();
+        sender.setUserId(7L);
+        FlashDeliveryOrder order = order();
+        when(userMapper.selectById(7L)).thenReturn(sender);
+
+        service.notifyPickedUp(order);
+
+        verify(service).sendToUser(same(sender), same(order), eq("PICKED_UP"),
+                eq("no.message.push.delivery.personnel.qspsz.order"));
+    }
+
+    @Test
+    void deliveredNotificationRoutesToSender() {
+        InfoUserMapper userMapper = mock(InfoUserMapper.class);
+        FlashDeliveryNotificationService service = spy(new FlashDeliveryNotificationService(
+                userMapper, mock(PushEventService.class)));
+        doNothing().when(service).sendToUser(any(), any(), anyString(), anyString());
+        InfoUser sender = new InfoUser();
+        sender.setUserId(7L);
+        FlashDeliveryOrder order = order();
+        when(userMapper.selectById(7L)).thenReturn(sender);
+
+        service.notifyDelivered(order);
+
+        verify(service).sendToUser(same(sender), same(order), eq("DELIVERED"),
+                eq("no.message.push.delivery.personnel.qsysd.order"));
+    }
+
+    @Test
+    void cancelledNotificationRoutesToAssignedRider() {
+        InfoUserMapper userMapper = mock(InfoUserMapper.class);
+        FlashDeliveryNotificationService service = spy(new FlashDeliveryNotificationService(
+                userMapper, mock(PushEventService.class)));
+        doNothing().when(service).sendToRider(any(), any(), anyString(), anyString());
+        InfoUser rider = new InfoUser();
+        rider.setUserId(9L);
+        FlashDeliveryOrder order = order();
+        order.setRiderId(9L);
+        when(userMapper.selectById(9L)).thenReturn(rider);
+
+        service.notifyCancelled(order);
+
+        verify(service).sendToRider(same(rider), same(order), eq("CANCELLED"),
+                eq("no.message.push.order.cancelled"));
+    }
+
+    @Test
+    void cancelledNotificationSkipsWhenNoRiderAssigned() {
+        InfoUserMapper userMapper = mock(InfoUserMapper.class);
+        FlashDeliveryNotificationService service = spy(new FlashDeliveryNotificationService(
+                userMapper, mock(PushEventService.class)));
+
+        service.notifyCancelled(order());
+
+        verify(service, never()).sendToRider(any(), any(), anyString(), anyString());
+        verify(service, never()).sendToUser(any(), any(), anyString(), anyString());
+    }
+
+    private static FlashDeliveryOrder order() {
+        FlashDeliveryOrder order = new FlashDeliveryOrder();
+        order.setId(10L);
+        order.setOrderNo("FL-20260813-021");
+        order.setUserId(7L);
+        return order;
+    }
+}

+ 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.Date;
 import java.util.List;
 import java.util.List;
+import java.util.Arrays;
 
 
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.anyInt;
 import static org.mockito.ArgumentMatchers.anyInt;
@@ -61,4 +62,21 @@ class FlashDeliveryAutoCompleteTaskTest {
 
 
         verify(service).complete(null, "SYSTEM", 2L);
         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());
+        }
+    }
 }
 }

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

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

@@ -33,6 +33,8 @@ public class FlashDeliveryOrder {
     private Integer vehicleType;
     private Integer vehicleType;
     /** 订单状态,取值见 FlashDeliveryStatus。 */
     /** 订单状态,取值见 FlashDeliveryStatus。 */
     private String status;
     private String status;
+    /** 是否已向骑手开放展示;立即单创建即开放,预约单到达时间窗后开放。 */
+    private Boolean isDisplay;
     /** 用户申报的包裹类别和重量范围。 */
     /** 用户申报的包裹类别和重量范围。 */
     private String packageType;
     private String packageType;
     private Integer quantity;
     private Integer quantity;
@@ -124,6 +126,9 @@ public class FlashDeliveryOrder {
     private Date completedAt;
     private Date completedAt;
     private Date cancelledAt;
     private Date cancelledAt;
 
 
+    /** 寄件人对骑手的评分 1-5,未评为 NULL;仅完成态订单可评一次。 */
+    private Double riderStars;
+
     /** 取消审计信息,操作人类型可为 USER 或 ADMIN。 */
     /** 取消审计信息,操作人类型可为 USER 或 ADMIN。 */
     private String cancelReason;
     private String cancelReason;
     private String cancelOperatorType;
     private String cancelOperatorType;

+ 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') "
             + "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}")
             + "order by juli asc limit #{num}")
     public List<RiderPosition> getAcceptRiderList(@Param("longitude") BigDecimal longitude, @Param("latitude")BigDecimal latitude,@Param("num")int 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);
 }
 }

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

@@ -39,6 +39,10 @@ public interface FlashDeliveryOrderMapper extends BaseMapper<FlashDeliveryOrder>
                                 @Param("expectedStatus") String expectedStatus,
                                 @Param("expectedStatus") String expectedStatus,
                                 @Param("nextStatus") String nextStatus, @Param("now") Date now);
                                 @Param("nextStatus") String nextStatus, @Param("now") Date now);
 
 
+    /** 寄件人首次评价骑手:仅完成态、已接骑手且从未评价时更新成功,防并发重复提交。 */
+    int rateRider(@Param("id") Long id, @Param("userId") Long userId,
+                  @Param("riderStars") Double riderStars, @Param("now") Date now);
+
     /** 供平台和系统任务按预期状态执行条件更新。 */
     /** 供平台和系统任务按预期状态执行条件更新。 */
     int transitionByStatus(@Param("id") Long id, @Param("expectedStatus") String expectedStatus,
     int transitionByStatus(@Param("id") Long id, @Param("expectedStatus") String expectedStatus,
                            @Param("nextStatus") String nextStatus,
                            @Param("nextStatus") String nextStatus,
@@ -57,4 +61,20 @@ public interface FlashDeliveryOrderMapper extends BaseMapper<FlashDeliveryOrder>
 
 
     /** 订单行锁内更新进度和目标,显式写入空目标以恢复默认顺序。 */
     /** 订单行锁内更新进度和目标,显式写入空目标以恢复默认顺序。 */
     int updateFulfilment(@Param("order") FlashDeliveryOrder order);
     int updateFulfilment(@Param("order") FlashDeliveryOrder order);
+
+    /** 查询预约取件时段结束后仍未被骑手接取的订单,供系统任务分批取消。 */
+    List<FlashDeliveryOrder> selectExpiredScheduled(@Param("now") Date now, @Param("limit") int limit);
+
+    /** 原子取消过期预约单;抢单或人工取消已先完成时返回 0,避免覆盖正常状态。 */
+    int cancelExpiredScheduled(@Param("id") Long id, @Param("now") Date now);
+
+    /** 查询已进入预约窗口但尚未向骑手开放的订单。 */
+    List<FlashDeliveryOrder> selectScheduledAvailabilityCandidates(@Param("now") Date now,
+                                                                   @Param("limit") int limit);
+
+    /** 骑手闪送平均分:仅统计已评价订单;无人评价时返回 null 由服务层回退默认分。 */
+    Double selectRiderAverageStars(@Param("riderId") Long riderId);
+
+    /** 原子开放预约单;并发任务、取消或抢单已先完成时返回 0。 */
+    int openScheduledForRiders(@Param("id") Long id, @Param("now") Date now);
 }
 }

+ 23 - 2
ruoyi-system/src/main/java/com/ruoyi/system/utils/AuthAspect.java

@@ -4,6 +4,8 @@ import com.ruoyi.common.core.domain.AjaxResult;
 import com.ruoyi.common.core.redis.RedisCache;
 import com.ruoyi.common.core.redis.RedisCache;
 import com.ruoyi.common.exception.ServiceException;
 import com.ruoyi.common.exception.ServiceException;
 import com.ruoyi.common.utils.MessageUtils;
 import com.ruoyi.common.utils.MessageUtils;
+import com.ruoyi.system.domain.InfoUser;
+import com.ruoyi.system.mapper.InfoUserMapper;
 import org.aspectj.lang.ProceedingJoinPoint;
 import org.aspectj.lang.ProceedingJoinPoint;
 import org.aspectj.lang.annotation.Around;
 import org.aspectj.lang.annotation.Around;
 import org.aspectj.lang.annotation.Aspect;
 import org.aspectj.lang.annotation.Aspect;
@@ -29,9 +31,11 @@ public class AuthAspect {
 
 
     private HttpServletRequest request;
     private HttpServletRequest request;
     private final RedisCache redisCache;
     private final RedisCache redisCache;
+    private final InfoUserMapper infoUserMapper;
 
 
-    public AuthAspect(RedisCache redisCache) {
+    public AuthAspect(RedisCache redisCache, InfoUserMapper infoUserMapper) {
         this.redisCache = redisCache;
         this.redisCache = redisCache;
+        this.infoUserMapper = infoUserMapper;
     }
     }
 
 
     @Resource
     @Resource
@@ -64,7 +68,11 @@ public class AuthAspect {
             String jti = jtiValue instanceof String ? (String) jtiValue : null;
             String jti = jtiValue instanceof String ? (String) jtiValue : null;
             if (auth.session() && (userId == null || jti == null || jti.isBlank()
             if (auth.session() && (userId == null || jti == null || jti.isBlank()
                     || !Boolean.TRUE.equals(redisCache.hasKey(jti)))) {
                     || !Boolean.TRUE.equals(redisCache.hasKey(jti)))) {
-                throw new ServiceException(MessageUtils.message("merchant.session.invalid"));
+                // Redis 会话校验只服务商家侧账号的即时撤销;普通用户和骑手没有会话撤销用途,
+                // 旧 token(jti 从未写入 Redis)放行,凭已验签的 JWT 通过即可。
+                if (!isConsumerUser(userId)) {
+                    throw new ServiceException(MessageUtils.message("merchant.session.invalid"));
+                }
             }
             }
 
 
             request.setAttribute(AuthContext.USER_ID_ATTRIBUTE, userId);
             request.setAttribute(AuthContext.USER_ID_ATTRIBUTE, userId);
@@ -73,6 +81,19 @@ public class AuthAspect {
         return joinPoint.proceed();
         return joinPoint.proceed();
     }
     }
 
 
+    /**
+     * 会话缺失时判断是否为无需商家会话的账号类型:0=普通用户、2=骑手。
+     * 商家侧类型(1/3/4/5)、用户不存在或类型未知时一律不放行。
+     */
+    private boolean isConsumerUser(Long userId) {
+        if (userId == null) {
+            return false;
+        }
+        InfoUser user = infoUserMapper.selectById(userId);
+        String userType = user == null ? null : user.getUserType();
+        return "0".equals(userType) || "2".equals(userType);
+    }
+
     private Long parseUserId(Object claim) {
     private Long parseUserId(Object claim) {
         if (!(claim instanceof String value) || value.isBlank() || "null".equals(value)) {
         if (!(claim instanceof String value) || value.isBlank() || "null".equals(value)) {
             return null;
             return null;

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

@@ -12,6 +12,7 @@
         <result property="deliveryType" column="delivery_type"/>
         <result property="deliveryType" column="delivery_type"/>
         <result property="vehicleType" column="vehicle_type"/>
         <result property="vehicleType" column="vehicle_type"/>
         <result property="status" column="status"/>
         <result property="status" column="status"/>
+        <result property="isDisplay" column="is_display"/>
         <result property="packageType" column="package_type"/>
         <result property="packageType" column="package_type"/>
         <result property="quantity" column="quantity"/>
         <result property="quantity" column="quantity"/>
         <result property="weightRange" column="weight_range"/>
         <result property="weightRange" column="weight_range"/>
@@ -67,6 +68,7 @@
         <result property="deliveredAt" column="delivered_at"/>
         <result property="deliveredAt" column="delivered_at"/>
         <result property="completedAt" column="completed_at"/>
         <result property="completedAt" column="completed_at"/>
         <result property="cancelledAt" column="cancelled_at"/>
         <result property="cancelledAt" column="cancelled_at"/>
+        <result property="riderStars" column="rider_stars"/>
         <result property="cancelReason" column="cancel_reason"/>
         <result property="cancelReason" column="cancel_reason"/>
         <result property="cancelOperatorType" column="cancel_operator_type"/>
         <result property="cancelOperatorType" column="cancel_operator_type"/>
         <result property="cancelOperatorId" column="cancel_operator_id"/>
         <result property="cancelOperatorId" column="cancel_operator_id"/>
@@ -88,8 +90,9 @@
     <update id="accept">
     <update id="accept">
         update flash_delivery_order
         update flash_delivery_order
         set rider_id=#{riderId}, status='ACCEPTED', accepted_at=#{now}, update_time=#{now}, version = version + 1
         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})
+        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>
     </update>
 
 
     <!-- 编辑与抢单竞争时,由同一订单行的原子条件更新决定谁先成功。 -->
     <!-- 编辑与抢单竞争时,由同一订单行的原子条件更新决定谁先成功。 -->
@@ -147,6 +150,20 @@
         where id=#{id} and (user_id=#{userId} or receiver_user_id = #{userId}) and status=#{expectedStatus}
         where id=#{id} and (user_id=#{userId} or receiver_user_id = #{userId}) and status=#{expectedStatus}
     </update>
     </update>
 
 
+    <!-- 评分条件更新:仅寄件人的完成态订单、已接骑手且从未评价才成功,并发重复提交返回 0。 -->
+    <update id="rateRider">
+        update flash_delivery_order
+        set rider_stars=#{riderStars}, update_time=#{now}
+        where id=#{id} and user_id=#{userId} and status='COMPLETED'
+          and rider_id is not null and rider_stars is null
+    </update>
+
+    <!-- 骑手闪送平均分只统计闪送订单内的评价,与外卖评分体系互不影响。 -->
+    <select id="selectRiderAverageStars" resultType="java.lang.Double">
+        select round(avg(rider_stars), 1) from flash_delivery_order
+        where rider_id = #{riderId} and rider_stars is not null
+    </select>
+
     <!-- 平台/系统完成同样使用预期状态条件,防止与用户确认并发重复成功。 -->
     <!-- 平台/系统完成同样使用预期状态条件,防止与用户确认并发重复成功。 -->
     <update id="transitionByStatus">
     <update id="transitionByStatus">
         update flash_delivery_order
         update flash_delivery_order
@@ -183,4 +200,40 @@
         where id = #{order.id} and rider_id = #{order.riderId}
         where id = #{order.id} and rider_id = #{order.riderId}
           and status = 'ACCEPTED' and version = #{order.version}
           and status = 'ACCEPTED' and version = #{order.version}
     </update>
     </update>
+
+    <!-- 预约时段结束仍无人接单时,由任务分批收口;只读取仍可取消的待接订单。 -->
+    <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>
+
+    <!-- 预约单只有进入取件窗口后才允许向骑手开放;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>
 </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"));
+    }
+}

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

@@ -148,6 +148,7 @@ class FlashDeliveryMapperXmlTest {
                 .collect(Collectors.toSet());
                 .collect(Collectors.toSet());
         assertTrue(mappedProperties.containsAll(Set.of(
         assertTrue(mappedProperties.containsAll(Set.of(
                 "receiverUserId",
                 "receiverUserId",
+                "isDisplay",
                 "deliveryType", "quantity", "weightRange", "specification",
                 "deliveryType", "quantity", "weightRange", "specification",
                 "pricingId", "pricingVersion", "pricingStartTime", "pricingEndTime",
                 "pricingId", "pricingVersion", "pricingStartTime", "pricingEndTime",
                 "startingDistance", "startingFare", "distance", "freight", "distanceFee",
                 "startingDistance", "startingFare", "distance", "freight", "distanceFee",
@@ -157,10 +158,36 @@ class FlashDeliveryMapperXmlTest {
         assertTrue(xml.contains("status = 'WAITING_ACCEPTANCE'"));
         assertTrue(xml.contains("status = 'WAITING_ACCEPTANCE'"));
         assertTrue(xml.contains("rider_id is null"));
         assertTrue(xml.contains("rider_id is null"));
         assertTrue(xml.contains("scheduled_pickup_start_at &lt;= #{now}"));
         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("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"));
         assertTrue(xml.contains("package_type"));
         assertTrue(xml.contains("delivery_pin_code"));
         assertTrue(xml.contains("delivery_pin_code"));
         assertTrue(xml.contains("delivered_at &lt;="));
         assertTrue(xml.contains("delivered_at &lt;="));
         assertTrue(xml.contains("version = version + 1"));
         assertTrue(xml.contains("version = version + 1"));
         assertTrue(xml.contains("receiver_user_id = #{userId}"));
         assertTrue(xml.contains("receiver_user_id = #{userId}"));
     }
     }
+
+    @Test
+    void riderAverageStarsAggregatesRatedFlashOrdersOnly() throws Exception {
+        String resource = "mapper/flash/FlashDeliveryOrderMapper.xml";
+        Configuration configuration = new Configuration();
+        configuration.getTypeAliasRegistry().registerAlias("FlashDeliveryOrder", FlashDeliveryOrder.class);
+        try (InputStream input = getClass().getClassLoader().getResourceAsStream(resource)) {
+            new XMLMapperBuilder(input, configuration, resource, configuration.getSqlFragments()).parse();
+        }
+        var statement = configuration.getMappedStatement(
+                "com.ruoyi.system.mapper.flash.FlashDeliveryOrderMapper.selectRiderAverageStars");
+        String text = statement.getBoundSql(java.util.Map.of("riderId", 8L)).getSql()
+                .replaceAll("\\s+", " ").toLowerCase();
+        assertTrue(text.contains("avg(rider_stars)"));
+        assertTrue(text.contains("rider_id = ?"));
+        assertTrue(text.contains("rider_stars is not null"));
+    }
 }
 }

+ 63 - 1
ruoyi-system/src/test/java/com/ruoyi/system/utils/AuthAspectTest.java

@@ -5,6 +5,8 @@ import com.auth0.jwt.algorithms.Algorithm;
 import com.ruoyi.common.core.redis.RedisCache;
 import com.ruoyi.common.core.redis.RedisCache;
 import com.ruoyi.common.exception.ServiceException;
 import com.ruoyi.common.exception.ServiceException;
 import com.ruoyi.common.utils.MessageUtils;
 import com.ruoyi.common.utils.MessageUtils;
+import com.ruoyi.system.domain.InfoUser;
+import com.ruoyi.system.mapper.InfoUserMapper;
 import jakarta.servlet.http.HttpServletRequest;
 import jakarta.servlet.http.HttpServletRequest;
 import org.aspectj.lang.ProceedingJoinPoint;
 import org.aspectj.lang.ProceedingJoinPoint;
 import org.aspectj.lang.reflect.MethodSignature;
 import org.aspectj.lang.reflect.MethodSignature;
@@ -29,6 +31,7 @@ import static org.mockito.Mockito.when;
 class AuthAspectTest {
 class AuthAspectTest {
 
 
     private RedisCache redisCache;
     private RedisCache redisCache;
+    private InfoUserMapper infoUserMapper;
     private HttpServletRequest request;
     private HttpServletRequest request;
     private ProceedingJoinPoint joinPoint;
     private ProceedingJoinPoint joinPoint;
     private MethodSignature signature;
     private MethodSignature signature;
@@ -37,10 +40,11 @@ class AuthAspectTest {
     @BeforeEach
     @BeforeEach
     void setUp() {
     void setUp() {
         redisCache = mock(RedisCache.class);
         redisCache = mock(RedisCache.class);
+        infoUserMapper = mock(InfoUserMapper.class);
         request = mock(HttpServletRequest.class);
         request = mock(HttpServletRequest.class);
         joinPoint = mock(ProceedingJoinPoint.class);
         joinPoint = mock(ProceedingJoinPoint.class);
         signature = mock(MethodSignature.class);
         signature = mock(MethodSignature.class);
-        aspect = new AuthAspect(redisCache);
+        aspect = new AuthAspect(redisCache, infoUserMapper);
         aspect.setHttpServletRequest(request);
         aspect.setHttpServletRequest(request);
         when(joinPoint.getSignature()).thenReturn(signature);
         when(joinPoint.getSignature()).thenReturn(signature);
     }
     }
@@ -75,11 +79,62 @@ class AuthAspectTest {
         verify(request).setAttribute(AuthContext.JTI_ATTRIBUTE, jti);
         verify(request).setAttribute(AuthContext.JTI_ATTRIBUTE, jti);
     }
     }
 
 
+    @Test
+    void merchantSessionAuthAllowsOrdinaryUserWithoutRedisSession() throws Throwable {
+        String token = JwtUtil.token("936", "user");
+        prepareInvocation("merchantSession", token);
+        loginUser("0");
+        when(redisCache.hasKey(anyString())).thenReturn(false);
+        Object expected = new Object();
+        when(joinPoint.proceed()).thenReturn(expected);
+
+        Object actual = aspect.around(joinPoint);
+
+        assertSame(expected, actual);
+        verify(request).setAttribute(AuthContext.USER_ID_ATTRIBUTE, 936L);
+    }
+
+    @Test
+    void merchantSessionAuthAllowsRiderWithoutRedisSession() throws Throwable {
+        String token = JwtUtil.token("936", "rider");
+        prepareInvocation("merchantSession", token);
+        loginUser("2");
+        when(redisCache.hasKey(anyString())).thenReturn(false);
+        Object expected = new Object();
+        when(joinPoint.proceed()).thenReturn(expected);
+
+        Object actual = aspect.around(joinPoint);
+
+        assertSame(expected, actual);
+        verify(request).setAttribute(AuthContext.USER_ID_ATTRIBUTE, 936L);
+    }
+
     @Test
     @Test
     void merchantSessionAuthRejectsMissingRedisSessionBeforeController() throws Throwable {
     void merchantSessionAuthRejectsMissingRedisSessionBeforeController() throws Throwable {
         String token = JwtUtil.token("936", "merchant");
         String token = JwtUtil.token("936", "merchant");
         String jti = tokenJti(token);
         String jti = tokenJti(token);
         prepareInvocation("merchantSession", token);
         prepareInvocation("merchantSession", token);
+        loginUser("1");
+        when(redisCache.hasKey(jti)).thenReturn(false);
+
+        try (MockedStatic<MessageUtils> messages = mockStatic(MessageUtils.class)) {
+            messages.when(() -> MessageUtils.message("merchant.session.invalid"))
+                    .thenReturn("session invalid");
+
+            ServiceException exception = assertThrows(ServiceException.class,
+                    () -> aspect.around(joinPoint));
+
+            assertEquals("session invalid", exception.getMessage());
+            verify(joinPoint, never()).proceed();
+        }
+    }
+
+    @Test
+    void merchantSessionAuthRejectsUnknownUserWithoutRedisSession() throws Throwable {
+        String token = JwtUtil.token("936", "stranger");
+        String jti = tokenJti(token);
+        prepareInvocation("merchantSession", token);
+        when(infoUserMapper.selectById(936L)).thenReturn(null);
         when(redisCache.hasKey(jti)).thenReturn(false);
         when(redisCache.hasKey(jti)).thenReturn(false);
 
 
         try (MockedStatic<MessageUtils> messages = mockStatic(MessageUtils.class)) {
         try (MockedStatic<MessageUtils> messages = mockStatic(MessageUtils.class)) {
@@ -101,6 +156,7 @@ class AuthAspectTest {
                 .withExpiresAt(new Date(System.currentTimeMillis() + 60_000))
                 .withExpiresAt(new Date(System.currentTimeMillis() + 60_000))
                 .sign(Algorithm.HMAC256("TEST-AUTH-TOKEN"));
                 .sign(Algorithm.HMAC256("TEST-AUTH-TOKEN"));
         prepareInvocation("merchantSession", token);
         prepareInvocation("merchantSession", token);
+        loginUser("1");
 
 
         try (MockedStatic<MessageUtils> messages = mockStatic(MessageUtils.class)) {
         try (MockedStatic<MessageUtils> messages = mockStatic(MessageUtils.class)) {
             messages.when(() -> MessageUtils.message("merchant.session.invalid"))
             messages.when(() -> MessageUtils.message("merchant.session.invalid"))
@@ -121,6 +177,12 @@ class AuthAspectTest {
         when(request.getHeader("token")).thenReturn(token);
         when(request.getHeader("token")).thenReturn(token);
     }
     }
 
 
+    private void loginUser(String userType) {
+        InfoUser user = new InfoUser();
+        user.setUserType(userType);
+        when(infoUserMapper.selectById(936L)).thenReturn(user);
+    }
+
     private String tokenJti(String token) {
     private String tokenJti(String token) {
         Map<String, Object> claims = JwtUtil.verifyToken(token);
         Map<String, Object> claims = JwtUtil.verifyToken(token);
         return (String) claims.get("jti");
         return (String) claims.get("jti");

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

@@ -13,6 +13,8 @@
 - `vehicleType`:`1`=机车、`2`=轿车;报价和创建均必填并必须一致。历史订单、骑手资料和运价配置的空值按机车处理。
 - `vehicleType`:`1`=机车、`2`=轿车;报价和创建均必填并必须一致。历史订单、骑手资料和运价配置的空值按机车处理。
 - 路线距离和 `estimatedDurationSeconds` 按 `vehicleType` 获取:机车使用 Google Routes 的 `TWO_WHEELER`,轿车使用 `DRIVE`。地图路线不可用时,`distanceSource=STRAIGHT_LINE`,`estimatedDurationSeconds` 仍必返:机车以 25 km/h、轿车以 30 km/h 的保守速度按直线距离估算,向上取整且最少 60 秒;该时长不参与金额计算。两轮路线是 Google Beta 能力;本期不修改用户 App 的提示文案。
 - 路线距离和 `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 分钟且开始时间不晚于三天后。
 - `deliveryMode`:`NOW`、`SCHEDULED`。预约起止时间使用 ISO 日期时间,时段固定 30 分钟且开始时间不晚于三天后。
+
+预约单只在 `scheduledPickupStartAt <= 当前时间 < scheduledPickupEndAt` 的窗口内向骑手开放;结束时仍为待接单的订单由 Quartz 任务自动取消,取消原因代码为 `SCHEDULE_EXPIRED`。
 - `status`:`WAITING_ACCEPTANCE`、`ACCEPTED`、`PICKED_UP`、`DELIVERED`、`COMPLETED`、`CANCELLED`。
 - `status`:`WAITING_ACCEPTANCE`、`ACCEPTED`、`PICKED_UP`、`DELIVERED`、`COMPLETED`、`CANCELLED`。
 
 
 ## 地址对象
 ## 地址对象
@@ -206,3 +208,9 @@
 - 任一取货点已取时,非空 `pickedGoodsDisposition` 及至少一张 `handoffImageUrls` 必填;先记录货物去向和交接,再关闭订单。
 - 任一取货点已取时,非空 `pickedGoodsDisposition` 及至少一张 `handoffImageUrls` 必填;先记录货物去向和交接,再关闭订单。
 - 原因、处理结果及物品去向各最多 500 字符;交接图片最多 9 张,单 URL 最多 1000 字符。问题站可为取货站或拒收涉及的收货站。
 - 原因、处理结果及物品去向各最多 500 字符;交接图片最多 9 张,单 URL 最多 1000 字符。问题站可为取货站或拒收涉及的收货站。
 - 交接凭证使用 `proofType=HANDOFF`,平台详情单独展示;不代表完成配送,也不触发支付退款。
 - 交接凭证使用 `proofType=HANDOFF`,平台详情单独展示;不代表完成配送,也不触发支付退款。
+
+## 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-039**:本功能的数据库变更只能记录在 `updatesql/sql.md`,不得直接执行。
 - **FR-040**:订单必须支持 `NOW` 和 `SCHEDULED` 两种配送方式;预约时段固定为 30 分钟且不得晚于创建时间后三天。
 - **FR-040**:订单必须支持 `NOW` 和 `SCHEDULED` 两种配送方式;预约时段固定为 30 分钟且不得晚于创建时间后三天。
 - **FR-041**:预约订单在预约开始时间前不得出现在可抢列表,也不得被骑手通过订单 ID 提前抢取。
 - **FR-041**:预约订单在预约开始时间前不得出现在可抢列表,也不得被骑手通过订单 ID 提前抢取。
+- **FR-103**:预约订单仅能在预约开始时间(含)至结束时间(不含)之间被骑手接取;结束时仍未接单的订单必须由系统任务自动取消,列表和接单原子更新均不得允许过期订单继续接取。
 - **FR-042**:用户可选择是否启用 4 位数字交付 PIN;启用后骑手必须同时提交正确 PIN 和至少一张送达图片。
 - **FR-042**:用户可选择是否启用 4 位数字交付 PIN;启用后骑手必须同时提交正确 PIN 和至少一张送达图片。
 - **FR-043**:PIN 只对订单用户和有权限的平台管理员可见,不得返回给待接单或已接单骑手,也不得写入业务日志。
 - **FR-043**:PIN 只对订单用户和有权限的平台管理员可见,不得返回给待接单或已接单骑手,也不得写入业务日志。
 - **FR-044**:创建订单可提交 1 至 9 张可选寄件图片;凭证类型扩展为 `SENDER`、`PICKUP`、`DELIVERY` 并记录操作人类型。
 - **FR-044**:创建订单可提交 1 至 9 张可选寄件图片;凭证类型扩展为 `SENDER`、`PICKUP`、`DELIVERY` 并记录操作人类型。
@@ -225,6 +226,7 @@
 - **FR-052**:价格表单必须校验时间范围有效、全部时段互不重叠、距离为正数、起送价格和计价金额为正整数、加急比例和最低加急费为非负数;配置保存即生效,删除后立即不可用于报价,操作成功后必须重新读取服务端列表。
 - **FR-052**:价格表单必须校验时间范围有效、全部时段互不重叠、距离为正数、起送价格和计价金额为正整数、加急比例和最低加急费为非负数;配置保存即生效,删除后立即不可用于报价,操作成功后必须重新读取服务端列表。
 - **FR-053**:订单页面必须按服务端分页,并支持状态、服务类型、订单号、用户 ID 和骑手 ID 组合筛选及一键重置。
 - **FR-053**:订单页面必须按服务端分页,并支持状态、服务类型、订单号、用户 ID 和骑手 ID 组合筛选及一键重置。
 - **FR-054**:平台订单详情必须展示订单概况、取送地址、路线与费用、预约与 PIN、骑手 ID 与履约时间、三类图片凭证及状态日志;不得要求管理员从列表字段拼接详情。
 - **FR-054**:平台订单详情必须展示订单概况、取送地址、路线与费用、预约与 PIN、骑手 ID 与履约时间、三类图片凭证及状态日志;不得要求管理员从列表字段拼接详情。
+- **FR-104**:平台订单概况对预约订单必须直接展示预约取件开始及结束时间,不得只显示“预约配送”。
 - **FR-055**:平台取消必须要求非空原因和二次确认;平台完成必须二次确认。操作成功后刷新服务端数据,操作失败时保留当前页面并显示后端错误。
 - **FR-055**:平台取消必须要求非空原因和二次确认;平台完成必须二次确认。操作成功后刷新服务端数据,操作失败时保留当前页面并显示后端错误。
 - **FR-056**:平台前端新增的全部用户可见文本必须通过 Vue i18n 提供简体中文、繁体中文、英文和越南文,不得硬编码单一语言文本。
 - **FR-056**:平台前端新增的全部用户可见文本必须通过 Vue i18n 提供简体中文、繁体中文、英文和越南文,不得硬编码单一语言文本。
 - **FR-057**:平台前端必须沿用现有 Vue 2、Element UI、若依请求封装、动态菜单和 `v-hasPermi` 体系,不新增重复的状态管理或 UI 框架。
 - **FR-057**:平台前端必须沿用现有 Vue 2、Element UI、若依请求封装、动态菜单和 `v-hasPermi` 体系,不新增重复的状态管理或 UI 框架。

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

@@ -214,3 +214,9 @@
 - [x] T109 将多取货点、车型计价与路线、降级预计时长、骑手容量限制合入 `test-202609v2`,同时保留 031 支付闸门、骑手支付方式过滤与夜市自配送流转。
 - [x] T109 将多取货点、车型计价与路线、降级预计时长、骑手容量限制合入 `test-202609v2`,同时保留 031 支付闸门、骑手支付方式过滤与夜市自配送流转。
 - [x] T110 在待抢闪送任务查询中同时按骑手车型与可接受支付方式过滤;确认收款接口继续仅允许本人已送达/已完成订单首次写入收款审计。
 - [x] T110 在待抢闪送任务查询中同时按骑手车型与可接受支付方式过滤;确认收款接口继续仅允许本人已送达/已完成订单首次写入收款审计。
 - [x] T111 维持 `test-202609v2` 已确认的越南语资源删除策略,将 v1 新增的车型、容量和多取货点文案合并至其余支持语言;自配送与车型 SQL 均保留为手动迁移登记。
 - [x] T111 维持 `test-202609v2` 已确认的越南语资源删除策略,将 v1 新增的车型、容量和多取货点文案合并至其余支持语言;自配送与车型 SQL 均保留为手动迁移登记。
+
+## Phase 22:合入 test 分支交付(2026-09-17 合并)
+
+- [x] T112 【2026-09-17】预约单结束时间到达后自动取消:列表/接单限制结束边界,Quartz 每分钟以条件更新收口,平台详情概况显示预约取件时段,补 SQL、注释和定向测试;SQL 仅已登记,尚未执行。(原 test 分支 T103)
+- [x] T113 【2026-09-17】随合并引入的其余闪送增强:预约单到点开放推送(`is_display` 门控)、订单状态变更消息推送、订单完成后骑手评分与详情返回骑手平均分、用户端与骑手端按订单编号查询详情、详情返回对端完整用户资料;i18n 契约同步(沿用 `test-202609v2` 删除越南语包的策略,其余语言包补齐文案)。
+- 备注:test 分支原 T101(车型运价)、T102(共用 Google Map Key)已由 Phase 20 T105/T106 覆盖,不重复记录。

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

@@ -0,0 +1,38 @@
+# Specification Quality Checklist: 闪送订单状态变更消息推送
+
+**Purpose**: Validate specification completeness and quality before proceeding to planning
+**Created**: 2026-09-17
+**Feature**: [spec.md](../spec.md)
+
+## Content Quality
+
+- [x] No implementation details (languages, frameworks, APIs)
+- [x] Focused on user value and business needs
+- [x] Written for non-technical stakeholders
+- [x] All mandatory sections completed
+
+## Requirement Completeness
+
+- [x] No [NEEDS CLARIFICATION] markers remain
+- [x] Requirements are testable and unambiguous
+- [x] Success criteria are measurable
+- [x] Success criteria are technology-agnostic (no implementation details)
+- [x] All acceptance scenarios are defined
+- [x] Edge cases are identified
+- [x] Scope is clearly bounded
+- [x] Dependencies and assumptions identified
+
+## Feature Readiness
+
+- [x] All functional requirements have clear acceptance criteria
+- [x] User scenarios cover primary flows
+- [x] Feature meets measurable outcomes defined in Success Criteria
+- [x] No implementation details leak into specification
+
+## Notes
+
+- 校验说明:
+  - 实现细节检查:spec 仅引用业务状态名(WAITING_ACCEPTANCE 等,为产品已有订单状态术语)与"现有推送通道/消息中心机制"的存在性约束,未规定语言、框架、接口形态;具体通道选型留给 plan 阶段。
+  - 范围边界: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 为可度量指标。

+ 43 - 0
specs/028-flash-delivery-push/contracts/push.md

@@ -0,0 +1,43 @@
+# Push Payload Contract: 闪送订单状态变更消息推送
+
+**Date**: 2026-09-17 | **Spec**: [spec.md](../spec.md)
+
+本特性**不新增 HTTP 接口端点**。对外契约只有一份:推送给客户端的 payload 形状(经 uni 云函数透传给 App)。
+
+## Payload(OrderPushBodyDto JSON)
+
+```json
+{
+  "ddId": "FD7A3F9C2E1B4D5A6F7C8D9E",
+  "state": "WAITING_ACCEPTANCE",
+  "type": 0,
+  "pushType": 3
+}
+```
+
+| 字段 | 取值 | 说明 |
+|------|------|------|
+| `ddId` | 闪送 `orderNo`(`FD` + 24 位大写十六进制) | 订单标识;沿用历史字段名以兼容客户端既有解析 |
+| `state` | `WAITING_ACCEPTANCE` / `ACCEPTED` / `PICKED_UP` / `DELIVERED` / `CANCELLED` | 推送对应的目标状态(闪送状态枚举字符串,非外卖数字状态) |
+| `type` | `0`(新单)/ `-1`(状态通知) | 0 表示骑手新单提醒,-1 表示非语音状态提醒 |
+| `pushType` | `3` | **新增取值**:3=闪送订单(既有:1=外卖、2=充值)。客户端按此路由到闪送订单详情页 |
+
+## 通知正文
+
+| 事件 | 接收端 | title | content(渲染后) |
+|------|--------|-------|------------------|
+| 骑手抢单 | 用户 App | `消息`(`no.message.push.message`) | `骑手已接单,NO:{orderNo}` |
+| 骑手取件 | 用户 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` 仍分别按各自业务类型路由。
+
+## 客户端适配注意(本仓库外)
+
+- 客户端收到 `pushType=3` 的推送时,用 `ddId`(orderNo)跳转闪送订单详情;`state` 为闪送状态枚举字符串,与外卖的数字 `state` 语义不同,勿混用。
+- 客户端适配不在本仓库范围(spec Assumptions)。

+ 54 - 0
specs/028-flash-delivery-push/data-model.md

@@ -0,0 +1,54 @@
+# Data Model: 闪送订单状态变更消息推送
+
+**Date**: 2026-09-17 | **Spec**: [spec.md](spec.md)
+
+本特性不新增表;2026-09-18 的新单通知追加 `flash_delivery_order.is_display`,作为骑手可见性与预约开放通知资格的同一门闩。
+
+## 消费的既有实体
+
+### FlashDeliveryOrder(闪送订单,既有)
+
+推送触发与接收人判定只读以下字段:
+
+| 字段 | 类型 | 用途 |
+|------|------|------|
+| `orderNo` | String | 推送正文 `,NO:` 后缀与 payload `ddId`(FR-007) |
+| `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(用户,既有)
+
+| 字段 | 用途 |
+|------|------|
+| `cid` | 设备推送标识;为空时云端推送跳过、消息记录仍生成 |
+| 语言设置 | 经 `PayPush.*HandleLocal` → `LocaleUtils.getUserLocale` 决定文案语言 |
+
+### push_message(消息记录表,既有,不修改)
+
+`PayPush` 通道内 `PushEventService.PublisherEvent(userId, title, content, body)` 落库,即 spec FR-008 的"消息中心记录"。通知服务不直接操作该表。
+
+## 触发事件矩阵(状态流转 → 推送)
+
+| 状态流转 | 触发方法(FlashDeliveryApplicationService) | 通知调用 | 接收人 | content key |
+|----------|--------------------------------------------|---------|--------|-------------|
+| WAITING_ACCEPTANCE → ACCEPTED | `accept`(锁内条件更新成功后) | `notifyAccepted(order)` | userId | ...receiving.order |
+| ACCEPTED → PICKED_UP | `pickup` → `transitionWithProof` | `notifyPickedUp(order)` | userId | ...qspsz.order |
+| 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`)。
+
+## 原子开放约束
+
+预约任务只选择 WAITING_ACCEPTANCE、无人接单、SCHEDULED、`is_display=false` 且当前时间位于半开区间 `[start, end)` 的订单。更新语句重复全部条件,并执行 `is_display=true`;影响行数为 1 才获得推送资格。这样列表可见性和通知资格在同一次数据库写入生效,多实例竞争也不会重复推送。
+
+## 传递给推送通道的 payload 契约
+
+见 [contracts/push.md](contracts/push.md)。

+ 99 - 0
specs/028-flash-delivery-push/plan.md

@@ -0,0 +1,99 @@
+# Implementation Plan: 闪送订单状态变更消息推送
+
+**Branch**: `028-flash-delivery-push` | **Date**: 2026-09-17 | **Spec**: [spec.md](spec.md)
+
+**Input**: Feature specification from `/specs/028-flash-delivery-push/spec.md`
+
+## Summary
+
+在闪送订单状态变更点接入既有推送通道,并追加新单通知:立即单以 `is_display=true` 创建后通知骑手;预约单以 `is_display=false` 创建,由独立 `FlashDeliveryAvailabilityTask` 每秒检查时间窗,以条件更新原子开放展示并取得唯一通知资格。履约事件推寄件用户、取消事件推接单骑手;骑手候选按在线状态、FLASH 配送类型、车型、距离和接单互斥规则筛选。外卖和闪送分别使用专属新单 i18n key,payload 继续复用 `OrderPushBodyDto`(pushType=3)。
+
+## Technical Context
+
+**Language/Version**: Java 21(Spring Boot + MyBatis-Plus)
+
+**Primary Dependencies**: Spring 事务同步(`TransactionSynchronizationManager`)、`AsyncManager`、`PayPush`(uni 云函数推送)、`PushEventService`(push_message 入库)、fastjson(payload 序列化)
+
+**Storage**: MySQL(`flash_delivery_order` 新增 `is_display`;原子开放预约单;推送消息经 PushEventService 写入既有 `push_message`)
+
+**Testing**: JUnit 5 + Mockito(`spy`+`doNothing` 打断网络发送,验证路由决策;仓库先例 `DeliveryOrderNotificationServiceTest`)
+
+**Target Platform**: Windows 开发 / Linux 部署的 Spring Boot 服务
+
+**Project Type**: web-service(多模块 Maven:`ruoyi-admin` → `ruoyi-system`)
+
+**Performance Goals**: 推送异步执行,不增加订单接口耗时(SC-004 零传导)
+
+**Constraints**: 推送失败不得影响状态流转(FR-009);仅事务提交后触发(FR-011);不新增 Android 通道
+
+**Scale/Scope**: 通知服务和应用服务增量 + 1 个独立任务类 + Entity/Mapper/XML + 后端 i18n + SQL/spec 测试
+
+## Constitution Check
+
+*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
+
+`.specify/memory/constitution.md` 为未定制的占位模板,无项目级原则可引用;改按仓库 `CLAUDE.md` 约束逐条核对:
+
+| 约束 | 结论 |
+|------|------|
+| Controller 参数规范 | 不涉及(无新 Controller/接口改动) |
+| 模块边界:集成代码放 ruoyi-admin | ✅ 通知服务放 `ruoyi-admin` 的 `com.ruoyi.app.flashdelivery.service`,不进 ruoyi-system |
+| 数据库变更须写 updatesql/sql.md | ✅ `is_display`、索引和 `sys_job` 仅记录于 `updatesql/sql.md`,不直接执行 |
+| 全栈字段清单 | ✅ 更新 Entity、Mapper XML、查询/条件更新及 SQL;该字段不对前端表单开放 |
+| Java 注释安全(无 `*/` 序列) | ✅ 实现时遵守 |
+| 前端 i18n 规范 | 不涉及前端;后端 6 份资源新增外卖/闪送专属新单 key |
+| CRLF 保留、不格式化整文件 | ✅ 实现时遵守 |
+
+无违规,无需 Complexity Tracking 条目。
+
+## Project Structure
+
+### Documentation (this feature)
+
+```text
+specs/028-flash-delivery-push/
+├── plan.md              # This file (/speckit-plan command output)
+├── research.md          # Phase 0 output (/speckit-plan command)
+├── data-model.md        # Phase 1 output (/speckit-plan command)
+├── quickstart.md        # Phase 1 output (/speckit-plan command)
+├── contracts/
+│   └── push.md          # 推送 payload 契约(Phase 1 output)
+└── tasks.md             # Phase 2 output (/speckit-tasks command - NOT created by /speckit-plan)
+```
+
+### Source Code (repository root)
+
+```text
+ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/
+├── service/
+│   ├── FlashDeliveryApplicationService.java   # 修改:状态通知、新单可见性及原子开放
+│   └── FlashDeliveryNotificationService.java  # 修改:状态通知与闪送新单骑手路由
+└── task/
+    └── FlashDeliveryAvailabilityTask.java     # 新增:每秒开放进入预约窗口的订单
+
+ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/
+└── service/
+    ├── FlashDeliveryApplicationServiceTest.java       # 修改:构造器新参 + 推送触发/不触发断言
+    └── FlashDeliveryNotificationServiceTest.java      # 新增:接收人判定与跳过逻辑
+```
+
+**Structure Decision**: 集成和任务仍在 `ruoyi-admin`;订单字段、候选骑手查询和原子更新落在 `ruoyi-system` 的既有 Entity/Mapper/XML;不动 Controller 层。
+
+## 关键设计点(供 tasks 生成引用)
+
+1. **通知服务 API**:`notifyAccepted(order)` / `notifyPickedUp(order)` / `notifyDelivered(order)` / `notifyCancelled(order)`;入参用事务前读到的 `FlashDeliveryOrder`(只消费 orderNo/userId/riderId 等不可变字段,不依赖其 status 快照)。
+2. **钩子位置**:
+   - `accept`:`withLock` 内 `writeLog(...)` 之后、`return` 之前;
+   - `pickup`/`deliver`:共用 `transitionWithProof` 的 `writeLog(...)` 之后,按 `next` 参数分派(PICKED_UP/DELIVERED);
+   - `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. **新单开放门闩**:立即单创建时 `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
+
+> 无 Constitution Check 违规,本节为空。

+ 46 - 0
specs/028-flash-delivery-push/quickstart.md

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

+ 96 - 0
specs/028-flash-delivery-push/research.md

@@ -0,0 +1,96 @@
+# Research: 闪送订单状态变更消息推送
+
+**Date**: 2026-09-17 | **Spec**: [spec.md](spec.md)
+
+研究方式:直接阅读现有代码与 i18n 资源(无外部依赖需要调研)。所有决策均基于仓库内已验证的事实。
+
+## D1: 通知服务的形态与放置
+
+**Decision**: 新建 `FlashDeliveryNotificationService`(`ruoyi-admin` 的 `com.ruoyi.app.flashdelivery.service` 包),完整镜像 `DeliveryOrderNotificationService` 的模式:`TransactionSynchronizationManager` 的 afterCommit 回调 + `AsyncManager.me().execute(TimerTask)` 异步执行 + `PayPush.userPushHandleLocal / qsPushHandleLocal` 发送。
+
+**Rationale**:
+- `DeliveryOrderNotificationService`(`ruoyi-admin/.../order/DeliveryOrderNotificationService.java`)是外送订单推送的现行实现,模式经过生产验证。
+- afterCommit 保证只在事务提交后触发(满足 FR-011:状态变更实际成功才推送);无事务同步时直接执行(便于测试)。
+- AsyncManager 异步执行保证推送 HTTP 调用不阻塞、失败不上抛(满足 FR-009)。
+- `PayPush.*HandleLocal` 系列封装了 locale 解析、`MessageUtils.message(...)` 多语言渲染、`PublisherEvent` 入库(满足 FR-006/FR-008)与 cid 判空跳过。
+- 放 `ruoyi-admin` 符合 CLAUDE.md 模块边界(依赖外部 HTTP 的集成代码不进 ruoyi-system)。
+
+**Alternatives considered**:
+- 在 Controller 层发推送:现有闪存 Controller 无业务逻辑,且 accept 等操作在应用服务锁内完成,Controller 拿不到可靠的成功信号;放弃。
+- 直接在 `FlashDeliveryApplicationService` 里调 PayPush:把推送基础设施混进 1200+ 行的应用服务,且无法单测路由;放弃。
+- Spring `@TransactionalEventListener`:项目无此用法先例,与现有模式不一致;放弃。
+
+## D2: 推送文案——复用现有 i18n key,不新增
+
+**Decision**: 全部复用现有 key(已验证 4 个 key × 6 个语言文件全部齐备,含 th_TH):
+
+| 事件 | title key | content key | zh_CN 文案 |
+|------|-----------|-------------|-----------|
+| 骑手抢单 (→ACCEPTED) | `no.message.push.message` | `no.message.push.delivery.personnel.receiving.order` | 骑手已接单 |
+| 骑手取件 (→PICKED_UP) | `no.message.push.message` | `no.message.push.delivery.personnel.qspsz.order` | 骑手配送中 |
+| 骑手送达 (→DELIVERED) | `no.message.push.message` | `no.message.push.delivery.personnel.qsysd.order` | 骑手已送达 |
+| 订单取消 (→CANCELLED) | `no.message.push.message` | `no.message.push.order.cancelled` | 订单已取消 |
+
+**Rationale**:
+- 语义与闪送场景一一对应,且与外卖订单推送文案保持一致(同一 App 内统一体验)。
+- 6 个文件(messages.properties=vi 默认、vi、zh_CN、zh_TW、en_US、th_TH)均已有这些 key,i18n 工作量为零,规避漏翻风险。
+- `PayPush.*HandleLocal` 会在 content 后拼接 `,NO:{订单号}`(满足 FR-007)。
+
+**Alternatives considered**: 新增 `flash.delivery.push.*` 专属 key(如"閃送騎手已取件"):文案可更精确区分闪送与外卖,但要新增 4×6=24 条翻译且收益有限;如后续需要区分再追加。
+
+## D3: 推送 payload——复用 OrderPushBodyDto,pushType 新增 3=闪送
+
+**Decision**: payload 用 `OrderPushBodyDto.getJson(orderNo, status, -1, 3)`:`ddId` 字段承载闪送 `orderNo`(形如 `FD` + 24 位),`state` 承载状态枚举字符串(如 `ACCEPTED`),`type=-1`(非语音提醒),`pushType=3` 标识闪送订单(现有取值 1=外卖、2=充值)。
+
+**Rationale**: 客户端已按 `OrderPushBodyDto` 的形状解析 payload 并按 `pushType` 路由;复用形状让客户端只需新增一个 pushType 分支。`orderNo` 是面向用户的订单标识(与外卖 `ddId` 同角色)。
+
+**Alternatives considered**: 新建 `FlashDeliveryPushBodyDto`:字段更语义化但客户端要适配两种形状;放弃。
+
+## D4: 各事件的接收人判定
+
+**Decision**:
+- ACCEPTED / PICKED_UP / DELIVERED → 寄件用户 `FlashDeliveryOrder.userId`(`userPushHandleLocal`)。
+- CANCELLED → `order.riderId != null` 时推骑手(`qsPushHandleLocal`),否则跳过。
+- 用户取消按状态机只可能发生在 WAITING_ACCEPTANCE/ACCEPTED;发生在 ACCEPTED 时必有骑手。平台取消可发生在任意非终态,WAITING_ACCEPTANCE 时 riderId 为空自然跳过(spec 边界场景 3)。
+
+**Rationale**: 与 spec FR-001~FR-005 一一对应;riderId 判空即覆盖"未接单取消不推骑手"。
+
+## D5: 用户信息获取
+
+**Decision**: 通知服务注入 `InfoUserMapper`,用 `selectById` 取 `cid`。
+
+**Rationale**: 闪送模块(`FlashDeliveryApplicationService`)现用 `InfoUserMapper` 而非 `IInfoUserService`,模块内保持一致。
+
+## D6: 测试策略
+
+**Decision**: 镜像 `DeliveryOrderNotificationServiceTest` 的手法:
+- 通知服务单测:`spy` 服务 + `doNothing` 打断包级可见的 send 方法(其内部是真实 HTTP 调用,不可单测),验证接收人判定与跳过逻辑;无事务同步时 `runAfterCommit` 同步执行,无需 Spring 上下文。
+- 应用服务单测:mock `FlashDeliveryNotificationService` 注入,verify 各状态流转方法成功路径调用对应 notify、失败路径与排除事件(complete、cancelExpiredScheduledOrder、加小费)`never()` 调用。仓库已有 `MockedStatic` 先例(`FlashDeliveryApplicationServiceTest`)可参考构造方式。
+
+**Rationale**: 与项目现有测试风格完全一致,不引入新测试设施。
+
+## D7: 消息入库与无 cid 行为
+
+**Decision**: 不额外开发——`PayPush.userPush/qsPush` 内部先 `pushEventService.PublisherEvent(...)`(写 `push_message`,即 FR-008 的消息记录)再判 cid;cid 为空只记 error 日志并跳过云端推送。两个行为均由复用通道免费获得。
+
+## 结论
+
+无 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 即可区分业务类型。

+ 129 - 0
specs/028-flash-delivery-push/spec.md

@@ -0,0 +1,129 @@
+# Feature Specification: 闪送订单状态变更消息推送
+
+**Feature Branch**: `028-flash-delivery-push`
+
+**Created**: 2026-09-17
+
+**Status**: Draft
+
+**Input**: User description: "闪送订单状态变更消息推送:骑手抢单、已取件、已送达推送给用户端;用户/平台取消推送给骑手端。2026-09-18 追加:立即闪送单创建后通知骑手;预约闪送单创建时不通知,进入可抢单时间窗口后再开放展示并通知;外卖和闪送使用不同的新单文案。加小费不推送。"
+
+## User Scenarios & Testing *(mandatory)*
+
+### User Story 1 - 寄件用户收到骑手接单通知 (Priority: P1)
+
+寄件用户下单后处于等待状态,骑手抢单成功的瞬间,用户收到一条推送,告知订单已被骑手接单、骑手正在前来取件。
+
+**Why this priority**: 用户下单后最焦虑的时段是"有没有人接单";接单通知是整个推送体系里价值最高的单条消息,没有它用户只能反复刷新订单页。
+
+**Independent Test**: 创建一笔待接单的闪送订单,骑手账号执行抢单,验证寄件用户收到"骑手已接单"推送。
+
+**Acceptance Scenarios**:
+
+1. **Given** 订单状态为 WAITING_ACCEPTANCE,**When** 骑手抢单成功(状态转为 ACCEPTED),**Then** 寄件用户在短时间内收到推送,内容表明骑手已接单并包含订单标识。
+2. **Given** 抢单因并发失败(订单已被他人抢走或已取消),**When** 抢单请求落空,**Then** 不产生任何推送。
+
+---
+
+### User Story 2 - 寄件用户收到取件与送达通知 (Priority: P1)
+
+履约中段,骑手确认取件、确认送达两个节点,寄件用户分别收到推送,掌握物品在途与签收进度。
+
+**Why this priority**: 与接单通知共同构成完整的履约进度感知;送达通知同时提示用户可去确认收货。
+
+**Independent Test**: 对一笔已接单订单依次执行取件(附凭证)、送达(附凭证),验证寄件用户先后收到两条对应推送。
+
+**Acceptance Scenarios**:
+
+1. **Given** 订单状态为 ACCEPTED,**When** 骑手确认取件成功(状态转为 PICKED_UP),**Then** 寄件用户收到"骑手已取件"推送。
+2. **Given** 订单状态为 PICKED_UP,**When** 骑手确认送达成功(状态转为 DELIVERED),**Then** 寄件用户收到"已送达"推送。
+3. **Given** 取件或送达操作因校验失败未改变订单状态,**When** 操作报错返回,**Then** 不产生推送。
+
+---
+
+### User Story 3 - 骑手收到订单取消通知 (Priority: P2)
+
+订单被取消时,已接单的骑手收到推送,及时停止履约,避免白跑。
+
+**Why this priority**: 取消是低频事件,但漏通知会导致骑手空跑、产生纠纷;相比履约进度通知优先级略低。
+
+**Independent Test**: 对一笔已接单订单分别走用户取消、平台取消两条路径,验证骑手收到取消推送;对未接单订单执行取消,验证不产生骑手推送。
+
+**Acceptance Scenarios**:
+
+1. **Given** 订单已被骑手接单(ACCEPTED/PICKED_UP),**When** 寄件用户取消订单成功(状态转为 CANCELLED),**Then** 该骑手收到"订单已取消"推送,内容含订单标识。
+2. **Given** 订单已被骑手接单,**When** 平台取消订单成功,**Then** 该骑手收到取消推送。
+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 / 标识为空)时:跳过云端推送,但消息记录仍正常生成,且不影响订单操作结果。
+- 推送服务调用失败或超时:只记录日志,不重试阻断,订单状态流转结果不受影响。
+- 接收方语言设置为越南语/繁中/英语/简中之外或未设置:按平台默认语言回落(与现有订单推送行为一致)。
+- 骑手抢单与用户取消并发(一方成功另一方必然失败):只对实际改变了订单状态的一方产生推送。
+- 平台连续取消多笔订单:每笔独立推送,互不影响。
+- 预约单进入窗口后的任务存在亚秒级调度延迟:在 `isDisplay` 原子切换前订单保持不可见,因此不会出现先看到订单、后收到推送的一分钟级错位。
+
+## Requirements *(mandatory)*
+
+### Functional Requirements
+
+- **FR-001**: 骑手抢单成功(WAITING_ACCEPTANCE → ACCEPTED)后,系统 MUST 向寄件用户发送"骑手已接单"推送通知。
+- **FR-002**: 骑手确认取件成功(ACCEPTED → PICKED_UP)后,系统 MUST 向寄件用户发送"骑手已取件"推送通知。
+- **FR-003**: 骑手确认送达成功(PICKED_UP → DELIVERED)后,系统 MUST 向寄件用户发送"已送达"推送通知。
+- **FR-004**: 寄件用户取消订单成功(状态转为 CANCELLED)且订单已有接单骑手时,系统 MUST 向该骑手发送"订单已取消"推送通知。
+- **FR-005**: 平台取消订单成功且订单已有接单骑手时,系统 MUST 向该骑手发送"订单已取消"推送通知。
+- **FR-006**: 所有推送文案 MUST 支持四种语言(vi、zh、tw、en),按接收方用户的语言设置呈现。
+- **FR-007**: 每条推送内容 MUST 包含订单标识(订单号),使接收方无需打开 App 搜索即可定位订单。
+- **FR-008**: 每条推送 MUST 同步生成消息记录(复用现有消息中心机制),用户/骑手可在消息列表中回看。
+- **FR-009**: 推送环节的任何失败(无设备标识、推送服务异常)MUST NOT 影响订单状态流转本身,也 MUST NOT 向操作方返回错误。
+- **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)*
+
+- **推送通知(Push Notification)**: 一次状态变更事件产生的一条通知,属性包括接收方(寄件用户或接单骑手)、事件类型(接单/取件/送达/取消)、标题与正文(按语言)、关联订单标识;复用现有消息记录实体落地,不新增独立数据模型。
+
+## Success Criteria *(mandatory)*
+
+### Measurable Outcomes
+
+- **SC-001**: 任一在推送范围内的事件(抢单、取件、送达、取消)成功发生后,接收方在 10 秒内收到推送(推送服务正常的前提下)。
+- **SC-002**: 四种语言设置的用户各抽检一批推送,文案语言与用户设置一致的比例为 100%。
+- **SC-003**: 抽检的全部推送均含订单号,接收方可凭推送内容直接定位订单,达标率 100%。
+- **SC-004**: 推送服务整体不可用时,抢单/取件/送达/取消接口的成功率与推送上线前持平(推送故障零传导)。
+- **SC-005**: 预约单进入可抢窗口后 2 秒内开放展示并开始异步推送,且同一订单开放通知最多触发一次。
+
+## Assumptions
+
+- 复用项目现有推送通道(App 端云函数推送 + 消息入库),不新建推送基础设施,不新增 Android 通道。
+- 推送在订单状态变更成功后异步触发,不放在订单操作的事务内。
+- 平台取消按需求仅推送骑手端;寄件用户通过订单列表感知取消结果,如需同时通知用户可后续追加需求。
+- 超时自动完成、过期预约单自动取消、预约单创建和加小费均不推送(FR-010);立即单创建和预约单开放属于新单通知触发点。
+- 推送点击后的客户端跳转(payload 路由到闪送订单详情页)由客户端处理;服务端只保证推送携带订单标识与业务类型。
+- 报文文案沿用现有订单推送的文案风格(简短状态描述 + 订单号后缀)。

+ 186 - 0
specs/028-flash-delivery-push/tasks.md

@@ -0,0 +1,186 @@
+---
+description: "Task list for feature 028-flash-delivery-push"
+---
+
+# Tasks: 闪送订单状态变更消息推送
+
+**Input**: Design documents from `/specs/028-flash-delivery-push/`
+
+**Prerequisites**: plan.md (required), spec.md (required for user stories), research.md, data-model.md, contracts/push.md
+
+**Tests**: quickstart.md「自动验证」明确要求单测(`FlashDeliveryNotificationServiceTest` 新建 + `FlashDeliveryApplicationServiceTest` 扩展),故包含测试任务且先行(红→绿)。
+
+**Organization**: Tasks are grouped by user story to enable independent implementation and testing of each story.
+
+## Format: `[ID] [P?] [Story] Description`
+
+- **[P]**: Can run in parallel (different files, no dependencies)
+- **[Story]**: Which user story this task belongs to (e.g., US1, US2, US3)
+- Include exact file paths in descriptions
+
+## Path Conventions
+
+- 主代码:`ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/`
+- 测试:`ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/`
+- 构建命令(Git Bash):`export JAVA_HOME="C:/Users/qmj/.jdks/graalvm-jdk-21.0.7" && export PATH="$JAVA_HOME/bin:$PATH" && mvn -pl ruoyi-admin -am test -Dtest='...'`
+
+---
+
+## Phase 1: Setup
+
+**Purpose**: 确认基线全绿,避免把存量问题带进本特性
+
+- [x] T001 运行既有闪送单测确认基线全绿:`mvn -pl ruoyi-admin -am test -Dtest='FlashDeliveryApplicationServiceTest,FlashDeliveryStateMachineTest,FlashDeliveryControllerContractTest'`(JDK21 环境变量见 Path Conventions)——基线 79 测试 1 存量失败(`createRejectsUnsupportedPayType`,源于在途 payType 白名单注释改动,非本特性范围,全程保持不新增失败即可)
+
+---
+
+## Phase 2: Foundational (Blocking Prerequisites)
+
+**Purpose**: 通知服务基础设施 + 应用服务装配,所有 user story 依赖
+
+- [x] T002 新建 `ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryNotificationService.java`:`@Service`;构造器注入 `InfoUserMapper`(`com.ruoyi.system.mapper`)与 `PushEventService`(`com.ruoyi.app.utils.event`);私有 `runAfterCommit(Runnable)`(镜像 `DeliveryOrderNotificationService.java:156-167`);包级可见 `sendToUser(FlashDeliveryOrder, String contentKey)` / `sendToRider(FlashDeliveryOrder, String contentKey)`:`runAfterCommit` → `userMapper.selectById` 取 `InfoUser` → `AsyncManager.me().execute(TimerTask)` → `PayPush.userPushHandleLocal` / `qsPushHandleLocal`,title 固定 `no.message.push.message`,body=`OrderPushBodyDto.getJson(order.getOrderNo(), 状态串, -1, 3)`,ddId=`order.getOrderNo()`;推送异常捕获仅记日志不上抛
+- [x] T003 `FlashDeliveryApplicationService.java:76-94` 构造器追加第 10 个依赖 `FlashDeliveryNotificationService` 并存字段;全仓更新 `new FlashDeliveryApplicationService(` 调用点(`ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryApplicationServiceTest.java` 等,先 grep 定位)补 mock 参数;编译并运行 T001 测试集保持全绿
+
+**Checkpoint**: 通知服务骨架就绪且零行为变化(尚无 notify 入口被调用)
+
+---
+
+## Phase 3: User Story 1 - 寄件用户收到骑手接单通知 (Priority: P1) 🎯 MVP
+
+**Goal**: 骑手抢单成功后,寄件用户收到「骑手已接单,NO:{orderNo}」推送与消息中心记录
+
+**Independent Test**: 对一笔 WAITING_ACCEPTANCE 订单执行 `accept` 成功 → 验证 `notifyAccepted` 被调用;抢单失败(条件更新 0 行)→ 不调用
+
+### Tests for User Story 1(先行,跑红)
+
+- [x] T004 [US1] 新建 `ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryNotificationServiceTest.java`:spy 服务 + `doNothing` 打断 `sendToUser`/`sendToRider`(镜像 `DeliveryOrderNotificationServiceTest.java` 手法),断言 `notifyAccepted(order)` 走 `sendToUser(order, "no.message.push.delivery.personnel.receiving.order")`;同时在 `FlashDeliveryApplicationServiceTest.java` 增补:accept 成功路径 verify `notifyAccepted`、`orderMapper.accept` 返回 0 时 never 调用
+
+### Implementation for User Story 1
+
+- [x] T005 [US1] 在 `FlashDeliveryNotificationService.java` 实现 `notifyAccepted(FlashDeliveryOrder)`(runAfterCommit 后推 userId,状态串 `ACCEPTED`);在 `FlashDeliveryApplicationService.java` `accept`(约 :485 `writeLog` 之后、`return` 之前,`withLock` lambda 内)调用 `notificationService.notifyAccepted(target)`
+- [x] T006 [US1] 运行 `mvn -pl ruoyi-admin -am test -Dtest='FlashDeliveryNotificationServiceTest,FlashDeliveryApplicationServiceTest'` 确认由红转绿
+
+**Checkpoint**: MVP——抢单通知可独立演示(quickstart.md 手动场景 3)
+
+---
+
+## Phase 4: User Story 2 - 寄件用户收到取件与送达通知 (Priority: P1)
+
+**Goal**: 骑手取件、送达成功后,寄件用户分别收到「骑手配送中」「骑手已送达」推送
+
+**Independent Test**: 对 ACCEPTED 订单 `pickup` 成功 → `notifyPickedUp`;对 PICKED_UP 订单 `deliver` 成功 → `notifyDelivered`;任一失败 → 不调用
+
+### Tests for User Story 2(先行,跑红)
+
+- [x] T007 [US2] `FlashDeliveryNotificationServiceTest.java` 增补:`notifyPickedUp` 走 `sendToUser(order, "no.message.push.delivery.personnel.qspsz.order")`、`notifyDelivered` 走 `sendToUser(order, "no.message.push.delivery.personnel.qsysd.order")`;`FlashDeliveryApplicationServiceTest.java` 增补:`transitionWithProof` 成功按 next 分派对应 notify、`orderMapper.transitionByRider` 返回 0 时两者均 never
+
+### Implementation for User Story 2
+
+- [x] T008 [US2] `FlashDeliveryNotificationService.java` 实现 `notifyPickedUp` / `notifyDelivered`(状态串 `PICKED_UP` / `DELIVERED`);`FlashDeliveryApplicationService.java` `transitionWithProof`(约 :781 `writeLog` 之后)按 `next` 参数分派调用(仅 PICKED_UP/DELIVERED 两值,其它忽略)
+- [x] T009 [US2] 运行 Story 2 测试集确认由红转绿
+
+**Checkpoint**: 履约进度通知完整(quickstart.md 手动场景 4、5)
+
+---
+
+## Phase 5: User Story 3 - 骑手收到订单取消通知 (Priority: P2)
+
+**Goal**: 用户/平台取消成功且已有接单骑手时,骑手收到「订单已取消,NO:{orderNo}」推送;未接单取消零推送
+
+**Independent Test**: ACCEPTED 状态 `userCancel`/`adminCancel` 成功 → `notifyCancelled`;WAITING_ACCEPTANCE 取消(riderId 空)→ 跳过;取消失败 → 不调用
+
+### Tests for User Story 3(先行,跑红)
+
+- [x] T010 [US3] `FlashDeliveryNotificationServiceTest.java` 增补:`notifyCancelled(order)` 在 riderId 非空时走 `sendToRider(order, "no.message.push.order.cancelled")`、riderId 为空时零交互;`FlashDeliveryApplicationServiceTest.java` 增补:`userCancel`/`adminCancel` 成功路径 verify `notifyCancelled`、`orderMapper.cancel` 返回 0 时 never
+
+### Implementation for User Story 3
+
+- [x] T011 [US3] `FlashDeliveryNotificationService.java` 实现 `notifyCancelled`(riderId 判空跳过,状态串 `CANCELLED`,推骑手端);`FlashDeliveryApplicationService.java` 在 `userCancel`(约 :397)与 `adminCancel`(约 :622)的 `writeLog` 之后调用
+- [x] T012 [US3] 运行 Story 3 测试集确认由红转绿
+
+**Checkpoint**: 取消通知完整(quickstart.md 手动场景 6、7、8)
+
+---
+
+## Phase 6: Polish & Cross-Cutting Concerns
+
+**Purpose**: 排除事件防回归 + 全量验证
+
+- [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 原始范围快照:当时零数据库变更、零新 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`;数据库迁移和真实推送联调仍需部署环境人工验证。
+
+---
+
+## Phase 8: 闪送订单骑手评分(2026-09-18 追加)
+
+**Goal**: 寄件人对已完成闪送订单的骑手评分(1-5 星,仅一次);闪送分与外卖 `pos_order_rating` 体系彻底分开,不并骑手总分、不给积分;用户端详情回显评分,平台 PC 端订单详情显示评分。
+
+**Background**: Bug 工单 #659「骑手评价后要可提交」——用户端已有评价页,后端此前无闪送评价支持。设计决策:评分存 `flash_delivery_order.rider_stars` 单字段(NULL=未评),新开专用接口带完整校验,骑手身份由后端从订单 `rider_id` 回填语义(评分条件更新即绑定该订单骑手),星级由服务端校验。
+
+- [x] T025 [US5] 先补失败测试:`FlashDeliveryApplicationServiceTest.java` 增补 `rateRider` 用例(寄件人完成单评价成功、非寄件人拒绝、未完成/无骑手拒绝、已评预检拒绝、并发条件更新 0 行拒绝、星级 null/越界拒绝、`userDetail` 回显 riderStars);`FlashDeliveryControllerContractTest.java` 增补 `POST /orders/{id}/rate` 契约(token→userId 透传 service)
+- [x] T026 [US5] `FlashDeliveryOrder` 增加 `riderStars` 字段;`FlashDeliveryOrderMapper.xml` resultMap 补 `rider_stars` 映射并新增 `rateRider` 原子条件更新(`status='COMPLETED' AND rider_id IS NOT NULL AND rider_stars IS NULL AND user_id=#{userId}` 守卫,防并发重复提交)
+- [x] T027 [US5] 新建 DTO `FlashDeliveryRatingRequest`(`Long orderId` + `Double stars`,orderId 放 body 与 `addTip`/`updateAddress` 模式一致);`FlashDeliveryUserController` 新增 `POST /system/flashDelivery/orders/rate`;`FlashDeliveryApplicationService.rateRider`(星级 1-5 服务端校验、寄件人/完成态/未评过校验、条件更新 0 行报已评);`FlashDeliveryOrderDetailView` 回显 `riderStars`(`adminDetail` 返回完整实体,平台端自动带出)
+- [x] T028 [US5] 后端 i18n 6 份资源新增 `flash.delivery.rating.stars.invalid` / `flash.delivery.rating.not.allowed` / `flash.delivery.rating.already`;`updatesql/sql.md` 登记 `flash_delivery_order.rider_stars` 列(不直接执行)
+- [x] T029 [US5] 平台 PC 端 `foodie-admin-vue` `views/flashDelivery/orders/OrderDetail.vue` 骑手信息区显示评分(NULL 显示未评价),4 份语言文件(`src/api/language/language.*.js`)`flashDelivery` 命名空间新增 key
+- [x] T030 [US5] 回归:定向测试全绿 + `mvn -pl ruoyi-admin -am test` 维持基线(仅存量已知失败 `createRejectsUnsupportedPayType`);用户端 App 前端调新接口与五星默认选中由 App 侧另行处理(Bug #659 前端部分)
+- [x] T031 [US5] 用户端详情 `GET /orders/{id}` 的 `rider` 用户资料带闪送平均分:新增 `selectRiderAverageStars`(`AVG(rider_stars)` 仅统计闪送评价,与外卖分互不影响),无评价回退 4.5 默认展示(与外卖骑手 `setQsStar` 惯例一致);TDD 含 service 两个用例与 XML 聚合语句断言
+
+---
+
+### Phase Dependencies
+
+- **Setup (Phase 1)**: 无依赖,立即开始
+- **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
+
+- 测试任务先行并跑红 → 实现任务 → 验证任务跑绿
+- 每 story 收尾可独立演示(见各 Checkpoint 对应 quickstart.md 场景)
+
+### Parallel Opportunities
+
+- 本特性文件高度集中,仅 T002/T003 与测试内容天然串行;无安全并行窗口(除 Phase 1 与文档工作)
+
+---
+
+## Implementation Strategy
+
+### MVP First (User Story 1 Only)
+
+1. Phase 1 基线 → Phase 2 基础设施(零行为变化)
+2. Phase 3 US1:抢单通知(最高价值单条消息)
+3. **STOP and VALIDATE**:quickstart.md 场景 3 手动验证 + 单测绿
+4. 继续 US2(取件/送达)、US3(取消)
+
+### Incremental Delivery
+
+每个 story 完成即独立可测、可部署的增量;US1 单独上线即可解决"用户不知是否被接单"的核心痛点。
+
+---
+
+## Notes
+
+- [P] 标记:本特性因文件集中,实际无可并行任务
+- 提交节奏:每 Phase 完成后提交一次(`/speckit-git-commit`)
+- 遵守 plan.md 关键设计点 1-5(不可变字段消费、钩子位置、pushType=3、排除清单)
+- Java 注释不得含 `*/` 序列;保留 CRLF;不格式化整文件

+ 46 - 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)
 ## 2026-09-02 骑手配送类型(025-rider-delivery-type)
 
 
 > 用途:骑手审核资料增加配送类型多选(美食外送 FOOD、闪送 FLASH),派单按骑手选择的配送类型过滤。只记录脚本,由开发者手动执行。
 > 用途:骑手审核资料增加配送类型多选(美食外送 FOOD、闪送 FLASH),派单按骑手选择的配送类型过滤。只记录脚本,由开发者手动执行。
@@ -1673,3 +1687,35 @@ ALTER TABLE flash_delivery_order
   ADD COLUMN vehicle_type TINYINT NOT NULL DEFAULT 1 COMMENT '下单车型快照:1=机车,2=轿车' AFTER delivery_type,
   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);
   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()'
+);
+```
+
+## 2026-09-18 闪送订单骑手评分
+
+```sql
+-- 2026-09-18 闪送订单骑手评分:寄件人对已完成订单评价骑手(1-5,仅一次),NULL=未评价
+ALTER TABLE flash_delivery_order ADD COLUMN rider_stars DOUBLE DEFAULT NULL COMMENT '寄件人对骑手评分1-5,NULL=未评价';
+```

Some files were not shown because too many files changed in this diff