qmj пре 1 час
родитељ
комит
47d929a6ba
79 измењених фајлова са 2681 додато и 370 уклоњено
  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. 2 2
      .specify/feature.json
  10. 1 1
      CLAUDE.md
  11. 7 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/controller/FlashDeliveryRiderController.java
  12. 16 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/controller/FlashDeliveryUserController.java
  13. 9 2
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryOrderDetailView.java
  14. 3 1
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryOrderView.java
  15. 2 2
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryPriceBreakdown.java
  16. 2 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryPricingRequest.java
  17. 4 1
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryQuoteRequest.java
  18. 5 3
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryQuoteView.java
  19. 12 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryRatingRequest.java
  20. 3 1
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryRiderOrderListView.java
  21. 0 22
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryRiderSummaryView.java
  22. 3 1
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryUserOrderListView.java
  23. 29 6
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/route/FlashDeliveryRouteService.java
  24. 25 8
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/route/GoogleRoutesDistanceProvider.java
  25. 3 2
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/route/RouteDistanceProvider.java
  26. 196 46
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryApplicationService.java
  27. 204 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryNotificationService.java
  28. 7 5
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryPricingCalculator.java
  29. 17 2
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/task/FlashDeliveryAutoCompleteTask.java
  30. 32 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/task/FlashDeliveryAvailabilityTask.java
  31. 1 1
      ruoyi-admin/src/main/java/com/ruoyi/app/order/DeliveryOrderNotificationService.java
  32. 1 1
      ruoyi-admin/src/main/java/com/ruoyi/app/order/InfoAddressController.java
  33. 1 1
      ruoyi-admin/src/main/java/com/ruoyi/app/order/dto/OrderPushBodyDto.java
  34. 8 0
      ruoyi-admin/src/main/resources/i18n/messages.properties
  35. 8 0
      ruoyi-admin/src/main/resources/i18n/messages_en_US.properties
  36. 8 0
      ruoyi-admin/src/main/resources/i18n/messages_th_TH.properties
  37. 8 0
      ruoyi-admin/src/main/resources/i18n/messages_vi.properties
  38. 8 0
      ruoyi-admin/src/main/resources/i18n/messages_zh_CN.properties
  39. 8 0
      ruoyi-admin/src/main/resources/i18n/messages_zh_TW.properties
  40. 29 0
      ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/controller/FlashDeliveryControllerContractTest.java
  41. 2 2
      ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryOrderViewTest.java
  42. 4 5
      ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryRiderOrderListViewTest.java
  43. 47 4
      ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/route/FlashDeliveryRouteServiceTest.java
  44. 21 0
      ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/route/GoogleRoutesDistanceProviderTest.java
  45. 556 42
      ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryApplicationServiceTest.java
  46. 2 1
      ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryI18nContractTest.java
  47. 161 0
      ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryNotificationServiceTest.java
  48. 2 2
      ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryPricingCalculatorTest.java
  49. 18 0
      ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/task/FlashDeliveryAutoCompleteTaskTest.java
  50. 63 0
      ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/task/FlashDeliveryAvailabilityTaskTest.java
  51. 8 1
      ruoyi-system/src/main/java/com/ruoyi/system/domain/flash/FlashDeliveryOrder.java
  52. 2 0
      ruoyi-system/src/main/java/com/ruoyi/system/domain/flash/FlashDeliveryPricing.java
  53. 13 0
      ruoyi-system/src/main/java/com/ruoyi/system/domain/flash/FlashDeliveryVehicleType.java
  54. 31 0
      ruoyi-system/src/main/java/com/ruoyi/system/mapper/RiderPositionMapper.java
  55. 20 0
      ruoyi-system/src/main/java/com/ruoyi/system/mapper/flash/FlashDeliveryOrderMapper.java
  56. 8 4
      ruoyi-system/src/main/java/com/ruoyi/system/mapper/flash/FlashDeliveryPricingMapper.java
  57. 23 2
      ruoyi-system/src/main/java/com/ruoyi/system/utils/AuthAspect.java
  58. 56 2
      ruoyi-system/src/main/resources/mapper/flash/FlashDeliveryOrderMapper.xml
  59. 61 0
      ruoyi-system/src/test/java/com/ruoyi/system/mapper/RiderPositionMapperContractTest.java
  60. 27 0
      ruoyi-system/src/test/java/com/ruoyi/system/mapper/flash/FlashDeliveryMapperXmlTest.java
  61. 4 2
      ruoyi-system/src/test/java/com/ruoyi/system/mapper/flash/FlashDeliveryPricingMapperContractTest.java
  62. 63 1
      ruoyi-system/src/test/java/com/ruoyi/system/utils/AuthAspectTest.java
  63. 20 10
      specs/024-flash-delivery/contracts/api.md
  64. 2 2
      specs/024-flash-delivery/data-model.md
  65. 1 1
      specs/024-flash-delivery/design.md
  66. 30 0
      specs/024-flash-delivery/plan.md
  67. 1 1
      specs/024-flash-delivery/quickstart.md
  68. 2 2
      specs/024-flash-delivery/research.md
  69. 15 4
      specs/024-flash-delivery/spec.md
  70. 4 0
      specs/024-flash-delivery/tasks.md
  71. 38 0
      specs/028-flash-delivery-push/checklists/requirements.md
  72. 43 0
      specs/028-flash-delivery-push/contracts/push.md
  73. 54 0
      specs/028-flash-delivery-push/data-model.md
  74. 99 0
      specs/028-flash-delivery-push/plan.md
  75. 46 0
      specs/028-flash-delivery-push/quickstart.md
  76. 96 0
      specs/028-flash-delivery-push/research.md
  77. 129 0
      specs/028-flash-delivery-push/spec.md
  78. 186 0
      specs/028-flash-delivery-push/tasks.md
  79. 60 0
      updatesql/sql.md

+ 1 - 7
.gitignore

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

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


+ 0 - 10
.idea/.gitignore

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

Разлика између датотеке није приказан због своје велике величине
+ 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>

+ 2 - 2
.specify/feature.json

@@ -1,3 +1,3 @@
 {
-  "feature_directory": "specs/027-offline-transfer-payment"
-}
+  "feature_directory": "specs/028-flash-delivery-push"
+}

+ 1 - 1
CLAUDE.md

@@ -204,5 +204,5 @@ Strong success criteria let you loop independently. Weak criteria ("make it work
 
 <!-- SPECKIT START -->
 For additional context about technologies to be used, project structure,
-shell commands, and other important information, read the current plan: `specs/027-offline-transfer-payment/plan.md`
+shell commands, and other important information, read the current plan: `specs/028-flash-delivery-push/plan.md`
 <!-- SPECKIT END -->

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

@@ -48,6 +48,13 @@ public class FlashDeliveryRiderController extends BaseController {
         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")
     @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));
     }
 
+    /** 按订单编号查询本人订单详情;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")
     @Auth
@@ -114,5 +121,14 @@ public class FlashDeliveryUserController extends BaseController {
         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)); }
 }

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

@@ -1,6 +1,7 @@
 package com.ruoyi.app.flashdelivery.dto;
 
 import com.fasterxml.jackson.annotation.JsonInclude;
+import com.ruoyi.system.domain.InfoUser;
 import lombok.Data;
 import lombok.EqualsAndHashCode;
 
@@ -19,9 +20,15 @@ public class FlashDeliveryOrderDetailView extends FlashDeliveryOrderView {
     private List<String> pickupImageUrls = List.of();
     /** 送达凭证图片 URL 列表,骑手上传。 */
     private List<String> deliveryImageUrls = List.of();
-    /** 骑手公开资料,未接单时为空。 */
+    /** 接单骑手完整用户资料,未接单时为空。 */
     @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 的用户详情返回,骑手端始终为空。 */
     @JsonInclude(JsonInclude.Include.NON_NULL)
     private String deliveryPinCode;

+ 3 - 1
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryOrderView.java

@@ -15,6 +15,8 @@ public class FlashDeliveryOrderView {
     private String serviceType;
     /** 配送等级:NORMAL=普通、URGENT=加急。 */
     private String deliveryType;
+    /** 配送车型:1=机车、2=轿车。 */
+    private Integer vehicleType;
     /** 订单状态,取值见 FlashDeliveryStatus。 */
     private String status;
     /** 物品类别(DOCUMENT、GIFT 等 9 种)。 */
@@ -43,7 +45,7 @@ public class FlashDeliveryOrderView {
     private String distanceSource;
     /** 预计配送时长(秒)。 */
     private Integer estimatedDurationSeconds;
-    /** 基础配送费(TWD),已含距离费。 */
+    /** 基础配送费(TWD),仅表示起步价,不含距离附加费。 */
     private Long baseDeliveryFee;
     /** 距离附加费(TWD)。 */
     private Long distanceFee;

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

@@ -11,12 +11,12 @@ public class FlashDeliveryPriceBreakdown {
     private BigDecimal billableDistance;
     /** 距离附加费(TWD)。 */
     private Long distanceFee;
-    /** 基础配送费(TWD),等于起步价加距离费。 */
+    /** 基础配送费(TWD),仅表示起步价,不含距离附加费。 */
     private Long baseDeliveryFee;
     /** 加急费(TWD),普通配送为 0。 */
     private Long urgentFee;
     /** 小费(TWD)。 */
     private Long tipAmount;
-    /** 总金额(TWD),等于基础配送费、加费、小费之和。 */
+    /** 总金额(TWD),等于基础配送费、距离附加费、加急费和小费之和。 */
     private Long amount;
 }

+ 2 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryPricingRequest.java

@@ -7,6 +7,8 @@ import java.math.BigDecimal;
 /** 平台新增或修改闪送时段运价的请求;开始时间必须早于结束时间,且各运价时段不能重叠。 */
 @Data
 public class FlashDeliveryPricingRequest {
+    /** 配送车型,必填:1=机车,2=轿车。 */
+    private Integer vehicleType;
     /** 运价时段开始时间,必填,格式为 HH:mm,取值范围 00:00 至 23:59,包含该时刻。 */
     private String startTime;
 

+ 4 - 1
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryQuoteRequest.java

@@ -19,6 +19,9 @@ public class FlashDeliveryQuoteRequest {
      */
     private String deliveryType;
 
+    /** 配送车型,必填:1=机车,2=轿车。 */
+    private Integer vehicleType;
+
     /**
      * 物品类别,必填。
      * 可选值:DOCUMENT、GIFT、CLOTHING、BEAUTY、DAILY_NECESSITIES、
@@ -30,7 +33,7 @@ public class FlashDeliveryQuoteRequest {
     private Integer quantity;
 
     /**
-     * 物品重量范围,填。
+     * 物品重量范围,填。
      * 可选值:UP_TO_5_KG、OVER_5_TO_10_KG、OVER_10_TO_15_KG、OVER_15_TO_20_KG。
      */
     private String weightRange;

+ 5 - 3
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryQuoteView.java

@@ -14,6 +14,8 @@ public class FlashDeliveryQuoteView {
     private String serviceType;
     /** 配送等级(回显请求值):NORMAL=普通、URGENT=加急。 */
     private String deliveryType;
+    /** 用户选择的配送车型:1=机车,2=轿车。 */
+    private Integer vehicleType;
     /** 取件方式(归一化后):NOW=立即、SCHEDULED=预约。 */
     private String deliveryMode;
     /** 预约取件时间窗开始时间,立即单为空。 */
@@ -30,7 +32,7 @@ public class FlashDeliveryQuoteView {
     private Integer distanceMeters;
     /** 距离来源:ROUTE=地图路线、STRAIGHT_LINE=降级直线。 */
     private String distanceSource;
-    /** 预计配送时长(秒),直线降级时为空。 */
+    /** 预计配送时长(秒);直线降级时按车型保守速度估算。 */
     private Integer estimatedDurationSeconds;
     /** 起步距离(公里)。 */
     private BigDecimal startingDistance;
@@ -42,9 +44,9 @@ public class FlashDeliveryQuoteView {
     private Long freight;
     /** 计费距离(公里):超出起步距离部分的计费公里数,不足 0.5 公里为 0、0.5 至 1 公里记 1 公里、1 公里以上按实际公里数。 */
     private BigDecimal billableDistance;
-    /** 距离附加费(TWD),已包含在基础配送费中。 */
+    /** 距离附加费(TWD)。 */
     private Long distanceFee;
-    /** 基础配送费(TWD),等于起步价加距离费。 */
+    /** 基础配送费(TWD),仅表示起步价,不含距离附加费。 */
     private Long baseDeliveryFee;
     /** 加急费比例(百分比)。 */
     private BigDecimal urgentRate;

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

+ 3 - 1
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryRiderOrderListView.java

@@ -15,6 +15,8 @@ public class FlashDeliveryRiderOrderListView {
     private String serviceType;
     /** 配送等级:NORMAL=普通、URGENT=加急。 */
     private String deliveryType;
+    /** 配送车型:1=机车、2=轿车。 */
+    private Integer vehicleType;
     /** 订单状态,取值见 FlashDeliveryStatus。 */
     private String status;
     /** 物品类别。 */
@@ -47,7 +49,7 @@ public class FlashDeliveryRiderOrderListView {
     private Integer distanceMeters;
     /** 预计配送时长(秒)。 */
     private Integer estimatedDurationSeconds;
-    /** 基础配送费(TWD),已含距离费。 */
+    /** 基础配送费(TWD),仅表示起步价,不含距离附加费。 */
     private Long baseDeliveryFee;
     /** 距离附加费(TWD)。 */
     private Long distanceFee;

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

+ 3 - 1
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryUserOrderListView.java

@@ -15,6 +15,8 @@ public class FlashDeliveryUserOrderListView {
     private String serviceType;
     /** 配送等级:NORMAL=普通、URGENT=加急。 */
     private String deliveryType;
+    /** 配送车型:1=机车、2=轿车。 */
+    private Integer vehicleType;
     /** 订单状态,取值见 FlashDeliveryStatus。 */
     private String status;
     /** 用户编辑待接单订单使用的乐观锁版本。 */
@@ -41,7 +43,7 @@ public class FlashDeliveryUserOrderListView {
     private String deliveryDetailAddress;
     /** 预计配送时长(秒)。 */
     private Integer estimatedDurationSeconds;
-    /** 基础配送费(TWD),已含距离费。 */
+    /** 基础配送费(TWD),仅表示起步价,不含距离附加费。 */
     private Long baseDeliveryFee;
     /** 加急费(TWD),普通配送为 0。 */
     private Long urgentFee;

+ 29 - 6
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/route/FlashDeliveryRouteService.java

@@ -1,29 +1,40 @@
 package com.ruoyi.app.flashdelivery.route;
 
+import com.ruoyi.system.domain.flash.FlashDeliveryVehicleType;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 import org.springframework.stereotype.Service;
 
 /** 路线距离服务:优先请求地图路线,异常时使用球面直线距离保证报价仍然可用。 */
 @Service
 public class FlashDeliveryRouteService {
     private static final double EARTH_RADIUS_METERS = 6_371_000D;
+    private static final int MOTORCYCLE_FALLBACK_SPEED_KPH = 25;
+    private static final int CAR_FALLBACK_SPEED_KPH = 30;
+    private static final int MINIMUM_FALLBACK_DURATION_SECONDS = 60;
+    private static final Logger log = LoggerFactory.getLogger(FlashDeliveryRouteService.class);
     private final RouteDistanceProvider provider;
 
     public FlashDeliveryRouteService(RouteDistanceProvider provider) {
         this.provider = provider;
     }
 
-    /** 计算取送两点路线距离:优先地图路线,异常或无结果时降级球面直线距离。 */
-    public RouteDistance calculate(GeoPoint origin, GeoPoint destination) {
+    /** 按用户选择车型计算取送路线:优先地图路线,异常或无结果时降级球面直线距离。 */
+    public RouteDistance calculate(GeoPoint origin, GeoPoint destination, Integer vehicleType) {
         validate(origin);
         validate(destination);
+        int selectedVehicle = FlashDeliveryVehicleType.isSupported(vehicleType)
+                ? vehicleType : FlashDeliveryVehicleType.MOTORCYCLE;
         try {
             RouteDistance route = provider.route(origin.latitude(), origin.longitude(),
-                    destination.latitude(), destination.longitude());
+                    destination.latitude(), destination.longitude(), selectedVehicle);
             if (route != null && route.distanceMeters() > 0) {
                 return route;
             }
-        } catch (RuntimeException ignored) {
-            // 地图超时、限流或配置缺失不阻断下单,统一进入本地 Haversine 降级。
+        } catch (RuntimeException exception) {
+            // 不记录地址、坐标或 Key;保留车型和失败原因以定位 Google Routes 降级。
+            log.warn("Google Routes unavailable; use straight-line fallback. vehicleType={}, reason={}",
+                    selectedVehicle, exception.getMessage());
         }
         // Haversine 只作为降级结果,并通过 source=STRAIGHT_LINE 明确告知调用方。
         double lat1 = Math.toRadians(origin.latitude().doubleValue());
@@ -35,7 +46,19 @@ public class FlashDeliveryRouteService {
                 + Math.cos(lat1) * Math.cos(lat2)
                 * Math.sin(deltaLon / 2) * Math.sin(deltaLon / 2);
         int meters = (int) Math.round(EARTH_RADIUS_METERS * 2D * Math.atan2(Math.sqrt(a), Math.sqrt(1D - a)));
-        return new RouteDistance(Math.max(1, meters), null, "STRAIGHT_LINE");
+        int straightLineMeters = Math.max(1, meters);
+        return new RouteDistance(straightLineMeters, calculateFallbackDurationSeconds(straightLineMeters, selectedVehicle),
+                "STRAIGHT_LINE");
+    }
+
+    /**
+     * 地图路线不可用时,按车型保守平均速度估算履约展示时长;该值不参与闪送计价。
+     */
+    private int calculateFallbackDurationSeconds(int distanceMeters, int vehicleType) {
+        int speedKph = vehicleType == FlashDeliveryVehicleType.CAR
+                ? CAR_FALLBACK_SPEED_KPH : MOTORCYCLE_FALLBACK_SPEED_KPH;
+        double seconds = distanceMeters * 3_600D / (speedKph * 1_000D);
+        return Math.max(MINIMUM_FALLBACK_DURATION_SECONDS, (int) Math.ceil(seconds));
     }
 
     private void validate(GeoPoint point) {

+ 25 - 8
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/route/GoogleRoutesDistanceProvider.java

@@ -5,6 +5,7 @@ import com.alibaba.fastjson2.JSONArray;
 import com.alibaba.fastjson2.JSONObject;
 import com.ruoyi.common.core.domain.entity.SysDictData;
 import com.ruoyi.common.utils.DictUtils;
+import com.ruoyi.system.domain.flash.FlashDeliveryVehicleType;
 import org.apache.http.client.config.RequestConfig;
 import org.apache.http.client.methods.CloseableHttpResponse;
 import org.apache.http.client.methods.HttpPost;
@@ -19,19 +20,20 @@ import java.math.BigDecimal;
 import java.nio.charset.StandardCharsets;
 import java.util.List;
 
-/** Google Routes API 的服务端实现,密钥只从后端专用字典读取。 */
+/** Google Routes API 的服务端实现,复用既有地图字典中的 Key。 */
 @Component
 public class GoogleRoutesDistanceProvider implements RouteDistanceProvider {
     private static final String ENDPOINT = "https://routes.googleapis.com/directions/v2:computeRoutes";
     private static final RequestConfig TIMEOUTS = RequestConfig.custom()
             .setConnectTimeout(2500).setConnectionRequestTimeout(2500).setSocketTimeout(3500).build();
 
-    /** 调用 Google Routes API 获取驾车路线距离与时长;密钥缺失或响应异常时抛错,由上层降级。 */
+    /** 调用 Google Routes API 获取所选车型路线距离与时长;密钥缺失或响应异常时抛错,由上层降级。 */
     @Override
     public RouteDistance route(BigDecimal originLatitude, BigDecimal originLongitude,
-                               BigDecimal destinationLatitude, BigDecimal destinationLongitude) {
-        // 不复用可能被浏览器端读取的 sys_googlemap_key,防止服务端 API 权限泄露。
-        List<SysDictData> keys = DictUtils.getDictCache("sys_google_routes_key");
+                               BigDecimal destinationLatitude, BigDecimal destinationLongitude,
+                               Integer vehicleType) {
+        // 闪送与外卖共用既有地图 Key,避免维护第二套字典配置。
+        List<SysDictData> keys = DictUtils.getDictCache(mapKeyDictType());
         if (keys == null || keys.isEmpty() || keys.get(0).getDictValue() == null
                 || keys.get(0).getDictValue().isBlank()) {
             throw new IllegalStateException("google routes key unavailable");
@@ -39,7 +41,7 @@ public class GoogleRoutesDistanceProvider implements RouteDistanceProvider {
         JSONObject body = new JSONObject();
         body.put("origin", waypoint(originLatitude, originLongitude));
         body.put("destination", waypoint(destinationLatitude, destinationLongitude));
-        body.put("travelMode", "DRIVE");
+        body.put("travelMode", travelModeFor(vehicleType));
         body.put("routingPreference", "TRAFFIC_AWARE");
         body.put("computeAlternativeRoutes", false);
         body.put("units", "METRIC");
@@ -51,8 +53,10 @@ public class GoogleRoutesDistanceProvider implements RouteDistanceProvider {
         request.setEntity(new StringEntity(body.toJSONString(), ContentType.APPLICATION_JSON));
         try (CloseableHttpClient client = HttpClients.custom().disableAutomaticRetries().build();
              CloseableHttpResponse response = client.execute(request)) {
-            if (response.getStatusLine().getStatusCode() < 200 || response.getStatusLine().getStatusCode() >= 300) {
-                throw new IllegalStateException("google routes non-success response");
+            int statusCode = response.getStatusLine().getStatusCode();
+            if (statusCode < 200 || statusCode >= 300) {
+                // 仅保留 HTTP 状态,绝不记录 Key、地址或完整 Google 响应内容。
+                throw new IllegalStateException("google routes http status " + statusCode);
             }
             JSONObject json = JSON.parseObject(EntityUtils.toString(response.getEntity(), StandardCharsets.UTF_8));
             JSONArray routes = json.getJSONArray("routes");
@@ -65,6 +69,8 @@ public class GoogleRoutesDistanceProvider implements RouteDistanceProvider {
                 throw new IllegalStateException("google routes invalid distance");
             }
             return new RouteDistance(meters, parseDuration(first.getString("duration")), "ROUTE");
+        } catch (IllegalStateException exception) {
+            throw exception;
         } catch (Exception exception) {
             throw new IllegalStateException("google routes request failed", exception);
         }
@@ -81,6 +87,17 @@ public class GoogleRoutesDistanceProvider implements RouteDistanceProvider {
         return waypoint;
     }
 
+    /** Google TWO_WHEELER 对应机车;无效或历史空值安全按机车回落。 */
+    static String travelModeFor(Integer vehicleType) {
+        return Integer.valueOf(FlashDeliveryVehicleType.CAR).equals(vehicleType)
+                ? "DRIVE" : "TWO_WHEELER";
+    }
+
+    /** 闪送与外卖共用的地图 Key 字典类型。 */
+    static String mapKeyDictType() {
+        return "sys_googlemap_key";
+    }
+
     private Integer parseDuration(String value) {
         if (value == null || !value.endsWith("s")) return null;
         try {

+ 3 - 2
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/route/RouteDistanceProvider.java

@@ -5,7 +5,8 @@ import java.math.BigDecimal;
 @FunctionalInterface
 /** 外部路线距离提供器,失败时由上层服务负责本地降级。 */
 public interface RouteDistanceProvider {
-    /** 按起终点计算路线距离与时长;失败直接抛异常,由上层负责降级。 */
+    /** 按起终点和用户选择车型计算路线距离与时长;失败直接抛异常,由上层负责降级。 */
     RouteDistance route(BigDecimal originLatitude, BigDecimal originLongitude,
-                        BigDecimal destinationLatitude, BigDecimal destinationLongitude);
+                        BigDecimal destinationLatitude, BigDecimal destinationLongitude,
+                        Integer vehicleType);
 }

+ 196 - 46
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryApplicationService.java

@@ -54,6 +54,8 @@ public class FlashDeliveryApplicationService {
     /** 支付方式取值与主订单系统同域:4=现金、6=线下转账(027 线下转账支付)。 */
     private static final String PAY_TYPE_CASH = "4";
     private static final String PAY_TYPE_TRANSFER = "6";
+    /** 自动取消原因使用稳定代码,供不同端按各自语言展示而不是保存单一语言文本。 */
+    private static final String SCHEDULE_EXPIRED_REASON = "SCHEDULE_EXPIRED";
     private static final DateTimeFormatter PRICING_TIME_FORMAT = DateTimeFormatter.ofPattern("HH:mm");
     private static final ZoneId TAIPEI = ZoneId.of("Asia/Taipei");
     private static final int MAX_DISTANCE_METERS = 40_000;
@@ -70,6 +72,7 @@ public class FlashDeliveryApplicationService {
     private final FlashDeliveryPricingCalculator pricingCalculator;
     private final RiderDeliveryLockService riderDeliveryLockService;
     private final RiderDeliveryExclusivityService riderDeliveryExclusivityService;
+    private final FlashDeliveryNotificationService notificationService;
 
     public FlashDeliveryApplicationService(FlashDeliveryOrderMapper orderMapper,
                                            FlashDeliveryPricingMapper pricingMapper,
@@ -79,7 +82,8 @@ public class FlashDeliveryApplicationService {
                                            FlashDeliveryRouteService routeService,
                                            FlashDeliveryPricingCalculator pricingCalculator,
                                            RiderDeliveryLockService riderDeliveryLockService,
-                                           RiderDeliveryExclusivityService riderDeliveryExclusivityService) {
+                                           RiderDeliveryExclusivityService riderDeliveryExclusivityService,
+                                           FlashDeliveryNotificationService notificationService) {
         this.orderMapper = orderMapper;
         this.pricingMapper = pricingMapper;
         this.imageMapper = imageMapper;
@@ -89,6 +93,7 @@ public class FlashDeliveryApplicationService {
         this.pricingCalculator = pricingCalculator;
         this.riderDeliveryLockService = riderDeliveryLockService;
         this.riderDeliveryExclusivityService = riderDeliveryExclusivityService;
+        this.notificationService = notificationService;
     }
 
     /** 闪送首页:返回开放的业务场景、配送等级,以及当前时段各服务的公开运价摘要。 */
@@ -96,7 +101,7 @@ public class FlashDeliveryApplicationService {
         FlashDeliveryHomeView view = new FlashDeliveryHomeView();
         view.setServiceTypes(SERVICE_TYPES);
         view.setDeliveryTypes(List.of("NORMAL", "URGENT"));
-        FlashDeliveryPricing pricing = findPricingAtOrNull(pricingTargetTime(new Date()));
+        FlashDeliveryPricing pricing = findPricingAtOrNull(pricingTargetTime(new Date()), FlashDeliveryVehicleType.MOTORCYCLE);
         view.setServices(pricing == null ? List.of() : SERVICE_TYPES.stream()
                 .map(serviceType -> serviceView(pricing, serviceType)).toList());
         return view;
@@ -114,6 +119,7 @@ public class FlashDeliveryApplicationService {
         FlashDeliveryQuoteView view = new FlashDeliveryQuoteView();
         view.setServiceType(request.getServiceType());
         view.setDeliveryType(request.getDeliveryType());
+        view.setVehicleType(request.getVehicleType());
         view.setDeliveryMode(normalizeDeliveryMode(request.getDeliveryMode()));
         view.setScheduledPickupStartAt(request.getScheduledPickupStartAt());
         view.setScheduledPickupEndAt(request.getScheduledPickupEndAt());
@@ -171,7 +177,9 @@ public class FlashDeliveryApplicationService {
         order.setUserId(userId);
         order.setServiceType(request.getServiceType());
         order.setDeliveryType(request.getDeliveryType());
+        order.setVehicleType(request.getVehicleType());
         order.setStatus(WAITING_ACCEPTANCE);
+        order.setIsDisplay("NOW".equals(deliveryMode));
         order.setPackageType(request.getPackageType());
         order.setQuantity(request.getQuantity());
         order.setWeightRange(request.getWeightRange());
@@ -219,6 +227,9 @@ public class FlashDeliveryApplicationService {
         }
         saveImages(order.getId(), "SENDER", "USER", userId, senderImages, now);
         writeLog(order.getId(), null, WAITING_ACCEPTANCE, "USER", userId, null, now);
+        if (Boolean.TRUE.equals(order.getIsDisplay())) {
+            notificationService.notifyAvailable(order);
+        }
         return participantDetail(order, true, false);
     }
 
@@ -236,7 +247,15 @@ public class FlashDeliveryApplicationService {
 
     /** 查询本人订单详情,仅寄件人和匹配到的收件人可见。 */
     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");
         return participantDetail(order, true, false);
     }
@@ -349,15 +368,18 @@ public class FlashDeliveryApplicationService {
                 && request.getPickup().getLongitude().compareTo(request.getDelivery().getLongitude()) == 0) {
             throw fail("flash.delivery.address.same");
         }
+        // 改址不能把既有机车/轿车订单改按另一车型的路线和时长重新报价。
+        Integer selectedVehicle = vehicleTypeOf(order);
         RouteDistance route = routeService.calculate(
                 new GeoPoint(request.getPickup().getLatitude(), request.getPickup().getLongitude()),
-                new GeoPoint(request.getDelivery().getLatitude(), request.getDelivery().getLongitude()));
+                new GeoPoint(request.getDelivery().getLatitude(), request.getDelivery().getLongitude()),
+                selectedVehicle);
         if (route.distanceMeters() > MAX_DISTANCE_METERS) throw fail("flash.delivery.distance.too.far");
         // 待接单尚未成交,改址按"订单定价时点"的最新运价重算(预约单取预约取件时刻、
         // 立即单取当前),管理员调价对未接单的改址立即生效;时段语义保住,预约单不会用到错误时段的价格。
         Date pricingDate = "SCHEDULED".equals(order.getDeliveryMode())
                 ? order.getScheduledPickupStartAt() : new Date();
-        FlashDeliveryPricing pricing = findPricingAt(pricingTargetTime(pricingDate));
+        FlashDeliveryPricing pricing = findPricingAt(pricingTargetTime(pricingDate), selectedVehicle);
         FlashDeliveryPriceBreakdown breakdown;
         try {
             breakdown = pricingCalculator.calculateBreakdown(pricing, route.distanceMeters(),
@@ -388,6 +410,8 @@ public class FlashDeliveryApplicationService {
             throw fail("flash.delivery.state.changed");
         }
         writeLog(orderId, order.getStatus(), CANCELLED, "USER", userId, reason, now);
+        // 取消成功后通知接单骑手;未接单订单由通知服务内部跳过。
+        notificationService.notifyCancelled(order);
     }
 
     /** 用户(寄件人或收件人)确认收货:已送达推进为已完成。 */
@@ -402,6 +426,23 @@ public class FlashDeliveryApplicationService {
         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 配送中、
      * completed 已送达/已完成、cancelled 已取消)。newTask 页签额外按骑手坐标做距离过滤和就近排序,
@@ -416,11 +457,12 @@ public class FlashDeliveryApplicationService {
         BigDecimal effectiveLongitude = "newTask".equals(selectedTab) ? longitude : null;
         BigDecimal effectiveLatitude = "newTask".equals(selectedTab) ? latitude : null;
         if ("newTask".equals(selectedTab)) {
-            // 新任务派单按骑手选择的配送类型过滤;本人已接订单页签不受限制
+            // 待抢单同时受闪送资质和骑手登记车型限制;本人已接订单页签不受车型过滤影响
             if (!rider.supportsDeliveryType("FLASH")) throw fail("flash.delivery.rider.type.not.enabled");
             validateRiderCoordinates(effectiveLongitude, effectiveLatitude);
         }
-        QueryWrapper<FlashDeliveryOrder> query = riderOrdersQuery(riderId, selectedTab,
+        Integer riderVehicleType = riderVehicleType(rider);
+        QueryWrapper<FlashDeliveryOrder> query = riderOrdersQuery(riderId, riderVehicleType, selectedTab,
                 effectiveLongitude, effectiveLatitude, true);
         IPage<FlashDeliveryOrder> orders = orderMapper.selectPage(page(pageNum, pageSize), query);
         FlashDeliveryRiderOrderPageView result = new FlashDeliveryRiderOrderPageView();
@@ -431,7 +473,7 @@ public class FlashDeliveryApplicationService {
         result.setSize(orders.getSize());
         if ("newTask".equals(selectedTab)) {
             result.setNearbyTaskCount(orders.getTotal());
-            QueryWrapper<FlashDeliveryOrder> maximumQuery = riderOrdersQuery(riderId, selectedTab,
+            QueryWrapper<FlashDeliveryOrder> maximumQuery = riderOrdersQuery(riderId, riderVehicleType, selectedTab,
                     effectiveLongitude, effectiveLatitude, false)
                     .select("MAX(amount)");
             result.setHighestOrderAmount(firstLong(orderMapper.selectObjs(maximumQuery)));
@@ -441,11 +483,21 @@ public class FlashDeliveryApplicationService {
 
     /** 骑手任务详情:抢单前返回脱敏视图(隐藏联系人、电话、坐标和 PIN);接单后仅中单骑手可见完整信息。 */
     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);
-        FlashDeliveryOrder order = requireOrder(orderId);
         if (WAITING_ACCEPTANCE.equals(order.getStatus()) && order.getRiderId() == null) {
-            // 不兼容闪送的骑手按订单不存在处理,避免泄露待抢任务信息。
-            if (!isAvailableAt(order, new Date()) || !rider.supportsDeliveryType("FLASH")) {
+            // 未开通闪送或车型不匹配时按订单不存在处理,避免泄露待抢任务信息。
+            if (!Boolean.TRUE.equals(order.getIsDisplay()) || !isAvailableAt(order, new Date())
+                    || !rider.supportsDeliveryType("FLASH")
+                    || !isRiderVehicleCompatible(rider, order)) {
                 throw fail("flash.delivery.order.not.found");
             }
             // 接单前显示取送文字地址,但隐藏联系人、电话、精确坐标、实际 PIN 和履约图片。
@@ -463,13 +515,20 @@ public class FlashDeliveryApplicationService {
         if (!rider.supportsDeliveryType("FLASH")) throw fail("flash.delivery.rider.type.not.enabled");
         return riderDeliveryLockService.withLock(riderId, () -> {
             FlashDeliveryOrder target = requireOrder(orderId);
-            riderDeliveryExclusivityService.assertCanAcceptFlash(riderId, target.getDeliveryType());
+            // 列表过滤不能替代锁内校验,防止骑手绕过列表直接提交不同车型订单。
+            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");
+            }
+            riderDeliveryExclusivityService.assertCanAcceptFlash(riderId, target.getDeliveryType());
             // Mapper 使用“待接单且 rider_id 为空”的条件更新,受影响行数为 1 才表示抢单成功。
             if (orderMapper.accept(orderId, riderId, now) != 1) {
                 throw fail("flash.delivery.order.already.accepted");
             }
             writeLog(orderId, WAITING_ACCEPTANCE, ACCEPTED, "RIDER", riderId, null, now);
+            notificationService.notifyAccepted(target);
             return participantDetail(requireOrder(orderId), false, false);
         });
     }
@@ -498,7 +557,7 @@ public class FlashDeliveryApplicationService {
     /** 平台运价配置列表,按时段开始时间排序。 */
     public List<FlashDeliveryPricing> adminPricing() {
         return pricingMapper.selectList(new QueryWrapper<FlashDeliveryPricing>()
-                .orderByAsc("start_time", "id"));
+                .orderByAsc("vehicle_type", "start_time", "id"));
     }
 
     /** 新增运价配置:校验参数合法性与时段不重叠后入库,版本号从 1 开始。 */
@@ -529,6 +588,7 @@ public class FlashDeliveryApplicationService {
         Date now = new Date();
         UpdateWrapper<FlashDeliveryPricing> update = new UpdateWrapper<FlashDeliveryPricing>()
                 .eq("id", pricingId).eq("config_version", request.getConfigVersion())
+                .set("vehicle_type", request.getVehicleType())
                 .set("start_time", request.getStartTime())
                 .set("end_time", request.getEndTime())
                 .set("starting_distance", scaleDistance(request.getStartingDistance()))
@@ -542,6 +602,7 @@ public class FlashDeliveryApplicationService {
                 .setSql("config_version = config_version + 1");
         // 版本条件防止两个管理员同时保存时后提交者静默覆盖先提交者。
         if (pricingMapper.update(null, update) != 1) throw fail("flash.delivery.pricing.changed");
+        pricing.setVehicleType(request.getVehicleType());
         pricing.setStartTime(request.getStartTime());
         pricing.setEndTime(request.getEndTime());
         pricing.setStartingDistance(scaleDistance(request.getStartingDistance()));
@@ -605,6 +666,8 @@ public class FlashDeliveryApplicationService {
             throw fail("flash.delivery.state.changed");
         }
         writeLog(orderId, order.getStatus(), CANCELLED, "ADMIN", adminId, reason, now);
+        // 平台取消同样只通知接单骑手;寄件用户按规格不推送。
+        notificationService.notifyCancelled(order);
     }
 
     /** 将已送达订单推进为已完成;平台手动与系统自动完成共用此入口,条件更新失败返回 false。 */
@@ -621,6 +684,36 @@ public class FlashDeliveryApplicationService {
         return orderMapper.selectAutoCompletable(deadline, Math.min(Math.max(limit, 1), 500));
     }
 
+    /** 查询已经错过预约取件结束时间、但仍无人接单的订单,供 Quartz 任务分批处理。 */
+    public List<FlashDeliveryOrder> expiredScheduledCandidates(Date now, int limit) {
+        return orderMapper.selectExpiredScheduled(now, Math.min(Math.max(limit, 1), 500));
+    }
+
+    /** 查询已进入预约窗口但仍未向骑手开放的订单,供独立 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) {
         if (request == null) throw fail("flash.delivery.request.required");
@@ -641,11 +734,14 @@ public class FlashDeliveryApplicationService {
             throw fail("flash.delivery.address.same");
         }
         Date pricingDate = "SCHEDULED".equals(deliveryMode) ? request.getScheduledPickupStartAt() : now;
-        FlashDeliveryPricing pricing = findPricingAt(pricingTargetTime(pricingDate));
+        // 同一个车型同时决定运价和 Google 路线模式,避免机车按轿车路线报价。
+        Integer selectedVehicle = requireVehicleType(request.getVehicleType());
+        FlashDeliveryPricing pricing = findPricingAt(pricingTargetTime(pricingDate), selectedVehicle);
         // 路线服务优先使用 Google Routes;不可用时在服务内部降级为球面直线距离。
         RouteDistance route = routeService.calculate(
                 new GeoPoint(request.getPickup().getLatitude(), request.getPickup().getLongitude()),
-                new GeoPoint(request.getDelivery().getLatitude(), request.getDelivery().getLongitude()));
+                new GeoPoint(request.getDelivery().getLatitude(), request.getDelivery().getLongitude()),
+                selectedVehicle);
         if (route.distanceMeters() > MAX_DISTANCE_METERS) throw fail("flash.delivery.distance.too.far");
         FlashDeliveryPriceBreakdown breakdown = pricingCalculator.calculateBreakdown(pricing,
                 route.distanceMeters(), request.getDeliveryType(), request.getTipAmount());
@@ -653,15 +749,15 @@ public class FlashDeliveryApplicationService {
     }
 
     /** 查询指定时刻命中的运价配置,未配置时直接报错。 */
-    private FlashDeliveryPricing findPricingAt(String targetTime) {
-        FlashDeliveryPricing pricing = findPricingAtOrNull(targetTime);
+    private FlashDeliveryPricing findPricingAt(String targetTime, Integer vehicleType) {
+        FlashDeliveryPricing pricing = findPricingAtOrNull(targetTime, vehicleType);
         if (pricing == null) throw fail("flash.delivery.pricing.not.available");
         return pricing;
     }
 
     /** 查询命中运价;命中多条说明配置重叠,报错交由平台修正。 */
-    private FlashDeliveryPricing findPricingAtOrNull(String targetTime) {
-        List<FlashDeliveryPricing> pricing = pricingMapper.selectAtTime(targetTime);
+    private FlashDeliveryPricing findPricingAtOrNull(String targetTime, Integer vehicleType) {
+        List<FlashDeliveryPricing> pricing = pricingMapper.selectAtTime(targetTime, vehicleType);
         if (pricing == null || pricing.isEmpty()) return null;
         if (pricing.size() > 1) throw fail("flash.delivery.pricing.overlap");
         return pricing.get(0);
@@ -673,7 +769,8 @@ public class FlashDeliveryApplicationService {
         BigDecimal startingDistance = normalizeDistance(request.getStartingDistance());
         BigDecimal distance = normalizeDistance(request.getDistance());
         BigDecimal urgentRate = normalizeRate(request.getUrgentRate());
-        if (!validStartTime(request.getStartTime()) || !validEndTime(request.getEndTime())
+        if (!FlashDeliveryVehicleType.isSupported(request.getVehicleType())
+                || !validStartTime(request.getStartTime()) || !validEndTime(request.getEndTime())
                 || toMinute(request.getStartTime()) >= toMinute(request.getEndTime())
                 || startingDistance == null || startingDistance.signum() <= 0
                 || startingDistance.compareTo(MAX_PRICING_DISTANCE) > 0
@@ -691,7 +788,7 @@ public class FlashDeliveryApplicationService {
 
     /** 确保新时段与既有运价不重叠,excludedId 用于修改时排除自身。 */
     private void ensureNoPricingOverlap(FlashDeliveryPricingRequest request, Long excludedId) {
-        if (pricingMapper.countOverlapping(request.getStartTime(), request.getEndTime(), excludedId) > 0) {
+        if (pricingMapper.countOverlapping(request.getStartTime(), request.getEndTime(), request.getVehicleType(), excludedId) > 0) {
             throw fail("flash.delivery.pricing.overlap");
         }
     }
@@ -699,6 +796,7 @@ public class FlashDeliveryApplicationService {
     /** 将运价请求转换为实体,距离与比例统一保留两位小数。 */
     private FlashDeliveryPricing pricingFrom(FlashDeliveryPricingRequest request) {
         FlashDeliveryPricing pricing = new FlashDeliveryPricing();
+        pricing.setVehicleType(request.getVehicleType());
         pricing.setStartTime(request.getStartTime());
         pricing.setEndTime(request.getEndTime());
         pricing.setStartingDistance(scaleDistance(request.getStartingDistance()));
@@ -746,6 +844,13 @@ public class FlashDeliveryApplicationService {
         }
         saveImages(orderId, proofType, "RIDER", riderId, urls, now);
         writeLog(orderId, expected, next, "RIDER", riderId, null, now);
+        // 状态推进成功后通知寄件用户;重读订单以携带最新归属信息。
+        FlashDeliveryOrder latest = requireOrder(orderId);
+        if (PICKED_UP.equals(next)) {
+            notificationService.notifyPickedUp(latest);
+        } else if (DELIVERED.equals(next)) {
+            notificationService.notifyDelivered(latest);
+        }
     }
 
     /** 状态、图片和日志处于同一事务;任一图片写入失败会回滚本次业务操作。 */
@@ -787,6 +892,12 @@ public class FlashDeliveryApplicationService {
         return urls;
     }
 
+    /** 骑手闪送平均分;尚无评价时按平台骑手展示惯例回退 4.5。 */
+    private Double riderAverageStars(Long riderId) {
+        Double average = orderMapper.selectRiderAverageStars(riderId);
+        return average == null ? 4.5 : average;
+    }
+
     /** 组装用户或骑手视角详情;hideSensitiveFields 为 true 时按抢单前脱敏返回。 */
     private FlashDeliveryOrderDetailView participantDetail(FlashDeliveryOrder order,
                                                            boolean userView,
@@ -795,7 +906,19 @@ public class FlashDeliveryApplicationService {
         if (userView) {
             view.setOrderVersion(order.getVersion());
             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) {
             groupImageUrls(view, imageMapper.selectList(new QueryWrapper<FlashDeliveryOrderImage>()
@@ -811,6 +934,7 @@ public class FlashDeliveryApplicationService {
         view.setOrderNo(order.getOrderNo());
         view.setServiceType(order.getServiceType());
         view.setDeliveryType(order.getDeliveryType());
+        view.setVehicleType(vehicleTypeOf(order));
         view.setStatus(order.getStatus());
         view.setOrderVersion(order.getVersion());
         view.setPackageType(order.getPackageType());
@@ -842,6 +966,7 @@ public class FlashDeliveryApplicationService {
         view.setOrderNo(order.getOrderNo());
         view.setServiceType(order.getServiceType());
         view.setDeliveryType(order.getDeliveryType());
+        view.setVehicleType(vehicleTypeOf(order));
         view.setStatus(order.getStatus());
         view.setPackageType(order.getPackageType());
         view.setQuantity(order.getQuantity());
@@ -874,6 +999,7 @@ public class FlashDeliveryApplicationService {
     private FlashDeliveryOrderDetailView detailOrderView(FlashDeliveryOrder order, boolean includeSensitiveFields) {
         FlashDeliveryOrderDetailView view = new FlashDeliveryOrderDetailView();
         populateOrderView(view, order, includeSensitiveFields);
+        view.setRiderStars(order.getRiderStars());
         return view;
     }
 
@@ -917,6 +1043,7 @@ public class FlashDeliveryApplicationService {
         view.setOrderNo(order.getOrderNo());
         view.setServiceType(order.getServiceType());
         view.setDeliveryType(order.getDeliveryType());
+        view.setVehicleType(vehicleTypeOf(order));
         view.setStatus(order.getStatus());
         view.setPackageType(order.getPackageType());
         view.setQuantity(order.getQuantity());
@@ -973,23 +1100,6 @@ public class FlashDeliveryApplicationService {
         return view;
     }
 
-    /** 组装骑手公开资料;仅已接单或已取件状态附带骑手实时位置。 */
-    private FlashDeliveryRiderSummaryView riderSummary(FlashDeliveryOrder order) {
-        if (order.getRiderId() == null) return null;
-        InfoUser rider = userMapper.selectInfoUserByUserId(order.getRiderId());
-        if (rider == null) return null;
-        FlashDeliveryRiderSummaryView view = new FlashDeliveryRiderSummaryView();
-        view.setName(hasText(rider.getNickName()) ? rider.getNickName() : rider.getUserName());
-        view.setAvatar(rider.getAvatar());
-        view.setRating(rider.getStar());
-        view.setImUserId(rider.getImUserId() == null ? null : String.valueOf(rider.getImUserId()));
-        if (ACCEPTED.equals(order.getStatus()) || PICKED_UP.equals(order.getStatus())) {
-            view.setLongitude(rider.getLongitude());
-            view.setLatitude(rider.getLatitude());
-        }
-        return view;
-    }
-
     /** 把费用明细写入报价视图。 */
     private void applyBreakdown(FlashDeliveryQuoteView view, FlashDeliveryPriceBreakdown breakdown) {
         view.setBillableDistance(breakdown.getBillableDistance());
@@ -1072,6 +1182,15 @@ public class FlashDeliveryApplicationService {
         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,
                           Long operatorId, String reason, Date now) {
@@ -1099,10 +1218,10 @@ public class FlashDeliveryApplicationService {
         }
     }
 
-    /** 校验物品信息:数量、重量范围、规格长度和小费。 */
+    /** 校验物品信息:重量范围和规格均可省略,小费仍必须明确传 0 或正整数。 */
     private void validateItem(FlashDeliveryQuoteRequest request) {
         if (request.getQuantity() == null || request.getQuantity() <= 0
-                || !hasText(request.getWeightRange()) || !WEIGHT_RANGES.contains(request.getWeightRange())
+                || hasText(request.getWeightRange()) && !WEIGHT_RANGES.contains(request.getWeightRange())
                 || tooLong(request.getSpecification(), 255)) {
             throw fail("flash.delivery.item.invalid");
         }
@@ -1111,6 +1230,33 @@ public class FlashDeliveryApplicationService {
         }
     }
 
+    private Integer requireVehicleType(Integer vehicleType) {
+        if (!FlashDeliveryVehicleType.isSupported(vehicleType)) {
+            throw fail("flash.delivery.vehicle.type.invalid");
+        }
+        return vehicleType;
+    }
+
+    /** DDL 执行前存在的旧订单按用户确认的机车规则兼容。 */
+    private Integer vehicleTypeOf(FlashDeliveryOrder order) {
+        return FlashDeliveryVehicleType.isSupported(order.getVehicleType())
+                ? order.getVehicleType() : FlashDeliveryVehicleType.MOTORCYCLE;
+    }
+
+    /** 骑手资料复用既有 vehicleType;历史空值和非法值安全回落为机车。 */
+    private Integer riderVehicleType(InfoUser rider) {
+        try {
+            Integer value = Integer.valueOf(rider.getVehicleType());
+            return FlashDeliveryVehicleType.isSupported(value) ? value : FlashDeliveryVehicleType.MOTORCYCLE;
+        } catch (Exception ignored) {
+            return FlashDeliveryVehicleType.MOTORCYCLE;
+        }
+    }
+
+    private boolean isRiderVehicleCompatible(InfoUser rider, FlashDeliveryOrder order) {
+        return riderVehicleType(rider).equals(vehicleTypeOf(order));
+    }
+
     /** 支付方式与主订单系统同域(4=现金、6=线下转账);闪送款项直达骑手,仅允许线下方式,缺省现金,下单后锁定。 */
     private String normalizePayType(String payType) {
         if (!hasText(payType)) return PAY_TYPE_CASH;
@@ -1158,16 +1304,18 @@ public class FlashDeliveryApplicationService {
     }
 
     /** 按页签构建骑手任务查询:newTask 限定可抢状态与时间窗,可选距离过滤和就近排序。 */
-    private QueryWrapper<FlashDeliveryOrder> riderOrdersQuery(Long riderId, String tab,
+    private QueryWrapper<FlashDeliveryOrder> riderOrdersQuery(Long riderId, Integer riderVehicleType, String tab,
                                                                 BigDecimal longitude, BigDecimal latitude,
                                                                 boolean orderResults) {
         QueryWrapper<FlashDeliveryOrder> query = new QueryWrapper<>();
         Date now = new Date();
         switch (tab) {
             case "newTask" -> {
-                query.eq("status", WAITING_ACCEPTANCE).isNull("rider_id")
+                query.eq("status", WAITING_ACCEPTANCE).isNull("rider_id").eq("is_display", true)
+                        .eq("vehicle_type", riderVehicleType)
                         .and(item -> item.eq("delivery_mode", "NOW")
-                                .or().le("scheduled_pickup_start_at", now));
+                                .or(nested -> nested.le("scheduled_pickup_start_at", now)
+                                        .gt("scheduled_pickup_end_at", now)));
                 if (longitude != null) {
                     Integer limitKm = newTaskDistanceLimit();
                     if (limitKm != null) {
@@ -1270,12 +1418,14 @@ public class FlashDeliveryApplicationService {
         }
     }
 
-    /** 判断订单当前是否开放抢单:立即单随时可抢,预约单到点开放。 */
+    /** 判断订单当前是否开放抢单:立即单随时可抢,预约单仅在开始(含)至结束(不含)之间可抢。 */
     private boolean isAvailableAt(FlashDeliveryOrder order, Date now) {
         return "NOW".equals(order.getDeliveryMode())
                 || "SCHEDULED".equals(order.getDeliveryMode())
                 && order.getScheduledPickupStartAt() != null
-                && !order.getScheduledPickupStartAt().after(now);
+                && order.getScheduledPickupEndAt() != null
+                && !order.getScheduledPickupStartAt().after(now)
+                && order.getScheduledPickupEndAt().after(now);
     }
 
     private void optionalEq(QueryWrapper<FlashDeliveryOrder> query, String column, String value) {

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

+ 7 - 5
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryPricingCalculator.java

@@ -21,9 +21,9 @@ public class FlashDeliveryPricingCalculator {
     }
 
     /**
-     * 费用明细计算:基础配送费 = 起步价 + 距离费;距离费按超起步距离部分、以步长折算的
+     * 费用明细计算:基础配送费仅为起步价;距离费按超起步距离部分、以步长折算的
      * 每公里单价线性计价,不足 0.5 公里不计、0.5 至 1 公里按 1 公里计;加急费仅 URGENT 收取,
-     * 取最低加急费与基础配送费乘加急比例的较大者;总金额 = 基础配送费 + 加急费 + 小费,均为整数 TWD。
+     * 按起步价和距离费之和计算;总金额 = 基础配送费 + 距离费 + 加急费 + 小费,均为整数 TWD。
      */
     public FlashDeliveryPriceBreakdown calculateBreakdown(FlashDeliveryPricing pricing, int distanceMeters,
                                                            String deliveryType, Long tipAmount) {
@@ -60,9 +60,11 @@ public class FlashDeliveryPricingCalculator {
         FlashDeliveryPriceBreakdown result = new FlashDeliveryPriceBreakdown();
         result.setBillableDistance(chargeableDistance.setScale(2, RoundingMode.HALF_UP));
         result.setDistanceFee(distanceFee);
-        long baseDeliveryFee = Math.addExact(pricing.getStartingFare(), distanceFee);
+        // 对外基础配送费固定表示起步价;普通配送费仅作内部计算,不单独落库或返回。
+        long baseDeliveryFee = pricing.getStartingFare();
+        long ordinaryDeliveryFee = Math.addExact(baseDeliveryFee, distanceFee);
         long urgentFee = "URGENT".equals(deliveryType)
-                ? Math.max(pricing.getMinimumUrgentFee(), BigDecimal.valueOf(baseDeliveryFee)
+                ? Math.max(pricing.getMinimumUrgentFee(), BigDecimal.valueOf(ordinaryDeliveryFee)
                 .multiply(pricing.getUrgentRate())
                 .divide(BigDecimal.valueOf(100), 0, RoundingMode.HALF_UP)
                 .longValueExact())
@@ -70,7 +72,7 @@ public class FlashDeliveryPricingCalculator {
         result.setBaseDeliveryFee(baseDeliveryFee);
         result.setUrgentFee(urgentFee);
         result.setTipAmount(tipAmount);
-        result.setAmount(Math.addExact(Math.addExact(baseDeliveryFee, urgentFee), tipAmount));
+        result.setAmount(Math.addExact(Math.addExact(ordinaryDeliveryFee, urgentFee), tipAmount));
         return result;
     }
 }

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

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

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

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

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

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

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

@@ -36,7 +36,7 @@ public class InfoAddressController extends BaseController {
     @Autowired private InfoAddressMapper infoAddressMapper;
     @Autowired private InfoAddressBookService addressBookService;
 
-    /** 返回历史浏览器地图密钥;服务端 Routes 使用另一套不公开的密钥。 */
+    /** 返回既有浏览器地图 Key;闪送 Routes 与外卖共用同一字典配置。 */
     @Anonymous
     @GetMapping("/getGoogleMapKey")
     public AjaxResult getGoogleMapKey() {

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

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

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

@@ -110,6 +110,8 @@ no.order.rider.accept.required=Vui lòng đợi tài xế nhận đơn trước
 no.message.push.delivery.personnel.qspsz.order=Shipper đang giao đến
 no.message.push.delivery.personnel.qsysd.order=Shipper đã giao xong.
 no.message.push.new.order=có lệnh mới
+no.message.push.new.food.order=Có đơn giao đồ ăn mới
+no.message.push.new.flash.order=Có đơn giao hàng nhanh mới
 no.message.push.recharge.success=nạp tiền thành công
 no.message.push.payment.success=thanh toán thành công
 no.message.push.payment.fail=thanh toán thất bại
@@ -309,6 +311,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.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.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.pricing.invalid=Pricing values must be valid positive numbers
 flash.delivery.pricing.not.available=No pricing is available for the current time period
@@ -323,6 +328,7 @@ flash.delivery.scene.invalid=Invalid order status group
 flash.delivery.pricing.not.found=Pricing configuration does not exist
 flash.delivery.pricing.changed=Pricing configuration has changed, please refresh and retry
 flash.delivery.delivery.type.invalid=Unsupported delivery level
+flash.delivery.vehicle.type.invalid=Unsupported vehicle type
 flash.delivery.item.invalid=Item quantity, weight range or specification is invalid
 flash.delivery.tip.invalid=Rider tip must be a non-negative integer TWD amount
 flash.delivery.quote.changed=The quote has changed, please review the latest price
@@ -331,8 +337,10 @@ flash.delivery.coordinates.invalid=Latitude and longitude must be provided in pa
 address.access.denied=Address does not exist or is not accessible
 address.data.invalid=Contact, address, or coordinates are invalid
 flash.delivery.rider.type.not.enabled=当前账号未开通闪送配送
+flash.delivery.rider.vehicle.not.match=The rider vehicle type does not match this order
 flash.delivery.rider.exclusive.conflict=The rider has an active exclusive delivery and cannot accept this order
 flash.delivery.edit.not.allowed=仅待接单且未分配骑手的订单可以修改
 flash.delivery.order.version.invalid=请提供有效的订单版本
 flash.delivery.tip.additional.invalid=追加小费必须为正整数且不能超出金额范围
 flash.delivery.pay.type.invalid=Chiỉ hỗ trợ thanh toán bằng tiền mặt hoặc chuyển khoản ngoại tuyến
+flash.delivery.schedule.not.open=The scheduled pickup window has not started or has already ended

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

@@ -108,6 +108,8 @@ no.message.push.rider.accepted.merchant=A rider has accepted the order. Please c
 no.message.push.order.cancelled=The order has been cancelled
 no.order.rider.accept.required=Please wait for a rider to accept the order before operating it
 no.message.push.new.order=New order
+no.message.push.new.food.order=New food delivery order
+no.message.push.new.flash.order=New flash delivery order
 no.message.push.recharge.success=Recharge successful
 no.message.push.payment.success=Payment successful
 no.message.push.payment.fail=Payment failed
@@ -312,6 +314,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.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.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.pricing.invalid=Pricing values must be valid positive numbers
 flash.delivery.pricing.not.available=No pricing is available for the current time period
@@ -326,6 +331,7 @@ flash.delivery.scene.invalid=Invalid order status group
 flash.delivery.pricing.not.found=Pricing configuration does not exist
 flash.delivery.pricing.changed=Pricing configuration has changed, please refresh and retry
 flash.delivery.delivery.type.invalid=Unsupported delivery level
+flash.delivery.vehicle.type.invalid=Unsupported vehicle type
 flash.delivery.item.invalid=Item quantity, weight range or specification is invalid
 flash.delivery.tip.invalid=Rider tip must be a non-negative integer TWD amount
 flash.delivery.quote.changed=The quote has changed, please review the latest price
@@ -334,8 +340,10 @@ flash.delivery.coordinates.invalid=Latitude and longitude must be provided in pa
 address.access.denied=Address does not exist or is not accessible
 address.data.invalid=Contact, address, or coordinates are invalid
 flash.delivery.rider.type.not.enabled=This account is not enabled for flash delivery orders
+flash.delivery.rider.vehicle.not.match=The rider vehicle type does not match this order
 flash.delivery.rider.exclusive.conflict=The rider has an active exclusive delivery and cannot accept this order
 flash.delivery.edit.not.allowed=Only orders awaiting acceptance with no assigned rider can be edited
 flash.delivery.order.version.invalid=Please provide a valid order version
 flash.delivery.tip.additional.invalid=Additional tip must be a positive integer within the supported amount range
 flash.delivery.pay.type.invalid=Payment method must be cash or offline transfer
+flash.delivery.schedule.not.open=The scheduled pickup window has not started or has already ended

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

@@ -108,6 +108,8 @@ no.message.push.rider.accepted.merchant=ไรเดอร์รับคำส
 no.message.push.order.cancelled=ยกเลิกคำสั่งซื้อแล้ว
 no.order.rider.accept.required=โปรดรอให้ไรเดอร์รับคำสั่งซื้อก่อนดำเนินการ
 no.message.push.new.order=มีคำสั่งซื้อใหม่
+no.message.push.new.food.order=มีออเดอร์ส่งอาหารใหม่
+no.message.push.new.flash.order=มีออเดอร์ส่งด่วนใหม่
 no.message.push.recharge.success=เติมเงินสำเร็จ
 no.message.push.payment.success=ชำระเงินสำเร็จ
 no.message.push.payment.fail=ชำระเงินไม่สำเร็จ
@@ -313,6 +315,9 @@ flash.delivery.transition.not.allowed=สถานะคำสั่งซื้
 flash.delivery.cancel.reason.required=ต้องระบุเหตุผลในการยกเลิก
 flash.delivery.cancel.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.pricing.invalid=ค่ากำหนดราคาต้องเป็นจำนวนบวกที่ถูกต้อง
 flash.delivery.pricing.not.available=ไม่มีอัตราค่าบริการที่ใช้ได้ในช่วงเวลานี้
@@ -327,6 +332,7 @@ flash.delivery.scene.invalid=พารามิเตอร์กลุ่มส
 flash.delivery.pricing.not.found=ไม่พบการตั้งค่าราคา
 flash.delivery.pricing.changed=การตั้งค่าราคาเปลี่ยนแปลงแล้ว โปรดรีเฟรชและลองอีกครั้ง
 flash.delivery.delivery.type.invalid=ไม่รองรับระดับการจัดส่งนี้
+flash.delivery.vehicle.type.invalid=ไม่รองรับประเภทยานพาหนะจัดส่งนี้
 flash.delivery.item.invalid=ข้อมูลจำนวน ช่วงน้ำหนัก หรือรายละเอียดสิ่งของไม่ถูกต้อง
 flash.delivery.tip.invalid=ทิปสำหรับไรเดอร์ต้องเป็นจำนวนเต็ม TWD ที่ไม่ติดลบ
 flash.delivery.quote.changed=ราคาเสนอมีการเปลี่ยนแปลง โปรดยืนยันค่าบริการล่าสุดอีกครั้ง
@@ -335,8 +341,10 @@ flash.delivery.coordinates.invalid=ต้องระบุละติจูด
 address.access.denied=ไม่มีที่อยู่นี้หรือคุณไม่มีสิทธิ์เข้าถึง
 address.data.invalid=ข้อมูลผู้ติดต่อ ที่อยู่ หรือพิกัดไม่ถูกต้อง
 flash.delivery.rider.type.not.enabled=บัญชีนี้ยังไม่ได้เปิดใช้บริการส่งด่วน
+flash.delivery.rider.vehicle.not.match=ประเภทยานพาหนะของไรเดอร์ไม่ตรงกับคำสั่งซื้อนี้
 flash.delivery.rider.exclusive.conflict=ไรเดอร์มีงานจัดส่งแบบเฉพาะที่กำลังดำเนินการ จึงยังไม่สามารถรับคำสั่งซื้อนี้ได้
 flash.delivery.edit.not.allowed=แก้ไขได้เฉพาะคำสั่งซื้อที่รอรับงานและยังไม่มีไรเดอร์
 flash.delivery.order.version.invalid=กรุณาระบุเวอร์ชันคำสั่งซื้อที่ถูกต้อง
 flash.delivery.tip.additional.invalid=ทิปเพิ่มเติมต้องเป็นจำนวนเต็มบวกและไม่เกินวงเงินที่รองรับ
 flash.delivery.pay.type.invalid=รองรับเฉพาะการชำระเงินสดหรือโอนแบบออฟไลน์
+flash.delivery.schedule.not.open=ยังไม่ถึงช่วงเวลานัดรับสินค้า หรือช่วงเวลาดังกล่าวสิ้นสุดแล้ว จึงยังรับออเดอร์นี้ไม่ได้

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

@@ -108,6 +108,8 @@ no.message.push.rider.accepted.merchant=Tài xế đã nhận đơn, vui lòng x
 no.message.push.order.cancelled=Đơn hàng đã bị hủy
 no.order.rider.accept.required=Vui lòng đợi tài xế nhận đơn trước khi thao tác
 no.message.push.new.order=có lệnh mới
+no.message.push.new.food.order=Có đơn giao đồ ăn mới
+no.message.push.new.flash.order=Có đơn giao hàng nhanh mới
 no.message.push.recharge.success=nạp tiền thành công
 no.message.push.payment.success=thanh toán thành công
 no.message.push.payment.fail=thanh toán thất bại
@@ -312,6 +314,9 @@ flash.delivery.transition.not.allowed=Trạng thái hiện tại không cho phé
 flash.delivery.cancel.reason.required=Phải nhập lý do hủy
 flash.delivery.cancel.not.allowed=Trạng thái hiện tại không cho phép hủy theo cách này
 flash.delivery.complete.not.allowed=Đơn hàng chưa thể hoàn tất
+flash.delivery.rating.stars.invalid=Đánh giá tài xế phải từ 1 đến 5
+flash.delivery.rating.not.allowed=Chỉ có thể đánh giá tài xế sau khi đơn hàng hoàn tất
+flash.delivery.rating.already=Tài xế của đơn hàng này đã được đánh giá
 flash.delivery.state.changed=Trạng thái đơn đã thay đổi, vui lòng tải lại và thử lại
 flash.delivery.pricing.invalid=Cấu hình giá phải là số dương hợp lệ
 flash.delivery.pricing.not.available=Không có mức giá khả dụng trong khung giờ hiện tại
@@ -326,6 +331,7 @@ flash.delivery.scene.invalid=Nhóm trạng thái đơn hàng không hợp lệ
 flash.delivery.pricing.not.found=Không tìm thấy cấu hình giá
 flash.delivery.pricing.changed=Cấu hình giá đã thay đổi, vui lòng tải lại và thử lại
 flash.delivery.delivery.type.invalid=Cấp độ giao hàng không được hỗ trợ
+flash.delivery.vehicle.type.invalid=Loại xe giao hàng không được hỗ trợ
 flash.delivery.item.invalid=Số lượng, khoảng trọng lượng hoặc quy cách hàng hóa không hợp lệ
 flash.delivery.tip.invalid=Tiền boa cho tài xế phải là số nguyên TWD không âm
 flash.delivery.quote.changed=Báo giá đã thay đổi, vui lòng xác nhận lại chi phí mới nhất
@@ -334,8 +340,10 @@ flash.delivery.coordinates.invalid=Vĩ độ và kinh độ phải được cung
 address.access.denied=Địa chỉ không tồn tại hoặc không có quyền truy cập
 address.data.invalid=Thông tin liên hệ, địa chỉ hoặc tọa độ không hợp lệ
 flash.delivery.rider.type.not.enabled=Tài khoản này chưa mở giao hàng nhanh
+flash.delivery.rider.vehicle.not.match=Loại xe của tài xế không phù hợp với đơn hàng này
 flash.delivery.rider.exclusive.conflict=Tài xế đang có nhiệm vụ giao hàng độc quyền và tạm thời không thể nhận đơn này
 flash.delivery.edit.not.allowed=Chỉ có thể sửa đơn đang chờ nhận và chưa có tài xế
 flash.delivery.order.version.invalid=Vui lòng cung cấp phiên bản đơn hàng hợp lệ
 flash.delivery.tip.additional.invalid=Tiền boa thêm phải là số nguyên dương trong phạm vi số tiền được hỗ trợ
 flash.delivery.pay.type.invalid=Chiỉ hỗ trợ thanh toán bằng tiền mặt hoặc chuyển khoản ngoại tuyến
+flash.delivery.schedule.not.open=Chưa đến hoặc đã hết thời gian nhận đơn theo lịch hẹn

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

@@ -108,6 +108,8 @@ no.message.push.rider.accepted.merchant=骑手已接单,请及时确认并备
 no.message.push.order.cancelled=订单已取消
 no.order.rider.accept.required=请等待骑手接单后再操作订单
 no.message.push.new.order=有新订单
+no.message.push.new.food.order=有新的外卖订单
+no.message.push.new.flash.order=有新的闪送订单
 no.message.push.recharge.success=充值成功
 no.message.push.payment.success=支付成功
 no.message.push.payment.fail=支付失败
@@ -313,6 +315,9 @@ flash.delivery.transition.not.allowed=当前订单状态不允许此操作
 flash.delivery.cancel.reason.required=取消原因不能为空
 flash.delivery.cancel.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.pricing.invalid=计价配置必须为有效正数
 flash.delivery.pricing.not.available=当前时段暂无可用运价
@@ -327,6 +332,7 @@ flash.delivery.scene.invalid=订单状态分组参数无效
 flash.delivery.pricing.not.found=计价配置不存在
 flash.delivery.pricing.changed=计价配置已发生变化,请刷新后重试
 flash.delivery.delivery.type.invalid=不支持的配送等级
+flash.delivery.vehicle.type.invalid=不支持的配送车型
 flash.delivery.item.invalid=物品数量、重量范围或规格信息不正确
 flash.delivery.tip.invalid=骑手小费必须是非负整数新台币金额
 flash.delivery.quote.changed=报价已变化,请重新确认最新费用
@@ -335,8 +341,10 @@ flash.delivery.coordinates.invalid=经纬度必须成对提供且范围有效
 address.access.denied=地址不存在或无权访问
 address.data.invalid=联系人、地址或经纬度信息无效
 flash.delivery.rider.type.not.enabled=当前账号未开通闪送配送
+flash.delivery.rider.vehicle.not.match=骑手配送车型与该订单不匹配
 flash.delivery.rider.exclusive.conflict=骑手存在进行中的独占配送任务,暂时不能接此订单
 flash.delivery.edit.not.allowed=仅待接单且未分配骑手的订单可以修改
 flash.delivery.order.version.invalid=请提供有效的订单版本
 flash.delivery.tip.additional.invalid=追加小费必须为正整数且不能超出金额范围
 flash.delivery.pay.type.invalid=支付方式仅支持现金或线下转账
+flash.delivery.schedule.not.open=预约取件时间未开始或已结束,暂不能接单

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

@@ -108,6 +108,8 @@ no.message.push.rider.accepted.merchant=騎手已接單,請及時確認並備
 no.message.push.order.cancelled=訂單已取消
 no.order.rider.accept.required=請等待騎手接單後再操作訂單
 no.message.push.new.order=有新訂單
+no.message.push.new.food.order=有新的外送訂單
+no.message.push.new.flash.order=有新的閃送訂單
 no.message.push.recharge.success=充值成功
 no.message.push.payment.success=支付成功
 no.message.push.payment.fail=支付失敗
@@ -313,6 +315,9 @@ flash.delivery.transition.not.allowed=目前訂單狀態不允許此操作
 flash.delivery.cancel.reason.required=取消原因不能為空
 flash.delivery.cancel.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.pricing.invalid=計價設定必須為有效正數
 flash.delivery.pricing.not.available=目前時段暫無可用運價
@@ -327,6 +332,7 @@ flash.delivery.scene.invalid=訂單狀態分組參數無效
 flash.delivery.pricing.not.found=計價設定不存在
 flash.delivery.pricing.changed=計價設定已發生變更,請重新整理後重試
 flash.delivery.delivery.type.invalid=不支援的配送等級
+flash.delivery.vehicle.type.invalid=不支援的配送車型
 flash.delivery.item.invalid=物品數量、重量範圍或規格資訊不正確
 flash.delivery.tip.invalid=騎手小費必須是非負整數新台幣金額
 flash.delivery.quote.changed=報價已變更,請重新確認最新費用
@@ -335,8 +341,10 @@ flash.delivery.coordinates.invalid=經緯度必須成對提供且範圍有效
 address.access.denied=地址不存在或無權存取
 address.data.invalid=聯絡人、地址或經緯度資訊無效
 flash.delivery.rider.type.not.enabled=當前賬號未開通闁送配送
+flash.delivery.rider.vehicle.not.match=騎手配送車型與此訂單不相符
 flash.delivery.rider.exclusive.conflict=騎手存在進行中的獨佔配送任務,暫時不能接此訂單
 flash.delivery.edit.not.allowed=僅待接單且未分配騎手的訂單可以修改
 flash.delivery.order.version.invalid=請提供有效的訂單版本
 flash.delivery.tip.additional.invalid=追加小費必須為正整數且不能超出金額範圍
 flash.delivery.pay.type.invalid=支付方式僅支援現金或線下轉帳
+flash.delivery.schedule.not.open=預約取件時間未開始或已結束,暫不能接單

+ 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.assertTrue;
 import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
 
 class FlashDeliveryControllerContractTest {
@@ -166,6 +167,34 @@ class FlashDeliveryControllerContractTest {
         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
     void addressConfirmationReturnsLatestQuoteForAnotherUserConfirmation() {
         FlashDeliveryApplicationService service = mock(FlashDeliveryApplicationService.class);

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

@@ -18,7 +18,7 @@ class FlashDeliveryOrderViewTest {
                 "pricingVersion", "cancelOperatorId", "cancelOperatorType", "version")) {
             assertFalse(fields.contains(forbidden), forbidden);
         }
-        for (String required : Set.of("serviceType", "deliveryType", "packageType", "quantity",
+        for (String required : Set.of("serviceType", "deliveryType", "vehicleType", "packageType", "quantity",
                 "weightRange", "specification", "deliveryMode",
                 "scheduledPickupStartAt", "pinRequired", "pickup", "delivery",
                 "baseDeliveryFee", "distanceFee", "urgentFee", "tipAmount", "amount")) {
@@ -34,7 +34,7 @@ class FlashDeliveryOrderViewTest {
             assertFalse(fields.contains(forbidden), forbidden);
         }
         for (String required : Set.of("senderImageUrls", "pickupImageUrls",
-                "deliveryImageUrls", "rider", "deliveryPinCode")) {
+                "deliveryImageUrls", "rider", "user", "deliveryPinCode")) {
             assertTrue(fields.contains(required), required);
         }
         assertTrue(FlashDeliveryOrderView.class.isAssignableFrom(FlashDeliveryOrderDetailView.class));

+ 4 - 5
ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryRiderOrderListViewTest.java

@@ -11,12 +11,11 @@ class FlashDeliveryRiderOrderListViewTest {
     void userListContainsOnlyFieldsRequiredByOrderCards() {
         Set<String> fields = Stream.of(FlashDeliveryUserOrderListView.class.getDeclaredFields())
                 .map(java.lang.reflect.Field::getName).collect(Collectors.toSet());
-        Set<String> expected = Set.of("id", "orderNo", "serviceType", "deliveryType", "status", "packageType",
-                "quantity", "weightRange", "baseDeliveryFee", "urgentFee", "tipAmount",
+        Set<String> expected = Set.of("id", "orderNo", "serviceType", "deliveryType", "vehicleType", "status", "packageType",
+                "orderVersion", "quantity", "weightRange", "baseDeliveryFee", "urgentFee", "tipAmount",
                 "deliveryMode", "scheduledPickupStartAt", "scheduledPickupEndAt", "pickupAddress",
                 "pickupDetailAddress", "deliveryAddress", "deliveryDetailAddress",
-                "estimatedDurationSeconds", "amount", "currency", "deliveredAt", "createTime",
-                "orderVersion");
+                "estimatedDurationSeconds", "amount", "currency", "deliveredAt", "createTime");
         org.junit.jupiter.api.Assertions.assertEquals(expected, fields);
     }
 
@@ -24,7 +23,7 @@ class FlashDeliveryRiderOrderListViewTest {
     void riderListContainsOnlyFieldsRequiredByOrderCards() {
         Set<String> fields = Stream.of(FlashDeliveryRiderOrderListView.class.getDeclaredFields())
                 .map(java.lang.reflect.Field::getName).collect(Collectors.toSet());
-        Set<String> expected = Set.of("id", "orderNo", "serviceType", "deliveryType", "status", "packageType",
+        Set<String> expected = Set.of("id", "orderNo", "serviceType", "deliveryType", "vehicleType", "status", "packageType",
                 "quantity", "weightRange", "specification", "deliveryMode",
                 "scheduledPickupStartAt", "scheduledPickupEndAt",
                 "pinRequired", "pickupAddress", "pickupDetailAddress", "deliveryAddress",

+ 47 - 4
ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/route/FlashDeliveryRouteServiceTest.java

@@ -1,21 +1,38 @@
 package com.ruoyi.app.flashdelivery.route;
 
 import org.junit.jupiter.api.Test;
+import com.ruoyi.system.domain.flash.FlashDeliveryVehicleType;
 
 import java.math.BigDecimal;
+import java.util.concurrent.atomic.AtomicReference;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 
 class FlashDeliveryRouteServiceTest {
 
+    @Test
+    void passesMotorcycleSelectionToTheRouteProvider() {
+        AtomicReference<Integer> requestedVehicle = new AtomicReference<>();
+        RouteDistanceProvider provider = (a, b, c, d, vehicleType) -> {
+            requestedVehicle.set(vehicleType);
+            return new RouteDistance(5210, 780, "ROUTE");
+        };
+        FlashDeliveryRouteService service = new FlashDeliveryRouteService(provider);
+
+        service.calculate(point("25.0330", "121.5645"), point("25.0478", "121.5319"),
+                FlashDeliveryVehicleType.MOTORCYCLE);
+
+        assertEquals(FlashDeliveryVehicleType.MOTORCYCLE, requestedVehicle.get());
+    }
+
     @Test
     void keepsDrivingRouteWhenProviderSucceeds() {
-        RouteDistanceProvider provider = (a, b, c, d) -> new RouteDistance(5210, 780, "ROUTE");
+        RouteDistanceProvider provider = (a, b, c, d, vehicleType) -> new RouteDistance(5210, 780, "ROUTE");
         FlashDeliveryRouteService service = new FlashDeliveryRouteService(provider);
 
         RouteDistance result = service.calculate(point("25.0330", "121.5645"),
-                point("25.0478", "121.5319"));
+                point("25.0478", "121.5319"), FlashDeliveryVehicleType.CAR);
 
         assertEquals(5210, result.distanceMeters());
         assertEquals(780, result.durationSeconds());
@@ -24,16 +41,42 @@ class FlashDeliveryRouteServiceTest {
 
     @Test
     void fallsBackToStraightLineWhenProviderFails() {
-        RouteDistanceProvider provider = (a, b, c, d) -> { throw new IllegalStateException("down"); };
+        RouteDistanceProvider provider = (a, b, c, d, vehicleType) -> { throw new IllegalStateException("down"); };
         FlashDeliveryRouteService service = new FlashDeliveryRouteService(provider);
 
         RouteDistance result = service.calculate(point("25.0330", "121.5645"),
-                point("25.0478", "121.5319"));
+                point("25.0478", "121.5319"), FlashDeliveryVehicleType.CAR);
 
         assertTrue(result.distanceMeters() >= 3600 && result.distanceMeters() <= 3800);
         assertEquals("STRAIGHT_LINE", result.source());
     }
 
+    @Test
+    void estimatesStraightLineFallbackDurationBySelectedVehicle() {
+        RouteDistanceProvider provider = (a, b, c, d, vehicleType) -> { throw new IllegalStateException("down"); };
+        FlashDeliveryRouteService service = new FlashDeliveryRouteService(provider);
+
+        RouteDistance motorcycle = service.calculate(point("0", "0"), point("0", "0.01"),
+                FlashDeliveryVehicleType.MOTORCYCLE);
+        RouteDistance car = service.calculate(point("0", "0"), point("0", "0.01"),
+                FlashDeliveryVehicleType.CAR);
+
+        assertEquals("STRAIGHT_LINE", motorcycle.source());
+        assertEquals(161, motorcycle.durationSeconds());
+        assertEquals(134, car.durationSeconds());
+    }
+
+    @Test
+    void keepsStraightLineFallbackDurationAtLeastOneMinute() {
+        RouteDistanceProvider provider = (a, b, c, d, vehicleType) -> { throw new IllegalStateException("down"); };
+        FlashDeliveryRouteService service = new FlashDeliveryRouteService(provider);
+
+        RouteDistance result = service.calculate(point("0", "0"), point("0", "0.000001"),
+                FlashDeliveryVehicleType.MOTORCYCLE);
+
+        assertEquals(60, result.durationSeconds());
+    }
+
     private GeoPoint point(String latitude, String longitude) {
         return new GeoPoint(new BigDecimal(latitude), new BigDecimal(longitude));
     }

+ 21 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/route/GoogleRoutesDistanceProviderTest.java

@@ -0,0 +1,21 @@
+package com.ruoyi.app.flashdelivery.route;
+
+import com.ruoyi.system.domain.flash.FlashDeliveryVehicleType;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+class GoogleRoutesDistanceProviderTest {
+
+    @Test
+    void usesExistingSharedMapKeyDictionaryForRoutes() {
+        assertEquals("sys_googlemap_key", GoogleRoutesDistanceProvider.mapKeyDictType());
+    }
+
+    @Test
+    void mapsUserSelectedVehicleToGoogleTravelMode() {
+        assertEquals("TWO_WHEELER", GoogleRoutesDistanceProvider.travelModeFor(FlashDeliveryVehicleType.MOTORCYCLE));
+        assertEquals("DRIVE", GoogleRoutesDistanceProvider.travelModeFor(FlashDeliveryVehicleType.CAR));
+        assertEquals("TWO_WHEELER", GoogleRoutesDistanceProvider.travelModeFor(null));
+    }
+}

+ 556 - 42
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.FlashDeliveryPricingRequest;
 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.FlashDeliveryRiderOrderPageView;
 import com.ruoyi.app.flashdelivery.exception.FlashDeliveryQuoteChangedException;
@@ -50,11 +51,13 @@ import static com.ruoyi.system.domain.flash.FlashDeliveryStatus.ACCEPTED;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
 import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
 import static org.junit.jupiter.api.Assertions.assertNull;
 import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 import com.ruoyi.common.exception.ServiceException;
 import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
 import static org.mockito.ArgumentMatchers.anyString;
 import static org.mockito.ArgumentMatchers.eq;
 import static org.mockito.ArgumentMatchers.isNull;
@@ -67,6 +70,7 @@ import static org.mockito.Mockito.when;
 import static org.mockito.Mockito.doAnswer;
 import static org.mockito.Mockito.inOrder;
 import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.verifyNoInteractions;
 
 class FlashDeliveryApplicationServiceTest {
     private static ConfigurableListableBeanFactory originalBeanFactory;
@@ -90,8 +94,8 @@ class FlashDeliveryApplicationServiceTest {
     @Test
     void quoteUsesServerRouteAndCurrentTimePricing() {
         Fixture fixture = new Fixture();
-        when(fixture.pricingMapper.selectAtTime(anyString())).thenReturn(List.of(pricing()));
-        when(fixture.routeService.calculate(any(), any())).thenReturn(new RouteDistance(3001, 600, "ROUTE"));
+        when(fixture.pricingMapper.selectAtTime(anyString(), anyInt())).thenReturn(List.of(pricing()));
+        when(fixture.routeService.calculate(any(), any(), anyInt())).thenReturn(new RouteDistance(3001, 600, "ROUTE"));
 
         var quote = fixture.service.quote(request());
 
@@ -106,8 +110,8 @@ class FlashDeliveryApplicationServiceTest {
     @Test
     void quoteRejectsRoutesLongerThanFortyKilometres() {
         Fixture fixture = new Fixture();
-        when(fixture.pricingMapper.selectAtTime(anyString())).thenReturn(List.of(pricing()));
-        when(fixture.routeService.calculate(any(), any())).thenReturn(new RouteDistance(40001, 1800, "ROUTE"));
+        when(fixture.pricingMapper.selectAtTime(anyString(), anyInt())).thenReturn(List.of(pricing()));
+        when(fixture.routeService.calculate(any(), any(), anyInt())).thenReturn(new RouteDistance(40001, 1800, "ROUTE"));
 
         assertThrows(ServiceException.class, () -> fixture.service.quote(request()));
     }
@@ -169,8 +173,8 @@ class FlashDeliveryApplicationServiceTest {
         Fixture fixture = new Fixture();
         when(fixture.userMapper.selectOrdinaryUserIdsByNormalizedPhone("0922222222"))
                 .thenReturn(List.of(88L));
-        when(fixture.pricingMapper.selectAtTime(anyString())).thenReturn(List.of(pricing()));
-        when(fixture.routeService.calculate(any(), any())).thenReturn(new RouteDistance(3200, 600, "ROUTE"));
+        when(fixture.pricingMapper.selectAtTime(anyString(), anyInt())).thenReturn(List.of(pricing()));
+        when(fixture.routeService.calculate(any(), any(), anyInt())).thenReturn(new RouteDistance(3200, 600, "ROUTE"));
         doAnswer(invocation -> {
             FlashDeliveryOrder order = invocation.getArgument(0);
             order.setId(99L);
@@ -244,8 +248,8 @@ class FlashDeliveryApplicationServiceTest {
     @Test
     void createDoesNotBindReceiverWhenNormalizedPhoneMatchesMultipleUsers() {
         Fixture fixture = new Fixture();
-        when(fixture.pricingMapper.selectAtTime(anyString())).thenReturn(List.of(pricing()));
-        when(fixture.routeService.calculate(any(), any())).thenReturn(new RouteDistance(3200, 600, "ROUTE"));
+        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("886912345678"))
                 .thenReturn(List.of(88L, 99L));
         doAnswer(invocation -> {
@@ -266,8 +270,8 @@ class FlashDeliveryApplicationServiceTest {
     @Test
     void createLeavesReceiverUnboundWhenPhoneHasNoRegisteredUser() {
         Fixture fixture = new Fixture();
-        when(fixture.pricingMapper.selectAtTime(anyString())).thenReturn(List.of(pricing()));
-        when(fixture.routeService.calculate(any(), any())).thenReturn(new RouteDistance(3200, 600, "ROUTE"));
+        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 -> {
@@ -336,9 +340,13 @@ class FlashDeliveryApplicationServiceTest {
         var parameters = query.getValue().getParamNameValuePairs().values();
         assertTrue(sql.contains("status"));
         assertTrue(sql.contains("rider_id is null"));
+        assertTrue(sql.contains("is_display"));
+        assertTrue(sql.contains("vehicle_type"));
         assertTrue(sql.contains("scheduled_pickup_start_at"));
+        assertTrue(sql.contains("scheduled_pickup_end_at"));
         assertTrue(sql.contains("st_distance_sphere"));
         assertTrue(parameters.contains("WAITING_ACCEPTANCE"));
+        assertTrue(parameters.contains(1));
         assertTrue(parameters.contains("NOW"));
         assertTrue(parameters.stream().anyMatch(Date.class::isInstance));
 
@@ -348,7 +356,9 @@ class FlashDeliveryApplicationServiceTest {
         String summarySql = summaryQuery.getValue().getSqlSegment().toLowerCase();
         var summaryParameters = summaryQuery.getValue().getParamNameValuePairs().values();
         assertTrue(summarySql.contains("rider_id is null"));
+        assertTrue(summarySql.contains("vehicle_type"));
         assertTrue(summarySql.contains("scheduled_pickup_start_at"));
+        assertTrue(summarySql.contains("scheduled_pickup_end_at"));
         assertTrue(summarySql.contains("st_distance_sphere"));
         assertTrue(summaryParameters.contains("WAITING_ACCEPTANCE"));
     }
@@ -501,9 +511,11 @@ class FlashDeliveryApplicationServiceTest {
         assertNull(result.getDelivery().getLatitude());
         assertNull(result.getDeliveryPinCode());
         assertNull(result.getUserNote());
+        assertNull(result.getUser());
         assertTrue(result.getSenderImageUrls().isEmpty());
         assertTrue(result.getPickupImageUrls().isEmpty());
         assertTrue(result.getDeliveryImageUrls().isEmpty());
+        verify(fixture.userMapper, never()).selectInfoUserByUserId(7L);
         verify(fixture.imageMapper, never()).selectList(any());
         verify(fixture.logMapper, never()).selectList(any());
     }
@@ -531,6 +543,53 @@ class FlashDeliveryApplicationServiceTest {
         verify(fixture.logMapper, never()).selectList(any());
     }
 
+    @Test
+    void userDetailReturnsRiderInfoAfterAcceptance() {
+        Fixture fixture = new Fixture();
+        FlashDeliveryOrder order = order(10L, ACCEPTED);
+        order.setRiderId(8L);
+        when(fixture.orderMapper.selectById(10L)).thenReturn(order);
+        InfoUser rider = new InfoUser();
+        rider.setUserId(8L);
+        rider.setUserType("2");
+        rider.setNickName("闪送骑士");
+        rider.setTelPhone("0933333333");
+        rider.setVehicleType("2");
+        rider.setLicensePlate("ABC-1234");
+        when(fixture.userMapper.selectInfoUserByUserId(8L)).thenReturn(rider);
+
+        var result = fixture.service.userDetail(7L, 10L);
+
+        assertEquals(8L, result.getRider().getUserId());
+        assertEquals("闪送骑士", result.getRider().getNickName());
+        assertEquals("0933333333", result.getRider().getTelPhone());
+        assertEquals("ABC-1234", result.getRider().getLicensePlate());
+        assertNull(result.getUser());
+    }
+
+    @Test
+    void assignedRiderDetailReturnsSenderInfo() {
+        Fixture fixture = new Fixture();
+        rider(fixture, 8L);
+        FlashDeliveryOrder order = order(10L, ACCEPTED);
+        order.setRiderId(8L);
+        when(fixture.orderMapper.selectById(10L)).thenReturn(order);
+        InfoUser sender = new InfoUser();
+        sender.setUserId(7L);
+        sender.setNickName("寄件达人");
+        sender.setPhone("0911111111");
+        sender.setAvatar("https://example.com/avatar.png");
+        when(fixture.userMapper.selectInfoUserByUserId(7L)).thenReturn(sender);
+
+        var result = fixture.service.riderDetail(8L, 10L);
+
+        assertEquals(7L, result.getUser().getUserId());
+        assertEquals("寄件达人", result.getUser().getNickName());
+        assertEquals("0911111111", result.getUser().getPhone());
+        assertEquals("https://example.com/avatar.png", result.getUser().getAvatar());
+        assertNull(result.getRider());
+    }
+
     @Test
     void matchedReceiverCanViewAndConfirmButCannotCancelOrder() {
         Fixture fixture = new Fixture();
@@ -549,6 +608,188 @@ class FlashDeliveryApplicationServiceTest {
                 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
     void orderWithoutCreationTimeReceiverBindingIsNotClaimedLater() {
         Fixture fixture = new Fixture();
@@ -589,6 +830,22 @@ class FlashDeliveryApplicationServiceTest {
         assertThrows(RuntimeException.class, () -> fixture.service.accept(8L, 10L));
     }
 
+    // 预约单已过取件结束时间:即使骑手绕过列表直接持有订单 ID 请求接单,锁内校验也必须拒绝。
+    @Test
+    void expiredScheduledOrderCannotBeAcceptedEvenWhenItsIdIsKnown() {
+        Fixture fixture = new Fixture();
+        rider(fixture, 8L);
+        FlashDeliveryOrder order = order(10L, "WAITING_ACCEPTANCE");
+        Date now = new Date();
+        order.setDeliveryMode("SCHEDULED");
+        order.setScheduledPickupStartAt(new Date(now.getTime() - 31L * 60L * 1000L));
+        order.setScheduledPickupEndAt(new Date(now.getTime() - 60L * 1000L));
+        when(fixture.orderMapper.selectById(10L)).thenReturn(order);
+
+        assertThrows(ServiceException.class, () -> fixture.service.accept(8L, 10L));
+        verify(fixture.orderMapper, never()).accept(any(), any(), any());
+    }
+
     @Test
     void flashAcceptUsesRiderLockAndChecksExclusivityBeforeAtomicUpdate() {
         Fixture fixture = new Fixture();
@@ -620,6 +877,202 @@ class FlashDeliveryApplicationServiceTest {
         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);
+        when(fixture.orderMapper.transitionByRider(eq(10L), eq(8L), eq("ACCEPTED"),
+                eq("PICKED_UP"), eq("picked_up_at"), 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.cancel(eq(10L), eq("PICKED_UP"), eq("ADMIN"), eq(1L),
+                anyString(), any(Date.class))).thenReturn(1);
+
+        fixture.service.adminCancel(1L, 10L, reason("异常收口"));
+
+        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));
+
+        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
     void oversizedProofUrlIsRejectedBeforeStateTransition() {
         Fixture fixture = new Fixture();
@@ -655,7 +1108,7 @@ class FlashDeliveryApplicationServiceTest {
     void concurrentPricingUpdateIsRejected() {
         Fixture fixture = new Fixture();
         when(fixture.pricingMapper.selectById(1L)).thenReturn(pricing());
-        when(fixture.pricingMapper.countOverlapping("00:00", "12:00", 1L)).thenReturn(0);
+        when(fixture.pricingMapper.countOverlapping("00:00", "12:00", 1, 1L)).thenReturn(0);
         when(fixture.pricingMapper.update(isNull(), any(Wrapper.class))).thenReturn(0);
         FlashDeliveryPricingRequest request = pricingRequest();
         request.setEndTime("12:00");
@@ -670,7 +1123,7 @@ class FlashDeliveryApplicationServiceTest {
     void overlappingPricingPeriodIsRejectedBeforeInsert() {
         Fixture fixture = new Fixture();
         FlashDeliveryPricingRequest request = pricingRequest();
-        when(fixture.pricingMapper.countOverlapping("00:00", "24:00", null)).thenReturn(1);
+        when(fixture.pricingMapper.countOverlapping("00:00", "24:00", 1, null)).thenReturn(1);
 
         assertThrows(ServiceException.class, () -> fixture.service.createPricing(1L, request));
 
@@ -708,7 +1161,7 @@ class FlashDeliveryApplicationServiceTest {
     void createPricingPersistsImmediatelyActiveTimePeriod() {
         Fixture fixture = new Fixture();
         FlashDeliveryPricingRequest request = pricingRequest();
-        when(fixture.pricingMapper.countOverlapping("00:00", "24:00", null)).thenReturn(0);
+        when(fixture.pricingMapper.countOverlapping("00:00", "24:00", 1, null)).thenReturn(0);
 
         fixture.service.createPricing(7L, request);
 
@@ -720,7 +1173,7 @@ class FlashDeliveryApplicationServiceTest {
 
         InOrder writeOrder = inOrder(fixture.pricingMapper);
         writeOrder.verify(fixture.pricingMapper)
-                .countOverlapping("00:00", "24:00", null);
+                .countOverlapping("00:00", "24:00", 1, null);
         writeOrder.verify(fixture.pricingMapper).insert(any(FlashDeliveryPricing.class));
     }
 
@@ -737,18 +1190,18 @@ class FlashDeliveryApplicationServiceTest {
 
         assertThrows(ServiceException.class, () -> fixture.service.quote(request()));
 
-        verify(fixture.routeService, never()).calculate(any(), any());
+        verify(fixture.routeService, never()).calculate(any(), any(), anyInt());
     }
 
     @Test
     void multipleCurrentPricingPeriodsFailExplicitlyBeforeRouteLookup() {
         Fixture fixture = new Fixture();
-        when(fixture.pricingMapper.selectAtTime(anyString()))
+        when(fixture.pricingMapper.selectAtTime(anyString(), anyInt()))
                 .thenReturn(List.of(pricing(), pricing()));
 
         assertThrows(ServiceException.class, () -> fixture.service.quote(request()));
 
-        verify(fixture.routeService, never()).calculate(any(), any());
+        verify(fixture.routeService, never()).calculate(any(), any(), anyInt());
     }
 
     @Test
@@ -759,12 +1212,14 @@ class FlashDeliveryApplicationServiceTest {
         when(fixture.pricingMapper.update(isNull(), any(Wrapper.class))).thenReturn(1);
         FlashDeliveryPricingRequest request = pricingRequest();
         request.setConfigVersion(1);
+        request.setVehicleType(2);
 
-        fixture.service.updatePricing(9L, 1L, request);
+        FlashDeliveryPricing updated = fixture.service.updatePricing(9L, 1L, request);
+        assertEquals(2, updated.getVehicleType());
 
         InOrder writeOrder = inOrder(fixture.pricingMapper);
         writeOrder.verify(fixture.pricingMapper)
-                .countOverlapping("00:00", "24:00", 1L);
+                .countOverlapping("00:00", "24:00", 2, 1L);
         writeOrder.verify(fixture.pricingMapper).update(isNull(), any(Wrapper.class));
     }
 
@@ -782,8 +1237,8 @@ class FlashDeliveryApplicationServiceTest {
     @Test
     void urgentQuoteReturnsSeparatedFeesAndUsesScheduledPickupTime() {
         Fixture fixture = new Fixture();
-        when(fixture.pricingMapper.selectAtTime("16:30")).thenReturn(List.of(pricing()));
-        when(fixture.routeService.calculate(any(), any())).thenReturn(new RouteDistance(6000, 900, "ROUTE"));
+        when(fixture.pricingMapper.selectAtTime("16:30", 1)).thenReturn(List.of(pricing()));
+        when(fixture.routeService.calculate(any(), any(), anyInt())).thenReturn(new RouteDistance(6000, 900, "ROUTE"));
         FlashDeliveryQuoteRequest request = request();
         request.setDeliveryType("URGENT");
         request.setTipAmount(5L);
@@ -800,18 +1255,18 @@ class FlashDeliveryApplicationServiceTest {
         var quote = fixture.service.quote(request);
 
         assertEquals(45L, quote.getDistanceFee());
-        assertEquals(135L, quote.getBaseDeliveryFee());
+        assertEquals(90L, quote.getBaseDeliveryFee());
         assertEquals(27L, quote.getUrgentFee());
         assertEquals(5L, quote.getTipAmount());
         assertEquals(167L, quote.getAmount());
-        verify(fixture.pricingMapper).selectAtTime("16:30");
+        verify(fixture.pricingMapper).selectAtTime("16:30", 1);
     }
 
     @Test
     void createRejectsChangedQuoteAndReturnsLatestQuoteWithoutInsert() {
         Fixture fixture = new Fixture();
-        when(fixture.pricingMapper.selectAtTime(anyString())).thenReturn(List.of(pricing()));
-        when(fixture.routeService.calculate(any(), any())).thenReturn(new RouteDistance(3200, 600, "ROUTE"));
+        when(fixture.pricingMapper.selectAtTime(anyString(), anyInt())).thenReturn(List.of(pricing()));
+        when(fixture.routeService.calculate(any(), any(), anyInt())).thenReturn(new RouteDistance(3200, 600, "ROUTE"));
         FlashDeliveryCreateRequest request = createRequest();
         request.setQuotedAmount(91L);
 
@@ -826,8 +1281,8 @@ class FlashDeliveryApplicationServiceTest {
     @Test
     void createDefaultsPayTypeToCashAndStoresTransferSelection() {
         Fixture fixture = new Fixture();
-        when(fixture.pricingMapper.selectAtTime(anyString())).thenReturn(List.of(pricing()));
-        when(fixture.routeService.calculate(any(), any())).thenReturn(new RouteDistance(3200, 600, "ROUTE"));
+        when(fixture.pricingMapper.selectAtTime(anyString(), anyInt())).thenReturn(List.of(pricing()));
+        when(fixture.routeService.calculate(any(), any(), anyInt())).thenReturn(new RouteDistance(3200, 600, "ROUTE"));
         doAnswer(invocation -> {
             FlashDeliveryOrder order = invocation.getArgument(0);
             order.setId(99L);
@@ -853,8 +1308,8 @@ class FlashDeliveryApplicationServiceTest {
     void createPassesThroughPayTypeAsProvided() {
         // 05cc7e4 放宽 normalizePayType:不再限定 4/6,任意 payType 透传存储(缺省仍为现金)。
         Fixture fixture = new Fixture();
-        when(fixture.pricingMapper.selectAtTime(anyString())).thenReturn(List.of(pricing()));
-        when(fixture.routeService.calculate(any(), any())).thenReturn(new RouteDistance(3200, 600, "ROUTE"));
+        when(fixture.pricingMapper.selectAtTime(anyString(), anyInt())).thenReturn(List.of(pricing()));
+        when(fixture.routeService.calculate(any(), any(), anyInt())).thenReturn(new RouteDistance(3200, 600, "ROUTE"));
         FlashDeliveryCreateRequest request = createRequest();
         request.setPayType("2");
 
@@ -887,8 +1342,8 @@ class FlashDeliveryApplicationServiceTest {
     @Test
     void quoteAcceptsAllFourWeightRanges() {
         Fixture fixture = new Fixture();
-        when(fixture.pricingMapper.selectAtTime(anyString())).thenReturn(List.of(pricing()));
-        when(fixture.routeService.calculate(any(), any())).thenReturn(new RouteDistance(3200, 600, "ROUTE"));
+        when(fixture.pricingMapper.selectAtTime(anyString(), anyInt())).thenReturn(List.of(pricing()));
+        when(fixture.routeService.calculate(any(), any(), anyInt())).thenReturn(new RouteDistance(3200, 600, "ROUTE"));
 
         for (String weightRange : List.of("UP_TO_5_KG", "OVER_5_TO_10_KG",
                 "OVER_10_TO_15_KG", "OVER_15_TO_20_KG")) {
@@ -904,8 +1359,8 @@ class FlashDeliveryApplicationServiceTest {
         for (String weightRange : List.of("UP_TO_5_KG", "OVER_5_TO_10_KG",
                 "OVER_10_TO_15_KG", "OVER_15_TO_20_KG")) {
             Fixture fixture = new Fixture();
-            when(fixture.pricingMapper.selectAtTime(anyString())).thenReturn(List.of(pricing()));
-            when(fixture.routeService.calculate(any(), any())).thenReturn(new RouteDistance(3200, 600, "ROUTE"));
+            when(fixture.pricingMapper.selectAtTime(anyString(), anyInt())).thenReturn(List.of(pricing()));
+            when(fixture.routeService.calculate(any(), any(), anyInt())).thenReturn(new RouteDistance(3200, 600, "ROUTE"));
             doAnswer(invocation -> {
                 FlashDeliveryOrder order = invocation.getArgument(0);
                 order.setId(99L);
@@ -935,12 +1390,45 @@ class FlashDeliveryApplicationServiceTest {
         }
     }
 
+    @Test
+    void quoteUsesSelectedVehiclePricingAndRejectsUnsupportedVehicle() {
+        Fixture fixture = new Fixture();
+        FlashDeliveryPricing carPricing = pricing();
+        carPricing.setId(2L);
+        carPricing.setVehicleType(2);
+        carPricing.setStartingFare(180L);
+        when(fixture.pricingMapper.selectAtTime(anyString(), eq(2))).thenReturn(List.of(carPricing));
+        when(fixture.routeService.calculate(any(), any(), anyInt())).thenReturn(new RouteDistance(3200, 600, "ROUTE"));
+
+        FlashDeliveryQuoteRequest carRequest = request();
+        carRequest.setVehicleType(2);
+        assertEquals(180L, fixture.service.quote(carRequest).getAmount());
+        verify(fixture.routeService).calculate(any(), any(), eq(2));
+
+        FlashDeliveryQuoteRequest missingVehicle = request();
+        missingVehicle.setVehicleType(null);
+        assertThrows(ServiceException.class, () -> fixture.service.quote(missingVehicle));
+    }
+
+    @Test
+    void quoteAllowsMissingWeightRangeAndSpecification() {
+        Fixture fixture = new Fixture();
+        when(fixture.pricingMapper.selectAtTime(anyString(), eq(1))).thenReturn(List.of(pricing()));
+        when(fixture.routeService.calculate(any(), any(), anyInt())).thenReturn(new RouteDistance(3200, 600, "ROUTE"));
+        FlashDeliveryQuoteRequest request = request();
+        request.setWeightRange(null);
+        request.setSpecification(null);
+
+        assertDoesNotThrow(() -> fixture.service.quote(request));
+    }
+
     private FlashDeliveryQuoteRequest request() {
         FlashDeliveryQuoteRequest request = new FlashDeliveryQuoteRequest();
         request.setServiceType("HELP_SEND");
         request.setDeliveryType("NORMAL");
         request.setPackageType("DOCUMENT");
         request.setQuantity(1);
+        request.setVehicleType(1);
         request.setWeightRange("UP_TO_5_KG");
         request.setSpecification("30 x 20 x 10 cm");
         request.setTipAmount(0L);
@@ -956,6 +1444,7 @@ class FlashDeliveryApplicationServiceTest {
         FlashDeliveryQuoteRequest quote = request();
         request.setServiceType(quote.getServiceType());
         request.setDeliveryType(quote.getDeliveryType());
+        request.setVehicleType(quote.getVehicleType());
         request.setPackageType(quote.getPackageType());
         request.setQuantity(quote.getQuantity());
         request.setWeightRange(quote.getWeightRange());
@@ -979,6 +1468,18 @@ class FlashDeliveryApplicationServiceTest {
         return request;
     }
 
+    private void prepareCreate(Fixture fixture) {
+        when(fixture.pricingMapper.selectAtTime(anyString(), anyInt())).thenReturn(List.of(pricing()));
+        when(fixture.routeService.calculate(any(), any(), anyInt()))
+                .thenReturn(new RouteDistance(3200, 600, "ROUTE"));
+        when(fixture.userMapper.selectOrdinaryUserIdsByNormalizedPhone("0922222222")).thenReturn(List.of());
+        doAnswer(invocation -> {
+            FlashDeliveryOrder order = invocation.getArgument(0);
+            order.setId(99L);
+            return 1;
+        }).when(fixture.orderMapper).insert(any(FlashDeliveryOrder.class));
+    }
+
     private FlashDeliveryAddressRequest address(String name, String phone, String lat, String lon) {
         FlashDeliveryAddressRequest address = new FlashDeliveryAddressRequest();
         address.setName(name);
@@ -993,6 +1494,7 @@ class FlashDeliveryApplicationServiceTest {
     private FlashDeliveryPricing pricing() {
         FlashDeliveryPricing pricing = new FlashDeliveryPricing();
         pricing.setId(1L);
+        pricing.setVehicleType(1);
         pricing.setStartTime("00:00");
         pricing.setEndTime("24:00");
         pricing.setStartingDistance(new BigDecimal("3.00"));
@@ -1007,6 +1509,7 @@ class FlashDeliveryApplicationServiceTest {
 
     private FlashDeliveryPricingRequest pricingRequest() {
         FlashDeliveryPricingRequest request = new FlashDeliveryPricingRequest();
+        request.setVehicleType(1);
         request.setStartTime("00:00");
         request.setEndTime("24:00");
         request.setStartingDistance(new BigDecimal("3.00"));
@@ -1043,9 +1546,17 @@ class FlashDeliveryApplicationServiceTest {
         rider.setUserId(riderId);
         rider.setUserType("2");
         rider.setDeliveryType("FLASH");
+        rider.setVehicleType("1");
         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) {
         FlashDeliveryOrder order = new FlashDeliveryOrder();
         order.setId(id);
@@ -1054,6 +1565,7 @@ class FlashDeliveryApplicationServiceTest {
         order.setServiceType("HELP_SEND");
         order.setDeliveryType("NORMAL");
         order.setStatus(status);
+        order.setIsDisplay(true);
         order.setPackageType("DOCUMENT");
         order.setQuantity(1);
         order.setWeightRange("UP_TO_5_KG");
@@ -1125,14 +1637,14 @@ class FlashDeliveryApplicationServiceTest {
         assertEquals(4500, quote.getDistanceMeters());
         assertEquals(900, quote.getEstimatedDurationSeconds());
         assertEquals(23L, quote.getDistanceFee());
-        assertEquals(113L, quote.getBaseDeliveryFee());
+        assertEquals(90L, quote.getBaseDeliveryFee());
         assertEquals(10L, quote.getTipAmount());
         assertEquals(123L, quote.getAmount());
         assertEquals(2, quote.getOrderVersion());
         assertEquals(1, quote.getPricingVersion());
         assertEquals("pickup road", order.getPickupAddress());
         // 改址报价必须查询运价表(按定价时点取最新),不再沿用订单快照
-        verify(fixture.pricingMapper).selectAtTime(anyString());
+        verify(fixture.pricingMapper).selectAtTime(anyString(), anyInt());
         verify(fixture.orderMapper, never()).updateWaitingAddress(any(), any());
         verify(fixture.logMapper, never()).insert(any(com.ruoyi.system.domain.flash.FlashDeliveryOrderLog.class));
     }
@@ -1214,7 +1726,7 @@ class FlashDeliveryApplicationServiceTest {
             assertThrows(ServiceException.class, () -> fixture.service.updateAddress(user, 10L, new FlashDeliveryAddressConfirmRequest()));
             assertThrows(ServiceException.class, () -> fixture.service.addTip(user, 10L, tipRequest(5L, 2)));
         }
-        verify(fixture.routeService, never()).calculate(any(), any());
+        verify(fixture.routeService, never()).calculate(any(), any(), anyInt());
         verify(fixture.orderMapper, never()).updateWaitingTip(any(), any());
     }
 
@@ -1258,7 +1770,7 @@ class FlashDeliveryApplicationServiceTest {
         input.setDelivery(request().getDelivery());
         input.getPickup().setPhone("");
         assertThrows(ServiceException.class, () -> fixture.service.quoteAddress(7L, 10L, input));
-        when(fixture.routeService.calculate(any(), any())).thenReturn(new RouteDistance(40001, 1000, "GOOGLE"));
+        when(fixture.routeService.calculate(any(), any(), anyInt())).thenReturn(new RouteDistance(40001, 1000, "GOOGLE"));
         assertThrows(ServiceException.class, () -> fixture.service.quoteAddress(7L, 10L, addressChange()));
         verify(fixture.orderMapper, never()).updateWaitingAddress(any(), any());
     }
@@ -1289,8 +1801,8 @@ class FlashDeliveryApplicationServiceTest {
         assertThrows(ServiceException.class, () -> fixture.service.addTip(7L, 10L, tipRequest(20L, 2)));
         assertEquals(30L, order.getTipAmount());
         verify(fixture.orderMapper).updateWaitingTip(any(), eq(2));
-        verify(fixture.routeService, never()).calculate(any(), any());
-        verify(fixture.pricingMapper, never()).selectAtTime(anyString());
+        verify(fixture.routeService, never()).calculate(any(), any(), anyInt());
+        verify(fixture.pricingMapper, never()).selectAtTime(anyString(), anyInt());
     }
 
     @Test
@@ -1362,9 +1874,9 @@ class FlashDeliveryApplicationServiceTest {
         order.setDistance(new BigDecimal("1.00"));
         order.setFreight(15L);
         when(fixture.orderMapper.selectById(10L)).thenReturn(order);
-        when(fixture.routeService.calculate(any(), any())).thenReturn(new RouteDistance(4500, 900, "GOOGLE"));
+        when(fixture.routeService.calculate(any(), any(), anyInt())).thenReturn(new RouteDistance(4500, 900, "GOOGLE"));
         // 031 后改址按订单定价时点读最新运价:stub 与订单快照同值,金额断言维持不变
-        when(fixture.pricingMapper.selectAtTime(anyString())).thenReturn(List.of(pricing()));
+        when(fixture.pricingMapper.selectAtTime(anyString(), anyInt())).thenReturn(List.of(pricing()));
         return order;
     }
 
@@ -1407,9 +1919,11 @@ class FlashDeliveryApplicationServiceTest {
         final FlashDeliveryRouteService routeService = mock(FlashDeliveryRouteService.class);
         final RiderDeliveryLockService lockService = mock(RiderDeliveryLockService.class);
         final RiderDeliveryExclusivityService exclusivityService = mock(RiderDeliveryExclusivityService.class);
+        final FlashDeliveryNotificationService notificationService = mock(FlashDeliveryNotificationService.class);
         final FlashDeliveryApplicationService service = new FlashDeliveryApplicationService(
                 orderMapper, pricingMapper, imageMapper, logMapper, userMapper,
-                routeService, new FlashDeliveryPricingCalculator(), lockService, exclusivityService);
+                routeService, new FlashDeliveryPricingCalculator(), lockService, exclusivityService,
+                notificationService);
 
         Fixture() {
             when(lockService.withLock(any(), any())).thenAnswer(invocation -> {

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

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

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

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

@@ -53,11 +53,11 @@ class FlashDeliveryPricingCalculatorTest {
     }
 
     @Test
-    void calculatesUrgentFeeFromBaseDeliveryFeeAndAddsTipSeparately() {
+    void exposesStartingFareAsBaseDeliveryFeeAndAddsDistanceFeeSeparately() {
         var breakdown = calculator.calculateBreakdown(pricing(), 6000, "URGENT", 5L);
 
         assertEquals(45L, breakdown.getDistanceFee());
-        assertEquals(135L, breakdown.getBaseDeliveryFee());
+        assertEquals(90L, breakdown.getBaseDeliveryFee());
         assertEquals(27L, breakdown.getUrgentFee());
         assertEquals(5L, breakdown.getTipAmount());
         assertEquals(167L, breakdown.getAmount());

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

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

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

+ 8 - 1
ruoyi-system/src/main/java/com/ruoyi/system/domain/flash/FlashDeliveryOrder.java

@@ -29,8 +29,12 @@ public class FlashDeliveryOrder {
     private String serviceType;
     /** 配送等级:NORMAL=普通、URGENT=一对一加急。 */
     private String deliveryType;
+    /** 用户下单选择的车型快照:1=机车,2=轿车。 */
+    private Integer vehicleType;
     /** 订单状态,取值见 FlashDeliveryStatus。 */
     private String status;
+    /** 是否已向骑手开放展示;立即单创建即开放,预约单到达时间窗后开放。 */
+    private Boolean isDisplay;
     /** 用户申报的包裹类别和重量范围。 */
     private String packageType;
     private Integer quantity;
@@ -98,7 +102,7 @@ public class FlashDeliveryOrder {
     private Long freight;
     /** 距离附加费(TWD),按超起步距离部分以每公里单价计算。 */
     private Long distanceFee;
-    /** 基础配送费(TWD),等于起步价加距离费。 */
+    /** 基础配送费(TWD),仅表示起步价,不含距离附加费。 */
     private Long baseDeliveryFee;
     /** 加急费比例(百分比)。 */
     private BigDecimal urgentRate;
@@ -120,6 +124,9 @@ public class FlashDeliveryOrder {
     private Date completedAt;
     private Date cancelledAt;
 
+    /** 寄件人对骑手的评分 1-5,未评为 NULL;仅完成态订单可评一次。 */
+    private Double riderStars;
+
     /** 取消审计信息,操作人类型可为 USER 或 ADMIN。 */
     private String cancelReason;
     private String cancelOperatorType;

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

@@ -15,6 +15,8 @@ public class FlashDeliveryPricing {
     @TableId(type = IdType.AUTO)
     /** 主键 ID,数据库自增。 */
     private Long id;
+    /** 车型:1=机车,2=轿车。 */
+    private Integer vehicleType;
     /** 时段开始时间(HH:mm,包含该时刻)。 */
     private String startTime;
     /** 时段结束时间(HH:mm,不包含该时刻,24:00 表示收尾到全天结束)。 */

+ 13 - 0
ruoyi-system/src/main/java/com/ruoyi/system/domain/flash/FlashDeliveryVehicleType.java

@@ -0,0 +1,13 @@
+package com.ruoyi.system.domain.flash;
+
+/** 闪送车型代码:接口与数据库固定使用 1=机车、2=轿车。 */
+public final class FlashDeliveryVehicleType {
+    public static final int MOTORCYCLE = 1;
+    public static final int CAR = 2;
+
+    private FlashDeliveryVehicleType() { }
+
+    public static boolean isSupported(Integer value) {
+        return value != null && (value == MOTORCYCLE || value == CAR);
+    }
+}

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

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

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

+ 8 - 4
ruoyi-system/src/main/java/com/ruoyi/system/mapper/flash/FlashDeliveryPricingMapper.java

@@ -9,27 +9,31 @@ import java.util.List;
 
 /** 闪送时段运价持久化接口。 */
 public interface FlashDeliveryPricingMapper extends BaseMapper<FlashDeliveryPricing> {
-    /** 查询指定时刻(HH:mm)命中的运价配置,时段为左闭右开。 */
+    /** 查询指定车型在指定时刻(HH:mm)命中的运价配置,时段为左闭右开。 */
     @Select("""
-            select id, start_time, end_time, starting_distance, starting_fare,
+            select id, vehicle_type, start_time, end_time, starting_distance, starting_fare,
                    distance, freight, urgent_rate, minimum_urgent_fee,
                    config_version, updated_by, create_time, update_time
             from flash_delivery_pricing
             where start_time <= #{targetTime}
               and end_time > #{targetTime}
+              and vehicle_type = #{vehicleType}
             order by start_time desc
             """)
-    List<FlashDeliveryPricing> selectAtTime(@Param("targetTime") String targetTime);
+    List<FlashDeliveryPricing> selectAtTime(@Param("targetTime") String targetTime,
+                                            @Param("vehicleType") Integer vehicleType);
 
-    /** 统计与指定时段重叠的运价数量,新增和修改时用于校验时段不重叠。 */
+    /** 统计同车型与指定时段重叠的运价数量,新增和修改时用于校验时段不重叠。 */
     @Select("""
             select count(1)
             from flash_delivery_pricing
             where start_time < #{endTime}
               and end_time > #{startTime}
+              and vehicle_type = #{vehicleType}
               and (#{excludedId} is null or id <> #{excludedId})
             """)
     int countOverlapping(@Param("startTime") String startTime,
                          @Param("endTime") String endTime,
+                         @Param("vehicleType") Integer vehicleType,
                          @Param("excludedId") Long excludedId);
 }

+ 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.exception.ServiceException;
 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.annotation.Around;
 import org.aspectj.lang.annotation.Aspect;
@@ -29,9 +31,11 @@ public class AuthAspect {
 
     private HttpServletRequest request;
     private final RedisCache redisCache;
+    private final InfoUserMapper infoUserMapper;
 
-    public AuthAspect(RedisCache redisCache) {
+    public AuthAspect(RedisCache redisCache, InfoUserMapper infoUserMapper) {
         this.redisCache = redisCache;
+        this.infoUserMapper = infoUserMapper;
     }
 
     @Resource
@@ -64,7 +68,11 @@ public class AuthAspect {
             String jti = jtiValue instanceof String ? (String) jtiValue : null;
             if (auth.session() && (userId == null || jti == null || jti.isBlank()
                     || !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);
@@ -73,6 +81,19 @@ public class AuthAspect {
         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) {
         if (!(claim instanceof String value) || value.isBlank() || "null".equals(value)) {
             return null;

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

@@ -10,7 +10,9 @@
         <result property="riderId" column="rider_id"/>
         <result property="serviceType" column="service_type"/>
         <result property="deliveryType" column="delivery_type"/>
+        <result property="vehicleType" column="vehicle_type"/>
         <result property="status" column="status"/>
+        <result property="isDisplay" column="is_display"/>
         <result property="packageType" column="package_type"/>
         <result property="quantity" column="quantity"/>
         <result property="weightRange" column="weight_range"/>
@@ -65,6 +67,7 @@
         <result property="deliveredAt" column="delivered_at"/>
         <result property="completedAt" column="completed_at"/>
         <result property="cancelledAt" column="cancelled_at"/>
+        <result property="riderStars" column="rider_stars"/>
         <result property="cancelReason" column="cancel_reason"/>
         <result property="cancelOperatorType" column="cancel_operator_type"/>
         <result property="cancelOperatorId" column="cancel_operator_id"/>
@@ -80,8 +83,9 @@
     <update id="accept">
         update flash_delivery_order
         set rider_id=#{riderId}, status='ACCEPTED', accepted_at=#{now}, update_time=#{now}, version = version + 1
-        where id=#{id} and status = 'WAITING_ACCEPTANCE' and rider_id is null
-          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>
 
     <!-- 编辑与抢单竞争时,由同一订单行的原子条件更新决定谁先成功。 -->
@@ -136,6 +140,20 @@
         where id=#{id} and (user_id=#{userId} or receiver_user_id = #{userId}) and status=#{expectedStatus}
     </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 flash_delivery_order
@@ -158,4 +176,40 @@
         where status='DELIVERED' and delivered_at &lt;= #{deadline}
         order by delivered_at asc limit #{limit}
     </select>
+
+    <!-- 预约时段结束仍无人接单时,由任务分批收口;只读取仍可取消的待接订单。 -->
+    <select id="selectExpiredScheduled" resultMap="FlashDeliveryOrderResult">
+        select * from flash_delivery_order
+        where status='WAITING_ACCEPTANCE' and rider_id is null and delivery_mode='SCHEDULED'
+          and scheduled_pickup_end_at &lt;= #{now}
+        order by scheduled_pickup_end_at asc limit #{limit}
+    </select>
+
+    <!-- 接单、用户取消与定时取消竞争时,以此条件更新决定唯一终态。 -->
+    <update id="cancelExpiredScheduled">
+        update flash_delivery_order
+        set status='CANCELLED', cancelled_at=#{now}, cancel_reason='SCHEDULE_EXPIRED',
+            cancel_operator_type='SYSTEM', cancel_operator_id=null,
+            update_time=#{now}, version = version + 1
+        where id=#{id} and status='WAITING_ACCEPTANCE' and rider_id is null and delivery_mode='SCHEDULED'
+          and scheduled_pickup_end_at &lt;= #{now}
+    </update>
+
+    <!-- 预约单只有进入取件窗口后才允许向骑手开放;is_display=false 同时充当未领取标记。 -->
+    <select id="selectScheduledAvailabilityCandidates" resultMap="FlashDeliveryOrderResult">
+        select * from flash_delivery_order
+        where status='WAITING_ACCEPTANCE' and rider_id is null and delivery_mode='SCHEDULED'
+          and is_display = false and scheduled_pickup_start_at &lt;= #{now}
+          and scheduled_pickup_end_at &gt; #{now}
+        order by scheduled_pickup_start_at asc limit #{limit}
+    </select>
+
+    <!-- false -> true 的条件更新保证多实例任务只由一个实例开放并推送。 -->
+    <update id="openScheduledForRiders">
+        update flash_delivery_order
+        set is_display = true, update_time = #{now}
+        where id=#{id} and status='WAITING_ACCEPTANCE' and rider_id is null and delivery_mode='SCHEDULED'
+          and is_display = false and scheduled_pickup_start_at &lt;= #{now}
+          and scheduled_pickup_end_at &gt; #{now}
+    </update>
 </mapper>

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

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

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

@@ -108,6 +108,7 @@ class FlashDeliveryMapperXmlTest {
                 .collect(Collectors.toSet());
         assertTrue(mappedProperties.containsAll(Set.of(
                 "receiverUserId",
+                "isDisplay",
                 "deliveryType", "quantity", "weightRange", "specification",
                 "pricingId", "pricingVersion", "pricingStartTime", "pricingEndTime",
                 "startingDistance", "startingFare", "distance", "freight", "distanceFee",
@@ -117,10 +118,36 @@ class FlashDeliveryMapperXmlTest {
         assertTrue(xml.contains("status = 'WAITING_ACCEPTANCE'"));
         assertTrue(xml.contains("rider_id is null"));
         assertTrue(xml.contains("scheduled_pickup_start_at &lt;= #{now}"));
+        assertTrue(xml.contains("scheduled_pickup_end_at &gt; #{now}"));
+        assertTrue(xml.contains("selectExpiredScheduled"));
+        assertTrue(xml.contains("cancelExpiredScheduled"));
+        assertTrue(xml.contains("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("delivery_pin_code"));
         assertTrue(xml.contains("delivered_at &lt;="));
         assertTrue(xml.contains("version = version + 1"));
         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"));
+    }
 }

+ 4 - 2
ruoyi-system/src/test/java/com/ruoyi/system/mapper/flash/FlashDeliveryPricingMapperContractTest.java

@@ -14,11 +14,12 @@ class FlashDeliveryPricingMapperContractTest {
 
     @Test
     void currentPeriodUsesInclusiveStartExclusiveEndAndDoesNotHideDuplicates() throws Exception {
-        Method method = FlashDeliveryPricingMapper.class.getMethod("selectAtTime", String.class);
+        Method method = FlashDeliveryPricingMapper.class.getMethod("selectAtTime", String.class, Integer.class);
         String sql = sql(method);
 
         assertTrue(sql.contains("start_time <= #{targettime}"));
         assertTrue(sql.contains("end_time > #{targettime}"));
+        assertTrue(sql.contains("vehicle_type = #{vehicletype}"));
         assertFalse(sql.contains("service_type"));
         assertFalse(sql.contains("limit"));
     }
@@ -26,11 +27,12 @@ class FlashDeliveryPricingMapperContractTest {
     @Test
     void overlapQueryAllowsAdjacentPeriodsIncludingEndOfDay() throws Exception {
         Method method = FlashDeliveryPricingMapper.class.getMethod(
-                "countOverlapping", String.class, String.class, Long.class);
+                "countOverlapping", String.class, String.class, Integer.class, Long.class);
         String sql = sql(method);
 
         assertTrue(sql.contains("start_time < #{endtime}"));
         assertTrue(sql.contains("end_time > #{starttime}"));
+        assertTrue(sql.contains("vehicle_type = #{vehicletype}"));
         assertFalse(sql.contains("service_type"));
     }
 

+ 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.exception.ServiceException;
 import com.ruoyi.common.utils.MessageUtils;
+import com.ruoyi.system.domain.InfoUser;
+import com.ruoyi.system.mapper.InfoUserMapper;
 import jakarta.servlet.http.HttpServletRequest;
 import org.aspectj.lang.ProceedingJoinPoint;
 import org.aspectj.lang.reflect.MethodSignature;
@@ -29,6 +31,7 @@ import static org.mockito.Mockito.when;
 class AuthAspectTest {
 
     private RedisCache redisCache;
+    private InfoUserMapper infoUserMapper;
     private HttpServletRequest request;
     private ProceedingJoinPoint joinPoint;
     private MethodSignature signature;
@@ -37,10 +40,11 @@ class AuthAspectTest {
     @BeforeEach
     void setUp() {
         redisCache = mock(RedisCache.class);
+        infoUserMapper = mock(InfoUserMapper.class);
         request = mock(HttpServletRequest.class);
         joinPoint = mock(ProceedingJoinPoint.class);
         signature = mock(MethodSignature.class);
-        aspect = new AuthAspect(redisCache);
+        aspect = new AuthAspect(redisCache, infoUserMapper);
         aspect.setHttpServletRequest(request);
         when(joinPoint.getSignature()).thenReturn(signature);
     }
@@ -75,11 +79,62 @@ class AuthAspectTest {
         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
     void merchantSessionAuthRejectsMissingRedisSessionBeforeController() throws Throwable {
         String token = JwtUtil.token("936", "merchant");
         String jti = tokenJti(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);
 
         try (MockedStatic<MessageUtils> messages = mockStatic(MessageUtils.class)) {
@@ -101,6 +156,7 @@ class AuthAspectTest {
                 .withExpiresAt(new Date(System.currentTimeMillis() + 60_000))
                 .sign(Algorithm.HMAC256("TEST-AUTH-TOKEN"));
         prepareInvocation("merchantSession", token);
+        loginUser("1");
 
         try (MockedStatic<MessageUtils> messages = mockStatic(MessageUtils.class)) {
             messages.when(() -> MessageUtils.message("merchant.session.invalid"))
@@ -121,6 +177,12 @@ class AuthAspectTest {
         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) {
         Map<String, Object> claims = JwtUtil.verifyToken(token);
         return (String) claims.get("jti");

+ 20 - 10
specs/024-flash-delivery/contracts/api.md

@@ -9,8 +9,12 @@
 - `serviceType`:`HELP_SEND`、`HELP_PICKUP`。
 - `deliveryType`:`NORMAL`、`URGENT`。
 - `packageType`:`DOCUMENT`、`GIFT`、`CLOTHING`、`BEAUTY`、`DAILY_NECESSITIES`、`FOOD_INGREDIENTS`、`ELECTRONICS`、`SMALL_APPLIANCE`、`OTHER`。
-- 物品信息提交 `packageType`、正整数 `quantity`、必填 `weightRange` 及最长 255 字符的可选 `specification`;`weightRange` 只接受 `UP_TO_5_KG`、`OVER_5_TO_10_KG`、`OVER_10_TO_15_KG`、`OVER_15_TO_20_KG`。
+- 物品信息提交 `packageType`、正整数 `quantity`、可选 `weightRange` 及最长 255 字符的可选 `specification`;传入时 `weightRange` 只接受 `UP_TO_5_KG`、`OVER_5_TO_10_KG`、`OVER_10_TO_15_KG`、`OVER_15_TO_20_KG`。
+- `vehicleType`:`1`=机车、`2`=轿车;报价和创建均必填并必须一致。历史订单、骑手资料和运价配置的空值按机车处理。
+- 路线距离和 `estimatedDurationSeconds` 按 `vehicleType` 获取:机车使用 Google Routes 的 `TWO_WHEELER`,轿车使用 `DRIVE`。地图路线不可用时,`distanceSource=STRAIGHT_LINE`,`estimatedDurationSeconds` 仍必返:机车以 25 km/h、轿车以 30 km/h 的保守速度按直线距离估算,向上取整且最少 60 秒;该时长不参与金额计算。两轮路线是 Google Beta 能力;本期不修改用户 App 的提示文案。
 - `deliveryMode`:`NOW`、`SCHEDULED`。预约起止时间使用 ISO 日期时间,时段固定 30 分钟且开始时间不晚于三天后。
+
+预约单只在 `scheduledPickupStartAt <= 当前时间 < scheduledPickupEndAt` 的窗口内向骑手开放;结束时仍为待接单的订单由 Quartz 任务自动取消,取消原因代码为 `SCHEDULE_EXPIRED`。
 - `status`:`WAITING_ACCEPTANCE`、`ACCEPTED`、`PICKED_UP`、`DELIVERED`、`COMPLETED`、`CANCELLED`。
 
 ## 地址对象
@@ -32,18 +36,18 @@
 | 方法 | 路径 | 请求/说明 |
 |---|---|---|
 | GET | `/system/flashDelivery/home` | 返回 `serviceTypes=[HELP_SEND,HELP_PICKUP]`、`deliveryTypes=[NORMAL,URGENT]` 及当前时刻命中的统一运价摘要;不返回内部更新人、时间或地图密钥 |
-| POST | `/system/flashDelivery/quote` | `serviceType,deliveryType,deliveryMode,scheduledPickupStartAt?,scheduledPickupEndAt?,packageType,quantity,weightRange,specification?,tipAmount,pickup,delivery`;立即订单按当前时刻、预约订单按预约开始时刻匹配运价 |
-| POST | `/system/flashDelivery/orders` | 报价请求字段 + `clientRequestId,pricingId,pricingVersion,quotedBaseDeliveryFee,quotedDistanceFee,quotedUrgentFee,quotedAmount,pinRequired?,senderImageUrls?,userNote?`;服务端复算并逐项校验后幂等创建 |
+| POST | `/system/flashDelivery/quote` | `serviceType,deliveryType,vehicleType,deliveryMode,scheduledPickupStartAt?,scheduledPickupEndAt?,packageType,quantity,weightRange?,specification?,tipAmount,pickup,delivery`;立即订单按当前时刻、预约订单按预约开始时刻匹配对应车型运价 |
+| POST | `/system/flashDelivery/orders` | 报价请求字段 + `clientRequestId,pricingId,pricingVersion,quotedBaseDeliveryFee,quotedDistanceFee,quotedUrgentFee,quotedAmount,pinRequired?,senderImageUrls?,userNote?`;服务端按相同车型复算并逐项校验后幂等创建 |
 | GET | `/system/flashDelivery/orders` | `page,size,role?`;`role=sender/receiver`,省略为 `sender`;按创建时间倒序返回当前用户作为寄件人或已绑定收件人的订单摘要 |
 | GET | `/system/flashDelivery/orders/{id}` | 订单创建人或创建时已绑定收件人的 App 订单详情;`data` 直接为订单字段,不返回原始状态日志 |
 | POST | `/system/flashDelivery/orders/{id}/cancel` | `{"reason":"行程变化"}`;仅订单创建人且状态为待接/已接 |
 | POST | `/system/flashDelivery/orders/{id}/confirmReceipt` | 无 body;订单创建人或已绑定收件人可对 DELIVERED 订单确认签收 |
 
-用户列表摘要字段固定为:`id,orderNo,serviceType,deliveryType,status,packageType,quantity,weightRange,deliveryMode,scheduledPickupStartAt,scheduledPickupEndAt,pickupAddress,pickupDetailAddress,deliveryAddress,deliveryDetailAddress,estimatedDurationSeconds,baseDeliveryFee,urgentFee,tipAmount,amount,currency,deliveredAt,createTime`。列表不返回联系人、电话、精确坐标、照片、日志或内部计价审计字段。`receiver` 不是按当前手机号动态查询:订单只在创建时唯一匹配已注册普通用户并固化 `receiverUserId`,未匹配订单以后不追溯认领。
+用户列表摘要字段固定为:`id,orderNo,serviceType,deliveryType,vehicleType,status,packageType,quantity,weightRange,deliveryMode,scheduledPickupStartAt,scheduledPickupEndAt,pickupAddress,pickupDetailAddress,deliveryAddress,deliveryDetailAddress,estimatedDurationSeconds,baseDeliveryFee,urgentFee,tipAmount,amount,currency,deliveredAt,createTime`。列表不返回联系人、电话、精确坐标、照片、日志或内部计价审计字段。`receiver` 不是按当前手机号动态查询:订单只在创建时唯一匹配已注册普通用户并固化 `receiverUserId`,未匹配订单以后不追溯认领。
 
-报价响应 data:`serviceType,deliveryType,deliveryMode,scheduledPickupStartAt?,scheduledPickupEndAt?,pricingId,startTime,endTime,distanceMeters,distanceSource,estimatedDurationSeconds?,startingDistance,startingFare,distance,freight,billableDistance,distanceFee,baseDeliveryFee,urgentRate,minimumUrgentFee,urgentFee,tipAmount,amount,currency,pricingVersion`。`billableDistance` 为按外卖规则处理 0.5 公里边界后的计费公里数;全部费用为整数 TWD,`urgentRate` 为百分比。`baseDeliveryFee=startingFare+distanceFee`;普通配送 `urgentFee=0`;加急配送 `urgentFee=max(minimumUrgentFee,roundHalfUp(baseDeliveryFee*urgentRate/100))`;`amount=baseDeliveryFee+urgentFee+tipAmount`。
+报价响应 data:`serviceType,deliveryType,vehicleType,deliveryMode,scheduledPickupStartAt?,scheduledPickupEndAt?,pricingId,startTime,endTime,distanceMeters,distanceSource,estimatedDurationSeconds?,startingDistance,startingFare,distance,freight,billableDistance,distanceFee,baseDeliveryFee,urgentRate,minimumUrgentFee,urgentFee,tipAmount,amount,currency,pricingVersion`。`billableDistance` 为按外卖规则处理 0.5 公里边界后的计费公里数;全部费用为整数 TWD,`urgentRate` 为百分比。`baseDeliveryFee=startingFare`,仅表示起步价;普通配送费在服务端按 `baseDeliveryFee+distanceFee` 计算但不单独返回;普通配送 `urgentFee=0`;加急配送 `urgentFee=max(minimumUrgentFee,roundHalfUp((baseDeliveryFee+distanceFee)*urgentRate/100))`;`amount=baseDeliveryFee+distanceFee+urgentFee+tipAmount`。已有订单的金额快照不回写,新口径仅用于此调整发布后的新报价和新订单。
 
-创建时服务端重新获取路线、按配送方式匹配运价并复算费用。`pricingId`、`pricingVersion`、`quotedBaseDeliveryFee`、`quotedDistanceFee`、`quotedUrgentFee`、`quotedAmount` 任一不一致时不创建订单,返回国际化“报价已变化,请重新确认”及完整最新报价。地址快照字段为联系人、电话、市/区、交付方式、地址、详细地址和经纬度。用户创建和详情响应的 `data` 直接为订单详情字段,照片分别为 `senderImageUrls,pickupImageUrls,deliveryImageUrls`,不返回原始状态日志;`deliveryPinCode` 仅在启用时向订单参与用户返回,用户仅在 `ACCEPTED`、`PICKED_UP` 阶段获得骑手位置。
+创建时服务端按 `vehicleType` 重新获取对应车型路线、匹配运价并复算费用。`pricingId`、`pricingVersion`、`quotedBaseDeliveryFee`、`quotedDistanceFee`、`quotedUrgentFee`、`quotedAmount` 任一不一致时不创建订单,返回国际化“报价已变化,请重新确认”及完整最新报价。地址快照字段为联系人、电话、市/区、交付方式、地址、详细地址和经纬度。用户创建和详情响应的 `data` 直接为订单详情字段,照片分别为 `senderImageUrls,pickupImageUrls,deliveryImageUrls`,不返回原始状态日志;`deliveryPinCode` 仅在启用时向订单参与用户返回,用户仅在 `ACCEPTED`、`PICKED_UP` 阶段获得骑手位置。
 
 ## 骑手端
 
@@ -59,7 +63,7 @@
 
 骑手页签状态映射:`newTask=WAITING_ACCEPTANCE`、`toPickup=ACCEPTED`、`delivering=PICKED_UP`、`completed=DELIVERED+COMPLETED`、`cancelled=CANCELLED`。除 `newTask` 外只返回当前骑手本人任务;闪送没有 `refund` 页签。
 
-骑手列表摘要字段固定为:`id,orderNo,serviceType,deliveryType,status,packageType,quantity,weightRange,specification,deliveryMode,scheduledPickupStartAt,scheduledPickupEndAt,pinRequired,pickupAddress,pickupDetailAddress,deliveryAddress,deliveryDetailAddress,pickupDistanceMeters,distanceMeters,estimatedDurationSeconds,baseDeliveryFee,urgentFee,tipAmount,amount,currency,createTime`。`newTask` 分页 data 在 `records,total,current,size` 外增加 `nearbyTaskCount,highestOrderAmount`;不返回加急比例、最低加急费或骑手收入字段。
+骑手列表摘要字段固定为:`id,orderNo,serviceType,deliveryType,vehicleType,status,packageType,quantity,weightRange,specification,deliveryMode,scheduledPickupStartAt,scheduledPickupEndAt,pinRequired,pickupAddress,pickupDetailAddress,deliveryAddress,deliveryDetailAddress,pickupDistanceMeters,distanceMeters,estimatedDurationSeconds,baseDeliveryFee,urgentFee,tipAmount,amount,currency,createTime`。`newTask` 只返回与骑手登记车型一致的待接单任务;骑手查看待接单详情或接单时也会重新校验车型。分页 data 在 `records,total,current,size` 外增加 `nearbyTaskCount,highestOrderAmount`;不返回加急比例、最低加急费或骑手收入字段。
 
 骑手接单前可按原型查看完整取送文字地址,但不返回用户 ID、联系人、电话、实际 PIN、精确经纬度、备注、幂等请求号、履约凭证或日志。抢单成功后才向中单骑手返回完整联系方式、坐标和有权限查看的照片数组。骑手详情在接单前后使用同一个直接 DTO,不返回 `{order,images,logs}` 包装,也不要求 App 判断 `data.order`。
 
@@ -67,8 +71,8 @@
 
 | 方法 | 路径 | 请求/说明 |
 |---|---|---|
-| GET | `/system/flashDelivery/admin/pricing` | 返回帮送和帮取共享的全部统一运价时段,按开始时间排序 |
-| POST | `/system/flashDelivery/admin/pricing` | `startTime,endTime,startingDistance,startingFare,distance,freight,urgentRate,minimumUrgentFee`;保存即生效 |
+| GET | `/system/flashDelivery/admin/pricing` | 返回全部车型运价时段,按车型、开始时间排序 |
+| POST | `/system/flashDelivery/admin/pricing` | `vehicleType,startTime,endTime,startingDistance,startingFare,distance,freight,urgentRate,minimumUrgentFee`;保存即生效 |
 | PUT | `/system/flashDelivery/admin/pricing/{id}` | 与新增相同的完整请求体;版本原子加一,并发覆盖返回业务错误 |
 | DELETE | `/system/flashDelivery/admin/pricing/{id}` | 删除运价时段并立即停止用于报价 |
 | GET | `/system/flashDelivery/admin/orders` | 分页;可按状态、类型、订单号、用户、骑手筛选 |
@@ -132,7 +136,7 @@
 }
 ```
 
-金额仅为示例,客户端必须使用实际报价;`distanceFee` 已包含于 `baseDeliveryFee`,总金额为 `baseDeliveryFee + urgentFee + tipAmount`。
+金额仅为示例,客户端必须使用实际报价;`baseDeliveryFee` 仅为起步价,`distanceFee` 为独立距离附加费,总金额为 `baseDeliveryFee + distanceFee + urgentFee + tipAmount`。
 
 保存前服务端重新计算路线和价格。距离或任一确认金额不同(包括漏传)时,返回非成功 AjaxResult,`msg` 为国际化 `flash.delivery.quote.changed`,`data` 为最新完整报价,订单不变。客户端展示最新报价,经用户再次确认后重新提交,不能自动按新报价扣改金额。
 
@@ -158,3 +162,9 @@
 - 同一小费请求成功后重发旧版本会被拒绝,不会重复加款。网络超时应刷新详情核对小费和总金额,不应自动换新版本重复追加。
 - 地址报价后如追加过小费,旧地址确认失效;刷新详情、重新报价并让用户确认。
 - 当前取消规则、支付和退款范围不变;无新增数据库字段或迁移 SQL。
+
+## 2026-09-17:订单详情返回对端完整用户资料
+
+- `GET /system/flashDelivery/orders/{id}`:骑手接单后 `rider` 直接返回骑手完整 `info_user` 资料(含联系方式、车型、车牌和坐标),未接单时为空;不再按状态裁剪位置字段,也不再是精简摘要对象。
+- `GET /system/flashDelivery/rider/orders/{id}` 及接单成功响应:接单后新增 `user` 字段,直接返回寄件用户完整 `info_user` 资料;抢单前脱敏详情不返回 `user`,用户端详情不返回寄件用户资料。
+

+ 2 - 2
specs/024-flash-delivery/data-model.md

@@ -2,13 +2,13 @@
 
 ## `flash_delivery_order`
 
-核心字段:`id`、唯一 `order_no`、与用户联合唯一的 `client_request_id`、创建人 `user_id`、创建时按收件手机号唯一匹配并固化的可空 `receiver_user_id`、`rider_id`、帮送/帮取 `service_type`、普通/加急 `delivery_type`、`status`;`package_type`、正整数 `quantity`、四档 `weight_range`、可空 `specification`;`delivery_mode`、预约开始/结束时间;`pin_required`、四位交付 PIN;取件/收件联系人、电话、市/区、交付方式、地址、详细地址和经纬度快照;`distance_meters`、`distance_source`、`estimated_duration_seconds`;整数 `amount`、`currency`、`pricing_id`、`pricing_version`、`pricing_start_time`、`pricing_end_time`、`starting_distance`、`starting_fare`、`distance`、`freight`、`distance_fee`、`base_delivery_fee`、`urgent_rate`、`minimum_urgent_fee`、`urgent_fee`、`tip_amount` 计价快照;`user_note`、`version`、关键状态时间、取消审计及创建/更新时间。
+核心字段:`id`、唯一 `order_no`、与用户联合唯一的 `client_request_id`、创建人 `user_id`、创建时按收件手机号唯一匹配并固化的可空 `receiver_user_id`、`rider_id`、帮送/帮取 `service_type`、普通/加急 `delivery_type`、`vehicle_type`(1=机车、2=轿车)、`status`;`package_type`、正整数 `quantity`、可空的四档 `weight_range`、可空 `specification`;`delivery_mode`、预约开始/结束时间;`pin_required`、四位交付 PIN;取件/收件联系人、电话、市/区、交付方式、地址、详细地址和经纬度快照;`distance_meters`、`distance_source`、`estimated_duration_seconds`;整数 `amount`、`currency`、`pricing_id`、`pricing_version`、`pricing_start_time`、`pricing_end_time`、`starting_distance`、`starting_fare`、`distance`、`freight`、`distance_fee`、`base_delivery_fee`、`urgent_rate`、`minimum_urgent_fee`、`urgent_fee`、`tip_amount` 计价快照;`user_note`、`version`、关键状态时间、取消审计及创建/更新时间。
 
 索引:订单号唯一、用户幂等号唯一、待抢排序、创建人列表、收件人列表、骑手列表、自动完成扫描。`receiver_user_id` 只保存创建当时唯一匹配的普通用户;空值和历史订单不补写,用户手机号变更也不回写。
 
 ## `flash_delivery_pricing`
 
-帮送与帮取共享多个统一运价时段,不再保存 `service_type`。字段为 `start_time`、`end_time`、`starting_distance`、整数 `starting_fare`、`distance`、整数 `freight`、非负两位小数百分比 `urgent_rate`、非负整数 `minimum_urgent_fee`、`config_version`、`updated_by` 和创建/更新时间。配置保存即生效,不保存 `enabled`;全部时间段不得重叠,索引支持按时间匹配目标时刻。有效修改后版本加一,历史订单不回写。时段查询不使用 `LIMIT 1` 隐藏异常数据;若匹配到多条则明确返回配置重叠错误。运价写入不再维护专用锁表;新增和修改仍执行重叠校验,修改仍使用 `config_version` 防止旧版本覆盖。
+帮送与帮取共享多个统一运价时段,不再保存 `service_type`,但每条配置必须有 `vehicle_type`(1=机车、2=轿车)其余字段为 `start_time`、`end_time`、`starting_distance`、整数 `starting_fare`、`distance`、整数 `freight`、非负两位小数百分比 `urgent_rate`、非负整数 `minimum_urgent_fee`、`config_version`、`updated_by` 和创建/更新时间。配置保存即生效,不保存 `enabled`;仅同一车型的时间段不得重叠,索引支持按车型和时间匹配目标时刻。有效修改后版本加一,历史订单不回写。时段查询不使用 `LIMIT 1` 隐藏异常数据;若同一车型匹配到多条则明确返回配置重叠错误。运价写入不再维护专用锁表;新增和修改仍执行同车型重叠校验,修改仍使用 `config_version` 防止旧版本覆盖。
 
 ## `flash_delivery_order_image`
 

+ 1 - 1
specs/024-flash-delivery/design.md

@@ -251,7 +251,7 @@ COMPLETED
 
 平台每个时间段配置 `startTime`、`endTime`、`startingDistance`、`startingFare`、`distance`、`freight`、`urgentRate` 和 `minimumUrgentFee`。所有时间段全局互斥,不再按 `serviceType` 分组。立即订单按报价时刻匹配,预约订单按 `scheduledPickupStartAt` 匹配;日期只用于确定预约有效性,时间段按业务当地时间的时分命中。
 
-普通配送费先沿用现有起送与超距规则计算:`baseDeliveryFee = startingFare + distanceFee`。普通配送 `urgentFee=0`;加急配送使用 `urgentFee = max(minimumUrgentFee, roundHalfUp(baseDeliveryFee × urgentRate / 100))`。骑手小费 `tipAmount` 是用户输入的独立非负整数 TWD,不参与加急费计算;最终 `amount = baseDeliveryFee + urgentFee + tipAmount`。
+基础配送费固定为起步价:`baseDeliveryFee = startingFare`;普通配送费只在服务端按 `baseDeliveryFee + distanceFee` 计算,不单独保存或返回。普通配送 `urgentFee=0`;加急配送使用 `urgentFee = max(minimumUrgentFee, roundHalfUp((baseDeliveryFee + distanceFee) × urgentRate / 100))`。骑手小费 `tipAmount` 是用户输入的独立非负整数 TWD,不参与加急费计算;最终 `amount = baseDeliveryFee + distanceFee + urgentFee + tipAmount`。
 
 ### 12.3 报价回传与创建校验
 

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

@@ -239,6 +239,14 @@ foodie-admin-vue/
 6. `home` 对三种服务分别读取当前时段,只返回有配置的服务;`quote/create` 每次重新读取当前时段并计算。订单固化 `pricingId,pricingVersion,pricingStartTime,pricingEndTime,startingDistance,startingFare,distance,freight,distanceFee`。
 7. 报价与公开服务 DTO 移除旧计价字段,保留 `estimatedDurationSeconds`;金额 DTO 和订单实体改为整数 `Long`。
 
+### 2026-09-17 基础配送费口径调整
+
+`baseDeliveryFee` 统一表示起步价,`distanceFee` 保持独立距离附加费;不增加配送小计字段。服务端计算加急费时仍使用起步价与距离附加费之和,订单总额为基础配送费、距离附加费、加急费与小费之和。既有订单快照不回写。
+
+### 2026-09-17 路线降级预计时长
+
+Google Routes 无路线、异常或不可用时,`FlashDeliveryRouteService` 继续以 Haversine 直线距离并写入 `distanceSource=STRAIGHT_LINE`。同时按订单车型生成 `estimatedDurationSeconds`:机车按 25 km/h,轿车按 30 km/h,结果向上取整且最少 60 秒。该值仅用于履约展示,不参与报价或订单金额计算;不新增来源字段,既有 `distanceSource` 即可标识降级估算。
+
 ### 平台前端交互
 
 - 价格页表格按服务类型、开始时间排序,列出起止时间、起送距离/价格、计价距离/金额、版本和更新时间。
@@ -721,3 +729,25 @@ The staged list must not contain `.claude/homunculus/observations.jsonl`、`.tmp
 11. **补充验收(T100)**:覆盖默认 A、沟通并保存调整后前往 B、仅导航状态不变、缺原因/声明或直接绕过调整操作拒绝、B 取件后默认回 A、原顺序/物品/金额不变、部分取件进度及取消入口、平台明确标注骑手声明、无法取货的介入与交接记录;覆盖调整请求重复、与取件/取消竞争、目标变化后的取件幂等重试。联调验证前不得将 App 行为标记为已验证,骑手声明不能作为已验证用户同意真实性的依据。
 
 外部约束:Google Routes 单次最多 25 个中途点,Maps URL 使用 two-wheeler 且途经点数量受平台限制;本期采用字典有效上限最多 26 个取货点与单站导航,不做无限站点分段请求。
+
+## 2026-09-17:按配送车型计价实施计划
+
+1. 为运价、报价请求/响应和订单快照增加 `vehicleType`(1=机车、2=轿车),数据库迁移仅写入 `updatesql/sql.md`,旧数据以默认值 1 保持原行为。
+2. 运价查询和时段重叠检查按车型隔离;报价、创建、待接单地址改价均使用订单车型,并将机车映射到 Google Routes `TWO_WHEELER`、轿车映射到 `DRIVE`,重量范围和规格仅在传入时校验。
+3. 骑手沿用既有 `info_user.vehicle_type`;待抢查询、待抢详情和接单重复核验订单车型,历史空值或非法值安全按机车处理。
+4. 平台端运价页面将车型作为必填配置维度,新增车型展示和四语言文案;用户 App 不在本次改动范围内。
+5. 补充服务、DTO 与平台前端定向测试,执行 JDK 21 受影响模块测试、前端测试及两个仓库的差异/换行检查;MySQL 迁移和真实 App 联调单列为未执行项。
+
+### 2026-09-17 实施进度
+
+- [x] 车型运价、报价/订单快照、骑手车型接单限制、重量/规格选填和 SQL 迁移登记已实现;SQL 未直接执行。
+- [x] 路线服务已按车型透传:机车为 Google Routes `TWO_WHEELER`,轿车为 `DRIVE`;报价、创建及待接单改址均覆盖,历史空值按机车回落。
+- [x] 路线车型映射、路线服务透传、车型报价、Mapper/DTO/i18n/接口契约定向测试通过;JDK 21 执行 `mvn -q -pl ruoyi-admin -am -DskipTests package` 通过;平台端 `npm run test:flash-delivery` 8 项通过;两个仓库 `git diff --check` 通过。
+- [ ] 未执行 MySQL 迁移、真实 Google Routes 请求、真实用户 App 联调或完整回归。平台端 `npm run build:prod` 因本机缺少 `vue-cli-service` 未完成。两轮路线是 Google Beta;用户 App 若展示两轮路线,需要另行补充 Google 要求的提示文案。
+
+### 2026-09-17 共享地图 Key 与路线降级诊断
+
+- [x] 闪送改为与外卖共用 `sys_googlemap_key`,不再依赖测试库中缺失的 `sys_google_routes_key`;Key 不进入闪送响应或业务日志。
+- [x] Google Routes 失败时记录车型与脱敏失败原因后降级直线距离;HTTP 非成功响应记录状态码,不记录地址、坐标、Key 或完整响应。
+- [x] `GoogleRoutesDistanceProviderTest` 与 `FlashDeliveryRouteServiceTest` 共 5 项通过。
+- [ ] 从当前开发机访问 `routes.googleapis.com:443` 连接超时,未能验证共享 Key 的 Routes API 权限;需在测试服务器部署后查看新告警日志或从该服务器网络发起验证。

+ 1 - 1
specs/024-flash-delivery/quickstart.md

@@ -1,6 +1,6 @@
 # 闪送 API 联调快速开始
 
-1. 开发者人工执行 `updatesql/sql.md` 中既有闪送迁移及 2026-09-07 统一运价增量,核对每个时段的加急比例和最低加急费;并在字典 `sys_google_routes_key` 配置仅供后端使用、已启用 Google Routes API 的独立密钥,不得复用匿名接口可读取的 `sys_googlemap_key`
+1. 开发者人工执行 `updatesql/sql.md` 中既有闪送迁移及 2026-09-07 统一运价增量,核对每个时段的加急比例和最低加急费;闪送与外卖共用字典 `sys_googlemap_key`,该 Key 必须启用 Google Routes API,并在 Google Cloud 配置 API 限制、来源限制和配额
 2. 启动 `ruoyi-admin`;用户和骑手请求携带现有 `token` 请求头,平台请求使用后台权限。
 3. 调用 `/system/flashDelivery/home`、`/quote`、`/orders`;报价提交帮送/帮取业务场景、普通/加急配送等级、立即/预约取件时段、完整物品信息和骑手小费。创建时回传报价 ID、版本及各项金额,服务端复算不一致时返回最新报价且不创建订单;收件账号匹配结果固化在新订单中,不追溯历史订单。
 4. 用户通过 `/system/flashDelivery/orders?role=sender` 查看“我发的”,通过 `role=receiver` 查看“我收的”;省略 `role` 时默认 `sender`。发件人与固化收件人均可查看详情和确认收货,只有发件人可以取消。

+ 2 - 2
specs/024-flash-delivery/research.md

@@ -4,11 +4,11 @@
 
 采用 Google Routes API v2 的 `POST https://routes.googleapis.com/directions/v2:computeRoutes`。请求头使用 `X-Goog-Api-Key` 和 `X-Goog-FieldMask: routes.distanceMeters,routes.duration`,请求体使用取件、收件经纬度、`DRIVE`、`TRAFFIC_AWARE`。参考 Google 官方 [Compute Routes](https://developers.google.com/maps/documentation/routes/reference/rest/v2/TopLevel/computeRoutes)、[计算路线](https://developers.google.com/maps/documentation/routes/compute_route_directions) 和 [字段掩码](https://developers.google.com/maps/documentation/routes/choose_fields)。
 
-服务端只从字典 `sys_google_routes_key` 读取独立密钥,不复用可能由现有匿名接口返回的浏览器密钥 `sys_googlemap_key`,也不在响应或日志中输出服务端密钥。该密钥需启用 Routes API 并限制服务端来源。连接/读取超时、HTTP 非成功、JSON 无路线或距离不大于 0 均降级。
+闪送与外卖共用字典 `sys_googlemap_key`,不在闪送响应或日志中输出 Key。该 Key 需启用 Routes API,并在 Google Cloud 配置 API 限制、来源限制和配额。连接/读取超时、HTTP 非成功、JSON 无路线或距离不大于 0 均降级;降级告警仅记录车型和失败原因,不记录地址、坐标或 Key
 
 ## 降级与精度
 
-外部路线不可用时使用 Haversine 公式计算球面直线距离,四舍五入为整数米并标记 `STRAIGHT_LINE`;路线成功标记 `ROUTE`。闪送计价与现有外卖订单保持同一业务规则:按当前时间匹配运价时段,起送距离内收起送价格,超出不足 0.5 公里不加价、0.5 至不足 1 公里按 1 公里计、至少 1 公里按实际超出距离计。距离与单价除法使用 `BigDecimal`,里程费用使用 `HALF_UP` 四舍五入为整数新台币;路线预计时长只用于履约展示,不参与金额计算。
+外部路线不可用时使用 Haversine 公式计算球面直线距离,四舍五入为整数米并标记 `STRAIGHT_LINE`;路线成功标记 `ROUTE`。直线降级仍返回预计时长:机车按 25 km/h、轿车按 30 km/h 计算,结果向上取整且最少 60 秒;这是保守履约展示估算,不参与计价。闪送计价与现有外卖订单保持同一业务规则:按当前时间匹配运价时段,起送距离内收起送价格,超出不足 0.5 公里不加价、0.5 至不足 1 公里按 1 公里计、至少 1 公里按实际超出距离计。距离与单价除法使用 `BigDecimal`,里程费用使用 `HALF_UP` 四舍五入为整数新台币;路线预计时长只用于履约展示,不参与金额计算。
 
 ## 并发与安全
 

+ 15 - 4
specs/024-flash-delivery/spec.md

@@ -178,7 +178,7 @@
 - **FR-005**:立即订单必须按报价时刻匹配唯一运价时段,预约订单必须按 `scheduledPickupStartAt` 匹配唯一运价时段;全部时间段之间不得重叠,没有匹配时段时必须拒绝报价和创建订单。
 - **FR-006**:计价必须与现有外卖订单一致:起送距离内只收起送价格;超出不足 0.5 公里不加价,超出 0.5 公里但不足 1 公里按 1 公里计,超出至少 1 公里按实际超出距离计;里程费用四舍五入到整数元后与起送价格相加,所有 TWD 金额均为整数且不再按百位或千位特殊取整。
 - **FR-007**:系统必须优先使用地图路线距离,地图失败时使用经纬度直线距离并向客户端返回距离来源。
-- **FR-008**:地图密钥必须从服务端配置读取,不得返回客户端或写入业务日志
+- **FR-008**:闪送与外卖共用字典 `sys_googlemap_key`;闪送接口和业务日志不得返回或写入该 Key,Google Cloud 必须限制其 API、来源和配额
 - **FR-009**:创建订单时前端必须回传报价配置 ID、版本、基础配送费、距离费、加急费和总金额;服务端必须重新计算距离和价格并逐项精确校验,一致后才能保存地址、路线、金额和计价配置快照,不一致时不得创建订单并必须返回最新报价。
 - **FR-010**:系统必须使用客户端请求号保证同一用户的订单创建幂等。
 - **FR-011**:闪送订单不得复用餐饮订单或打车订单数据模型。
@@ -212,6 +212,7 @@
 - **FR-039**:本功能的数据库变更只能记录在 `updatesql/sql.md`,不得直接执行。
 - **FR-040**:订单必须支持 `NOW` 和 `SCHEDULED` 两种配送方式;预约时段固定为 30 分钟且不得晚于创建时间后三天。
 - **FR-041**:预约订单在预约开始时间前不得出现在可抢列表,也不得被骑手通过订单 ID 提前抢取。
+- **FR-103**:预约订单仅能在预约开始时间(含)至结束时间(不含)之间被骑手接取;结束时仍未接单的订单必须由系统任务自动取消,列表和接单原子更新均不得允许过期订单继续接取。
 - **FR-042**:用户可选择是否启用 4 位数字交付 PIN;启用后骑手必须同时提交正确 PIN 和至少一张送达图片。
 - **FR-043**:PIN 只对订单用户和有权限的平台管理员可见,不得返回给待接单或已接单骑手,也不得写入业务日志。
 - **FR-044**:创建订单可提交 1 至 9 张可选寄件图片;凭证类型扩展为 `SENDER`、`PICKUP`、`DELIVERY` 并记录操作人类型。
@@ -225,6 +226,7 @@
 - **FR-052**:价格表单必须校验时间范围有效、全部时段互不重叠、距离为正数、起送价格和计价金额为正整数、加急比例和最低加急费为非负数;配置保存即生效,删除后立即不可用于报价,操作成功后必须重新读取服务端列表。
 - **FR-053**:订单页面必须按服务端分页,并支持状态、服务类型、订单号、用户 ID 和骑手 ID 组合筛选及一键重置。
 - **FR-054**:平台订单详情必须展示订单概况、取送地址、路线与费用、预约与 PIN、骑手 ID 与履约时间、三类图片凭证及状态日志;不得要求管理员从列表字段拼接详情。
+- **FR-104**:平台订单概况对预约订单必须直接展示预约取件开始及结束时间,不得只显示“预约配送”。
 - **FR-055**:平台取消必须要求非空原因和二次确认;平台完成必须二次确认。操作成功后刷新服务端数据,操作失败时保留当前页面并显示后端错误。
 - **FR-056**:平台前端新增的全部用户可见文本必须通过 Vue i18n 提供简体中文、繁体中文、英文和越南文,不得硬编码单一语言文本。
 - **FR-057**:平台前端必须沿用现有 Vue 2、Element UI、若依请求封装、动态菜单和 `v-hasPermi` 体系,不新增重复的状态管理或 UI 框架。
@@ -240,9 +242,9 @@
 - **FR-067**:闪送继续保持非支付业务,创建成功直接进入 `WAITING_ACCEPTANCE`,不增加待支付状态、支付倒计时、支付接口或退款流程。
 - **FR-068**:所有闪送金额继续使用整数 TWD;蓝湖稿中的小数金额仅为视觉占位,不改变接口和数据库金额类型。
 - **FR-069**:用户端不得展示固定或伪造的附近骑手人数及预计接单分钟数,统一使用“发布后等待附近骑手接单”;本期不增加实时骑手数量或接单预测接口。
-- **FR-070**:普通配送费等于起送价格与距离费之和;普通配送的加急费为 0,加急配送的加急费等于“普通配送费 × 当前时段加急比例”四舍五入到整数 TWD 后与最低加急费取较大值。
-- **FR-071**:骑手小费由用户提交,为独立的非负整数 TWD;订单总金额等于普通配送费、加急费和骑手小费之和,小费不得参与加急费计算。
-- **FR-072**:报价和订单响应必须分别返回 `baseDeliveryFee`、`distanceFee`、`urgentFee`、`tipAmount` 和 `amount`;加急报价还必须返回命中的 `urgentRate` 和 `minimumUrgentFee`,费用名称不得混用。
+- **FR-070**:`baseDeliveryFee` 仅表示起送价格,`distanceFee` 表示距离附加费;普通配送费仅在服务端按两者之和计算,不增加小计字段。普通配送的加急费为 0,加急配送的加急费等于“普通配送费 × 当前时段加急比例”四舍五入到整数 TWD 后与最低加急费取较大值。
+- **FR-071**:骑手小费由用户提交,为独立的非负整数 TWD;订单总金额等于基础配送费、距离附加费、加急费和骑手小费之和,小费不得参与加急费计算。
+- **FR-072**:报价和订单响应必须分别返回 `baseDeliveryFee`、`distanceFee`、`urgentFee`、`tipAmount` 和 `amount`;加急报价还必须返回命中的 `urgentRate` 和 `minimumUrgentFee`,费用名称不得混用。历史订单金额快照不回写。
 
 ### 关键实体
 
@@ -355,3 +357,12 @@
 **方案来源**:上述为本项目 2026-09-15 已确认的方式 2。Lalamove 的业务规范仅作为讨论背景;骑手声明、调整记录及具体 App 操作是本项目设计,不标注为已核实的 Lalamove 操作流程。
 
 地图约束依据:[Google Routes 中途点限制](https://developers.google.com/maps/documentation/routes/intermed_waypoints)、[Google Maps URL 参数及平台途经点限制](https://developers.google.com/maps/documentation/urls/get-started)。
+
+## 2026-09-16 确认、2026-09-17 实施:按配送车型计价
+
+- 用户报价与创建闪送订单均传 `vehicleType`:`1`=机车、`2`=轿车;服务端拒绝缺失或其他数值。
+- 闪送运价按“车型 + 时段”分别配置;报价、创建及待接单改址均以订单车型命中运价,订单保存车型与既有价格快照,后续调价不改变历史订单。
+- 报价、创建及待接单改址调用 Google Routes 时同样传订单车型:机车使用 `TWO_WHEELER`,轿车使用 `DRIVE`;返回的路线距离和预计时长随订单保存。地图失败时降级为直线距离并标记 `STRAIGHT_LINE`,预计时长按机车 25 km/h、轿车 30 km/h 保守估算,向上取整且最少 60 秒;该展示估算不参与计价。
+- 骑手沿用既有 `info_user.vehicle_type`(`1`=机车、`2`=轿车);待抢列表、待抢详情和接单均要求与订单车型一致。历史骑手、运价和订单均默认机车,保证迁移后既有行为不变。
+- 重量范围与体积/规格说明改为选填;未传时保存 `NULL`,不参与本期运费计算。物品类别、数量、小费及地址校验保持必填规则。
+- 本期不修改用户 App 界面;后续 App 仅需在报价和创建请求都传相同的 `vehicleType`。

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

@@ -179,3 +179,7 @@
 - [ ] T098 实现 FR-095、FR-096 当前目标及调整记录保存、DTO/实体/Mapper/视图/必要 SQL、原因与声明校验、订单权限和状态保护、重复请求与并发保护;取件校验有效目标及必要记录,目标完成后回到原顺序最早未取站,不修改原顺序/物品/金额
 - [ ] T099 落实用户端排序与提示、骑手端默认下一站/沟通后选择目标/前往与取件分离、各端实际取件进度及取消入口;平台可核对调整声明、异常处理结果和已取物品去向/交接凭证,用户可见文案国际化,聊天可作为沟通依据
 - [ ] T100 补充并执行顺序调整验收:有无原因/声明、直接取件绕过、导航不推进状态、B 取件后回 A、原顺序与金额不变、进度/取消按钮、声明展示、异常交接、调整重复及与取件/取消并发、已取站重试;配合 T095 记录实际测试与 App/数据库联调结果
+
+- [x] T101 【2026-09-17】按 `vehicleType`(1=机车、2=轿车)扩展闪送运价、报价、订单快照、骑手待抢/详情/接单校验、Google Routes(机车 TWO_WHEELER、轿车 DRIVE)与平台运价配置;历史数据默认机车;重量范围和规格改为选填。本期不修改用户 App,详见 spec.md「按配送车型计价」。
+- [x] T102 【2026-09-17】闪送与外卖共用 `sys_googlemap_key`;Google Routes 降级日志仅记录车型和失败原因,不记录地址、坐标或 Key;同步契约、快速开始和研究文档。
+- [x] T103 【2026-09-17】预约单结束时间到达后自动取消:列表/接单限制结束边界,Quartz 每分钟以条件更新收口,平台详情概况显示预约取件时段,补 SQL、注释和定向测试;SQL 仅已登记,尚未执行。

+ 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;不格式化整文件

+ 60 - 0
updatesql/sql.md

@@ -1355,6 +1355,20 @@ WHERE NOT EXISTS (
 );
 ```
 
+## 2026-09-17 闪送预约超时自动取消(024-flash-delivery)
+
+> 用途:每分钟取消预约取件时间已结束、仍无人接单的闪送订单。仅记录脚本,由开发者手动执行;接单 SQL 同时校验预约窗口,任务延迟不会允许过期接单。
+
+```sql
+INSERT INTO sys_job
+  (job_name, job_group, invoke_target, cron_expression, misfire_policy, concurrent, status, create_by, create_time, remark)
+SELECT '闪送预约超时取消', 'DEFAULT', 'flashDeliveryAutoCompleteTask.cancelExpiredScheduledOrders()',
+       '0 0/1 * * * ?', '3', '1', '0', 'admin', NOW(), '预约取件时段结束仍未接单的闪送订单自动取消'
+WHERE NOT EXISTS (
+  SELECT 1 FROM sys_job WHERE invoke_target = 'flashDeliveryAutoCompleteTask.cancelExpiredScheduledOrders()'
+);
+```
+
 ## 2026-09-02 骑手配送类型(025-rider-delivery-type)
 
 > 用途:骑手审核资料增加配送类型多选(美食外送 FOOD、闪送 FLASH),派单按骑手选择的配送类型过滤。只记录脚本,由开发者手动执行。
@@ -1539,3 +1553,49 @@ ALTER TABLE flash_delivery_order
 ALTER TABLE info_user
   MODIFY COLUMN avatar VARCHAR(1000) DEFAULT '' COMMENT '头像地址';
 ```
+
+## 2026-09-17 闪送按车型计价(024-flash-delivery)
+
+> 用途:闪送运价和订单快照增加 `vehicle_type`(1=机车、2=轿车)。默认值为 1,确保历史运价和订单继续按机车处理;骑手复用既有 `info_user.vehicle_type` 字段。脚本只登记,由开发者备份后手动执行。
+
+```sql
+ALTER TABLE flash_delivery_pricing
+  ADD COLUMN vehicle_type TINYINT NOT NULL DEFAULT 1 COMMENT '配送车型:1=机车,2=轿车' AFTER id,
+  ADD KEY idx_flash_pricing_vehicle_period (vehicle_type, start_time, end_time);
+
+ALTER TABLE flash_delivery_order
+  ADD COLUMN vehicle_type TINYINT NOT NULL DEFAULT 1 COMMENT '下单车型快照:1=机车,2=轿车' AFTER delivery_type,
+  ADD KEY idx_flash_available_vehicle (status, vehicle_type, delivery_mode, scheduled_pickup_start_at);
+```
+
+## 2026-09-18 闪送订单开放展示与新单推送(028-flash-delivery-push)
+
+> 用途:立即单创建后直接向符合条件的骑手开放并推送;预约单创建时隐藏,由独立定时任务在 `scheduled_pickup_start_at <= NOW() < scheduled_pickup_end_at` 时原子切换为可展示并推送一次。脚本只记录,由开发者备份后手动执行。
+
+```sql
+ALTER TABLE flash_delivery_order
+  ADD COLUMN is_display TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否向骑手开放展示:0否,1是' AFTER status,
+  ADD KEY idx_flash_scheduled_visibility (status, delivery_mode, is_display, scheduled_pickup_start_at);
+
+-- 存量待接预约单先隐藏;处于有效时间窗的订单会在任务下一次执行时开放并推送。
+UPDATE flash_delivery_order
+SET is_display = 0
+WHERE status = 'WAITING_ACCEPTANCE'
+  AND rider_id IS NULL
+  AND delivery_mode = 'SCHEDULED';
+
+INSERT INTO sys_job
+  (job_name, job_group, invoke_target, cron_expression, misfire_policy, concurrent, status, create_by, create_time, remark)
+SELECT '闪送预约单开放展示', 'DEFAULT', 'flashDeliveryAvailabilityTask.openScheduledOrders()',
+       '0/1 * * * * ?', '3', '1', '0', 'admin', NOW(), '每秒开放已进入预约取件窗口的闪送订单并通知骑手'
+WHERE NOT EXISTS (
+  SELECT 1 FROM sys_job WHERE invoke_target = 'flashDeliveryAvailabilityTask.openScheduledOrders()'
+);
+```
+
+## 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=未评价';
+```

Неке датотеке нису приказане због велике количине промена