Kaynağa Gözat

补充闪送预约与安全交付能力

对照用户端和骑手端补充原型,增加包裹重量档、预约配送、费用明细、交付 PIN、寄件凭证和状态分组。
收紧角色响应与待抢订单隐私边界,改用若依动态定时任务,并同步测试、国际化、spec-kit 文档和迁移 SQL。
qmj 5 saat şu andan
ebeveyn
işleme
6b3a707c53
65 değiştirilmiş dosya ile 1104 ekleme ve 107 silme
  1. 7 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/controller/FlashDeliveryAdminController.java
  2. 12 3
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/controller/FlashDeliveryRiderController.java
  3. 10 1
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/controller/FlashDeliveryUserController.java
  4. 5 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryAddressRequest.java
  5. 19 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryAddressView.java
  6. 16 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryAdminOrderDetailView.java
  7. 12 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryAvailableOrderView.java
  8. 12 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryCreateRequest.java
  9. 11 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryDeliverRequest.java
  10. 1 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryHomeView.java
  11. 14 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryImageView.java
  12. 15 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryLogView.java
  13. 8 6
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryOrderDetailView.java
  14. 37 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryOrderView.java
  15. 18 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryPriceBreakdown.java
  16. 4 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryPricingUpdateRequest.java
  17. 1 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryProofRequest.java
  18. 3 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryQuoteRequest.java
  19. 9 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryQuoteView.java
  20. 1 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryReasonRequest.java
  21. 16 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryRiderSummaryView.java
  22. 3 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryServiceView.java
  23. 1 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/InfoAddressRequest.java
  24. 3 1
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/route/FlashDeliveryRouteService.java
  25. 1 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/route/GeoPoint.java
  26. 3 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/route/GoogleRoutesDistanceProvider.java
  27. 1 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/route/RouteDistance.java
  28. 1 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/route/RouteDistanceProvider.java
  29. 302 30
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryApplicationService.java
  30. 41 6
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryPricingCalculator.java
  31. 6 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryStateMachine.java
  32. 6 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/service/InfoAddressBookService.java
  33. 6 3
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/task/FlashDeliveryAutoCompleteTask.java
  34. 11 0
      ruoyi-admin/src/main/java/com/ruoyi/app/order/InfoAddressController.java
  35. 7 0
      ruoyi-admin/src/main/resources/i18n/messages.properties
  36. 7 0
      ruoyi-admin/src/main/resources/i18n/messages_en_US.properties
  37. 7 0
      ruoyi-admin/src/main/resources/i18n/messages_vi.properties
  38. 7 0
      ruoyi-admin/src/main/resources/i18n/messages_zh_CN.properties
  39. 7 0
      ruoyi-admin/src/main/resources/i18n/messages_zh_TW.properties
  40. 5 0
      ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/controller/FlashDeliveryControllerContractTest.java
  41. 13 0
      ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryAvailableOrderViewTest.java
  42. 26 0
      ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryOrderViewTest.java
  43. 145 4
      ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryApplicationServiceTest.java
  44. 25 11
      ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryPricingCalculatorTest.java
  45. 13 0
      ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/task/FlashDeliveryAutoCompleteTaskTest.java
  46. 2 0
      ruoyi-system/src/main/java/com/ruoyi/system/domain/InfoAddress.java
  47. 38 0
      ruoyi-system/src/main/java/com/ruoyi/system/domain/flash/FlashDeliveryOrder.java
  48. 4 0
      ruoyi-system/src/main/java/com/ruoyi/system/domain/flash/FlashDeliveryOrderImage.java
  49. 2 0
      ruoyi-system/src/main/java/com/ruoyi/system/domain/flash/FlashDeliveryOrderLog.java
  50. 9 0
      ruoyi-system/src/main/java/com/ruoyi/system/domain/flash/FlashDeliveryPricing.java
  51. 1 0
      ruoyi-system/src/main/java/com/ruoyi/system/domain/flash/FlashDeliveryStatus.java
  52. 1 0
      ruoyi-system/src/main/java/com/ruoyi/system/mapper/flash/FlashDeliveryOrderImageMapper.java
  53. 1 0
      ruoyi-system/src/main/java/com/ruoyi/system/mapper/flash/FlashDeliveryOrderLogMapper.java
  54. 12 0
      ruoyi-system/src/main/java/com/ruoyi/system/mapper/flash/FlashDeliveryOrderMapper.java
  55. 1 0
      ruoyi-system/src/main/java/com/ruoyi/system/mapper/flash/FlashDeliveryPricingMapper.java
  56. 26 0
      ruoyi-system/src/main/resources/mapper/flash/FlashDeliveryOrderMapper.xml
  57. 3 0
      ruoyi-system/src/test/java/com/ruoyi/system/mapper/flash/FlashDeliveryMapperXmlTest.java
  58. 13 10
      specs/024-flash-delivery/contracts/api.md
  59. 3 3
      specs/024-flash-delivery/data-model.md
  60. 12 12
      specs/024-flash-delivery/design.md
  61. 14 3
      specs/024-flash-delivery/plan.md
  62. 1 1
      specs/024-flash-delivery/research.md
  63. 27 13
      specs/024-flash-delivery/spec.md
  64. 10 0
      specs/024-flash-delivery/tasks.md
  65. 46 0
      updatesql/sql.md

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

@@ -11,16 +11,19 @@ import com.ruoyi.common.enums.BusinessType;
 import org.springframework.security.access.prepost.PreAuthorize;
 import org.springframework.web.bind.annotation.*;
 
+/** 平台端闪送管理接口,按计价和订单操作分别校验平台权限。 */
 @RestController
 @RequestMapping("/system/flashDelivery/admin")
 public class FlashDeliveryAdminController extends BaseController {
     private final FlashDeliveryApplicationService service;
     public FlashDeliveryAdminController(FlashDeliveryApplicationService service) { this.service = service; }
 
+    /** 查询三种闪送服务的完整后台计价配置。 */
     @PreAuthorize("@ss.hasPermi('flash:pricing:list')")
     @GetMapping("/pricing")
     public AjaxResult pricing() { return success(service.adminPricing()); }
 
+    /** 更新计价配置,服务层使用版本条件避免并发覆盖。 */
     @PreAuthorize("@ss.hasPermi('flash:pricing:edit')")
     @Log(title = "闪送计价配置", businessType = BusinessType.UPDATE)
     @PutMapping("/pricing/{serviceType}")
@@ -29,6 +32,7 @@ public class FlashDeliveryAdminController extends BaseController {
         return success(service.updatePricing(SecurityUtils.getUserId(), serviceType, request));
     }
 
+    /** 按状态、类型、订单号、用户或骑手筛选全部闪送订单。 */
     @PreAuthorize("@ss.hasPermi('flash:order:list')")
     @GetMapping("/orders")
     public AjaxResult orders(@RequestParam(defaultValue = "1") int pageNum,
@@ -41,10 +45,12 @@ public class FlashDeliveryAdminController extends BaseController {
         return success(service.adminOrders(pageNum, pageSize, status, serviceType, orderNo, userId, riderId));
     }
 
+    /** 查询完整订单、凭证和审计日志。 */
     @PreAuthorize("@ss.hasPermi('flash:order:query')")
     @GetMapping("/orders/{id}")
     public AjaxResult detail(@PathVariable Long id) { return success(service.adminDetail(id)); }
 
+    /** 平台取消非终态异常订单,并记录原因和操作人。 */
     @PreAuthorize("@ss.hasPermi('flash:order:cancel')")
     @Log(title = "闪送订单取消", businessType = BusinessType.UPDATE)
     @PostMapping("/orders/{id}/cancel")
@@ -53,6 +59,7 @@ public class FlashDeliveryAdminController extends BaseController {
         return success();
     }
 
+    /** 平台将已送达订单人工收口为已完成。 */
     @PreAuthorize("@ss.hasPermi('flash:order:complete')")
     @Log(title = "闪送订单完成", businessType = BusinessType.UPDATE)
     @PostMapping("/orders/{id}/complete")

+ 12 - 3
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/controller/FlashDeliveryRiderController.java

@@ -1,6 +1,7 @@
 package com.ruoyi.app.flashdelivery.controller;
 
 import com.ruoyi.app.flashdelivery.dto.FlashDeliveryProofRequest;
+import com.ruoyi.app.flashdelivery.dto.FlashDeliveryDeliverRequest;
 import com.ruoyi.app.flashdelivery.service.FlashDeliveryApplicationService;
 import com.ruoyi.common.annotation.Anonymous;
 import com.ruoyi.common.core.controller.BaseController;
@@ -9,6 +10,7 @@ import com.ruoyi.system.utils.Auth;
 import com.ruoyi.system.utils.JwtUtil;
 import org.springframework.web.bind.annotation.*;
 
+/** 骑手端闪送接口;仅 userType=2 的 token 可执行接单和履约操作。 */
 @RestController
 @RequestMapping("/system/flashDelivery/rider")
 @Anonymous
@@ -16,6 +18,7 @@ public class FlashDeliveryRiderController extends BaseController {
     private final FlashDeliveryApplicationService service;
     public FlashDeliveryRiderController(FlashDeliveryApplicationService service) { this.service = service; }
 
+    /** 查询可抢订单,返回接单前安全字段而不是完整订单实体。 */
     @GetMapping("/orders/available")
     @Auth
     public AjaxResult available(@RequestHeader String token,
@@ -25,27 +28,32 @@ public class FlashDeliveryRiderController extends BaseController {
         return success(service.available(userId(token), pageNum, pageSize, serviceType));
     }
 
+    /** 查询当前骑手已经抢到的任务。 */
     @GetMapping("/orders/mine")
     @Auth
     public AjaxResult mine(@RequestHeader String token,
                            @RequestParam(defaultValue = "1") int pageNum,
                            @RequestParam(defaultValue = "10") int pageSize,
-                           @RequestParam(required = false) String status) {
-        return success(service.riderOrders(userId(token), pageNum, pageSize, status));
+                           @RequestParam(required = false) String status,
+                           @RequestParam(required = false) String scene) {
+        return success(service.riderOrders(userId(token), pageNum, pageSize, status, scene));
     }
 
+    /** 未接单时返回安全摘要,中单后仅中单骑手可见完整信息。 */
     @GetMapping("/orders/{id}")
     @Auth
     public AjaxResult detail(@RequestHeader String token, @PathVariable Long id) {
         return success(service.riderDetail(userId(token), id));
     }
 
+    /** 原子抢单;同一订单只能由一个骑手成功接取。 */
     @PostMapping("/orders/{id}/accept")
     @Auth
     public AjaxResult accept(@RequestHeader String token, @PathVariable Long id) {
         return success(service.accept(userId(token), id));
     }
 
+    /** 上传取件凭证并将已接单订单推进为已取件。 */
     @PostMapping("/orders/{id}/pickup")
     @Auth
     public AjaxResult pickup(@RequestHeader String token, @PathVariable Long id,
@@ -54,10 +62,11 @@ public class FlashDeliveryRiderController extends BaseController {
         return success();
     }
 
+    /** 上传送达凭证并将已取件订单推进为已送达。 */
     @PostMapping("/orders/{id}/deliver")
     @Auth
     public AjaxResult deliver(@RequestHeader String token, @PathVariable Long id,
-                              @RequestBody FlashDeliveryProofRequest request) {
+                              @RequestBody FlashDeliveryDeliverRequest request) {
         service.deliver(userId(token), id, request);
         return success();
     }

+ 10 - 1
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/controller/FlashDeliveryUserController.java

@@ -9,6 +9,7 @@ import com.ruoyi.system.utils.Auth;
 import com.ruoyi.system.utils.JwtUtil;
 import org.springframework.web.bind.annotation.*;
 
+/** 用户端闪送接口;用户身份始终从请求头 token 解析。 */
 @RestController
 @RequestMapping("/system/flashDelivery")
 @Anonymous
@@ -16,10 +17,12 @@ public class FlashDeliveryUserController extends BaseController {
     private final FlashDeliveryApplicationService service;
     public FlashDeliveryUserController(FlashDeliveryApplicationService service) { this.service = service; }
 
+    /** 获取当前启用的服务类型和公开计价摘要。 */
     @GetMapping("/home")
     @Auth
     public AjaxResult home(@RequestHeader String token) { return success(service.home()); }
 
+    /** 根据取送坐标获取服务端实时报价。 */
     @PostMapping("/quote")
     @Auth
     public AjaxResult quote(@RequestHeader String token, @RequestBody FlashDeliveryQuoteRequest request) {
@@ -27,28 +30,33 @@ public class FlashDeliveryUserController extends BaseController {
         return success(service.quote(request));
     }
 
+    /** 幂等创建闪送订单,客户端不能指定用户、距离或金额。 */
     @PostMapping("/orders")
     @Auth
     public AjaxResult create(@RequestHeader String token, @RequestBody FlashDeliveryCreateRequest request) {
         return success(service.create(userId(token), request));
     }
 
+    /** 分页查询当前用户自己的闪送订单。 */
     @GetMapping("/orders")
     @Auth
     public AjaxResult orders(@RequestHeader String token,
                              @RequestParam(defaultValue = "1") int pageNum,
                              @RequestParam(defaultValue = "10") int pageSize,
                              @RequestParam(required = false) String status,
+                             @RequestParam(required = false) String scene,
                              @RequestParam(required = false) String serviceType) {
-        return success(service.userOrders(userId(token), pageNum, pageSize, status, serviceType));
+        return success(service.userOrders(userId(token), pageNum, pageSize, status, scene, serviceType));
     }
 
+    /** 查询本人订单详情、履约图片和状态日志。 */
     @GetMapping("/orders/{id}")
     @Auth
     public AjaxResult detail(@RequestHeader String token, @PathVariable Long id) {
         return success(service.userDetail(userId(token), id));
     }
 
+    /** 在待接单或已接单阶段取消本人订单。 */
     @PostMapping("/orders/{id}/cancel")
     @Auth
     public AjaxResult cancel(@RequestHeader String token, @PathVariable Long id,
@@ -57,6 +65,7 @@ public class FlashDeliveryUserController extends BaseController {
         return success();
     }
 
+    /** 用户确认已经收到物品,将已送达订单置为已完成。 */
     @PostMapping("/orders/{id}/confirmReceipt")
     @Auth
     public AjaxResult confirmReceipt(@RequestHeader String token, @PathVariable Long id) {

+ 5 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryAddressRequest.java

@@ -4,12 +4,17 @@ import lombok.Data;
 
 import java.math.BigDecimal;
 
+/** 下单或报价使用的联系人和地理坐标快照。 */
 @Data
 public class FlashDeliveryAddressRequest {
     private String name;
     private String phone;
     private String address;
     private String addressDetail;
+    /** 市/区仅用于安全区域展示,不得替代完整地址校验。 */
+    private String city;
+    private String area;
+    private String handoffMethod;
     private BigDecimal longitude;
     private BigDecimal latitude;
 }

+ 19 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryAddressView.java

@@ -0,0 +1,19 @@
+package com.ruoyi.app.flashdelivery.dto;
+
+import lombok.Data;
+
+import java.math.BigDecimal;
+
+/** 订单用户或中单骑手可见的取送地址业务快照。 */
+@Data
+public class FlashDeliveryAddressView {
+    private String name;
+    private String phone;
+    private String address;
+    private String addressDetail;
+    private String city;
+    private String area;
+    private String handoffMethod;
+    private BigDecimal longitude;
+    private BigDecimal latitude;
+}

+ 16 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryAdminOrderDetailView.java

@@ -0,0 +1,16 @@
+package com.ruoyi.app.flashdelivery.dto;
+
+import com.ruoyi.system.domain.flash.FlashDeliveryOrder;
+import com.ruoyi.system.domain.flash.FlashDeliveryOrderImage;
+import com.ruoyi.system.domain.flash.FlashDeliveryOrderLog;
+import lombok.Data;
+
+import java.util.List;
+
+/** 平台审计详情,平台权限通过后才返回完整持久化字段。 */
+@Data
+public class FlashDeliveryAdminOrderDetailView {
+    private FlashDeliveryOrder order;
+    private List<FlashDeliveryOrderImage> images;
+    private List<FlashDeliveryOrderLog> logs;
+}

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

@@ -12,11 +12,23 @@ public class FlashDeliveryAvailableOrderView {
     private String orderNo;
     private String serviceType;
     private String status;
+    private String packageType;
+    private String packageSize;
+    private String deliveryMode;
+    private Date scheduledPickupStartAt;
+    private Date scheduledPickupEndAt;
+    /** 接单前只返回市/区,不返回街道、门牌或联系人。 */
+    private String pickupCity;
+    private String pickupArea;
+    private String deliveryCity;
+    private String deliveryArea;
+    /** 仅保留两位小数的区域级坐标,接单前不返回精确地址。 */
     private BigDecimal pickupLatitudeApprox;
     private BigDecimal pickupLongitudeApprox;
     private BigDecimal deliveryLatitudeApprox;
     private BigDecimal deliveryLongitudeApprox;
     private Integer distanceMeters;
+    private Integer estimatedDurationSeconds;
     private BigDecimal amount;
     private String currency;
     private Date createTime;

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

@@ -3,9 +3,21 @@ package com.ruoyi.app.flashdelivery.dto;
 import lombok.Data;
 import lombok.EqualsAndHashCode;
 
+import java.util.Date;
+import java.util.List;
+
+/** 创建闪送订单请求;服务端会重新计算父类中的路线和价格。 */
 @Data
 @EqualsAndHashCode(callSuper = true)
 public class FlashDeliveryCreateRequest extends FlashDeliveryQuoteRequest {
+    /** 客户端生成的幂等请求号,同一用户重复提交时返回原订单。 */
     private String clientRequestId;
+    /** 立即送或预约送;为空时兼容为 NOW。 */
+    private String deliveryMode;
+    private Date scheduledPickupStartAt;
+    private Date scheduledPickupEndAt;
+    /** 为空时按补充原型默认启用 PIN。 */
+    private Boolean pinRequired;
+    private List<String> senderImageUrls;
     private String userNote;
 }

+ 11 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryDeliverRequest.java

@@ -0,0 +1,11 @@
+package com.ruoyi.app.flashdelivery.dto;
+
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+/** 骑手确认送达请求;启用 PIN 的订单必须同时提交四位交付码。 */
+@Data
+@EqualsAndHashCode(callSuper = true)
+public class FlashDeliveryDeliverRequest extends FlashDeliveryProofRequest {
+    private String pinCode;
+}

+ 1 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryHomeView.java

@@ -4,6 +4,7 @@ import lombok.Data;
 
 import java.util.List;
 
+/** 用户闪送首页响应,仅包含当前启用的公开服务摘要。 */
 @Data
 public class FlashDeliveryHomeView {
     private List<FlashDeliveryServiceView> services;

+ 14 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryImageView.java

@@ -0,0 +1,14 @@
+package com.ruoyi.app.flashdelivery.dto;
+
+import lombok.Data;
+
+import java.util.Date;
+
+/** 对用户和骑手公开的图片凭证,不暴露内部操作人 ID。 */
+@Data
+public class FlashDeliveryImageView {
+    private String proofType;
+    private String imageUrl;
+    private Integer sortOrder;
+    private Date createTime;
+}

+ 15 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryLogView.java

@@ -0,0 +1,15 @@
+package com.ruoyi.app.flashdelivery.dto;
+
+import lombok.Data;
+
+import java.util.Date;
+
+/** 面向订单参与者的状态日志,保留角色但隐藏内部操作人 ID。 */
+@Data
+public class FlashDeliveryLogView {
+    private String fromStatus;
+    private String toStatus;
+    private String operatorType;
+    private String reason;
+    private Date createTime;
+}

+ 8 - 6
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryOrderDetailView.java

@@ -1,15 +1,17 @@
 package com.ruoyi.app.flashdelivery.dto;
 
-import com.ruoyi.system.domain.flash.FlashDeliveryOrder;
-import com.ruoyi.system.domain.flash.FlashDeliveryOrderImage;
-import com.ruoyi.system.domain.flash.FlashDeliveryOrderLog;
+import com.fasterxml.jackson.annotation.JsonInclude;
 import lombok.Data;
 
 import java.util.List;
 
+/** 用户或中单骑手详情;PIN 仅在订单用户响应中赋值。 */
 @Data
 public class FlashDeliveryOrderDetailView {
-    private FlashDeliveryOrder order;
-    private List<FlashDeliveryOrderImage> images;
-    private List<FlashDeliveryOrderLog> logs;
+    private FlashDeliveryOrderView order;
+    private List<FlashDeliveryImageView> images;
+    private List<FlashDeliveryLogView> logs;
+    private FlashDeliveryRiderSummaryView rider;
+    @JsonInclude(JsonInclude.Include.NON_NULL)
+    private String deliveryPinCode;
 }

+ 37 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryOrderView.java

@@ -0,0 +1,37 @@
+package com.ruoyi.app.flashdelivery.dto;
+
+import lombok.Data;
+
+import java.math.BigDecimal;
+import java.util.Date;
+
+/** 用户和中单骑手共用的业务订单视图,不包含持久化及身份内部字段。 */
+@Data
+public class FlashDeliveryOrderView {
+    private Long id;
+    private String orderNo;
+    private String serviceType;
+    private String status;
+    private String packageType;
+    private String packageSize;
+    private String deliveryMode;
+    private Date scheduledPickupStartAt;
+    private Date scheduledPickupEndAt;
+    private Boolean pinRequired;
+    private FlashDeliveryAddressView pickup;
+    private FlashDeliveryAddressView delivery;
+    private Integer distanceMeters;
+    private String distanceSource;
+    private Integer estimatedDurationSeconds;
+    private BigDecimal amount;
+    private String currency;
+    private String userNote;
+    private Date acceptedAt;
+    private Date pickedUpAt;
+    private Date deliveredAt;
+    private Date completedAt;
+    private Date cancelledAt;
+    private String cancelReason;
+    private Date createTime;
+    private Date updateTime;
+}

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

@@ -0,0 +1,18 @@
+package com.ruoyi.app.flashdelivery.dto;
+
+import lombok.Data;
+
+import java.math.BigDecimal;
+
+/** 报价计算的可展示明细,各金额统一保留两位小数。 */
+@Data
+public class FlashDeliveryPriceBreakdown {
+    private BigDecimal startPrice;
+    private BigDecimal minimumPrice;
+    private BigDecimal distanceFee;
+    private BigDecimal durationFee;
+    private BigDecimal subtotal;
+    private BigDecimal peakMultiplier;
+    private BigDecimal peakFee;
+    private BigDecimal amount;
+}

+ 4 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryPricingUpdateRequest.java

@@ -4,10 +4,14 @@ import lombok.Data;
 
 import java.math.BigDecimal;
 
+/** 平台更新某一服务类型计价规则的请求。 */
 @Data
 public class FlashDeliveryPricingUpdateRequest {
     private BigDecimal startPrice;
+    private BigDecimal minimumPrice;
     private Integer startDistanceMeters;
     private BigDecimal perKmPrice;
+    private BigDecimal perMinutePrice;
+    private BigDecimal peakMultiplier;
     private Boolean enabled;
 }

+ 1 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryProofRequest.java

@@ -4,6 +4,7 @@ import lombok.Data;
 
 import java.util.List;
 
+/** 骑手取件或送达时提交的图片凭证,业务层要求 1 至 9 张有效 URL。 */
 @Data
 public class FlashDeliveryProofRequest {
     private List<String> imageUrls;

+ 3 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryQuoteRequest.java

@@ -2,9 +2,12 @@ package com.ruoyi.app.flashdelivery.dto;
 
 import lombok.Data;
 
+/** 闪送报价请求,支持帮送、帮取和加急送。 */
 @Data
 public class FlashDeliveryQuoteRequest {
     private String serviceType;
+    private String packageType;
+    private String packageSize;
     private FlashDeliveryAddressRequest pickup;
     private FlashDeliveryAddressRequest delivery;
 }

+ 9 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryQuoteView.java

@@ -4,13 +4,22 @@ import lombok.Data;
 
 import java.math.BigDecimal;
 
+/** 服务端报价结果;distanceSource 用于区分地图路线和降级距离。 */
 @Data
 public class FlashDeliveryQuoteView {
     private String serviceType;
     private Integer distanceMeters;
     private String distanceSource;
     private Integer estimatedDurationSeconds;
+    private BigDecimal startPrice;
+    private BigDecimal minimumPrice;
+    private BigDecimal distanceFee;
+    private BigDecimal durationFee;
+    private BigDecimal subtotal;
+    private BigDecimal peakMultiplier;
+    private BigDecimal peakFee;
     private BigDecimal amount;
     private String currency;
+    /** 报价采用的计价版本,创建订单时仍会按最新配置重新计算。 */
     private Integer pricingVersion;
 }

+ 1 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryReasonRequest.java

@@ -2,6 +2,7 @@ package com.ruoyi.app.flashdelivery.dto;
 
 import lombok.Data;
 
+/** 用户或平台取消闪送订单时提交的原因。 */
 @Data
 public class FlashDeliveryReasonRequest {
     private String reason;

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

@@ -0,0 +1,16 @@
+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;
+    private String imUserId;
+    private BigDecimal longitude;
+    private BigDecimal latitude;
+}

+ 3 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryServiceView.java

@@ -9,8 +9,11 @@ import java.math.BigDecimal;
 public class FlashDeliveryServiceView {
     private String serviceType;
     private BigDecimal startPrice;
+    private BigDecimal minimumPrice;
     private Integer startDistanceMeters;
     private BigDecimal perKmPrice;
+    private BigDecimal perMinutePrice;
+    private BigDecimal peakMultiplier;
     private Integer pricingVersion;
     private String currency;
 }

+ 1 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/InfoAddressRequest.java

@@ -3,6 +3,7 @@ package com.ruoyi.app.flashdelivery.dto;
 import lombok.Data;
 import java.math.BigDecimal;
 
+/** 用户地址簿新增/修改请求;userId 永远由 token 决定,不对客户端开放。 */
 @Data
 public class InfoAddressRequest {
     private Long id;

+ 3 - 1
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/route/FlashDeliveryRouteService.java

@@ -2,6 +2,7 @@ package com.ruoyi.app.flashdelivery.route;
 
 import org.springframework.stereotype.Service;
 
+/** 路线距离服务:优先请求地图路线,异常时使用球面直线距离保证报价仍然可用。 */
 @Service
 public class FlashDeliveryRouteService {
     private static final double EARTH_RADIUS_METERS = 6_371_000D;
@@ -21,8 +22,9 @@ public class FlashDeliveryRouteService {
                 return route;
             }
         } catch (RuntimeException ignored) {
-            // External route failures intentionally fall back to straight-line distance.
+            // 地图超时、限流或配置缺失不阻断下单,统一进入本地 Haversine 降级。
         }
+        // Haversine 只作为降级结果,并通过 source=STRAIGHT_LINE 明确告知调用方。
         double lat1 = Math.toRadians(origin.latitude().doubleValue());
         double lat2 = Math.toRadians(destination.latitude().doubleValue());
         double deltaLat = lat2 - lat1;

+ 1 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/route/GeoPoint.java

@@ -2,4 +2,5 @@ package com.ruoyi.app.flashdelivery.route;
 
 import java.math.BigDecimal;
 
+/** 不可变经纬度坐标,纬度在前、经度在后。 */
 public record GeoPoint(BigDecimal latitude, BigDecimal longitude) { }

+ 3 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/route/GoogleRoutesDistanceProvider.java

@@ -19,6 +19,7 @@ import java.math.BigDecimal;
 import java.nio.charset.StandardCharsets;
 import java.util.List;
 
+/** Google Routes API 的服务端实现,密钥只从后端专用字典读取。 */
 @Component
 public class GoogleRoutesDistanceProvider implements RouteDistanceProvider {
     private static final String ENDPOINT = "https://routes.googleapis.com/directions/v2:computeRoutes";
@@ -28,6 +29,7 @@ public class GoogleRoutesDistanceProvider implements RouteDistanceProvider {
     @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");
         if (keys == null || keys.isEmpty() || keys.get(0).getDictValue() == null
                 || keys.get(0).getDictValue().isBlank()) {
@@ -43,6 +45,7 @@ public class GoogleRoutesDistanceProvider implements RouteDistanceProvider {
         HttpPost request = new HttpPost(ENDPOINT);
         request.setConfig(TIMEOUTS);
         request.setHeader("X-Goog-Api-Key", keys.get(0).getDictValue());
+        // 只请求计价需要的字段,减少响应体及不必要的数据暴露。
         request.setHeader("X-Goog-FieldMask", "routes.distanceMeters,routes.duration");
         request.setEntity(new StringEntity(body.toJSONString(), ContentType.APPLICATION_JSON));
         try (CloseableHttpClient client = HttpClients.custom().disableAutomaticRetries().build();

+ 1 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/route/RouteDistance.java

@@ -1,3 +1,4 @@
 package com.ruoyi.app.flashdelivery.route;
 
+/** 路线计算结果;source 标识 ROUTE 或 STRAIGHT_LINE。 */
 public record RouteDistance(int distanceMeters, Integer durationSeconds, String source) { }

+ 1 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/route/RouteDistanceProvider.java

@@ -3,6 +3,7 @@ package com.ruoyi.app.flashdelivery.route;
 import java.math.BigDecimal;
 
 @FunctionalInterface
+/** 外部路线距离提供器,失败时由上层服务负责本地降级。 */
 public interface RouteDistanceProvider {
     RouteDistance route(BigDecimal originLatitude, BigDecimal originLongitude,
                         BigDecimal destinationLatitude, BigDecimal destinationLongitude);

+ 302 - 30
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryApplicationService.java

@@ -22,13 +22,29 @@ import org.springframework.transaction.annotation.Transactional;
 import java.net.URI;
 import java.math.BigDecimal;
 import java.math.RoundingMode;
+import java.security.SecureRandom;
 import java.util.*;
+import java.util.function.Function;
 
 import static com.ruoyi.system.domain.flash.FlashDeliveryStatus.*;
 
+/**
+ * 闪送业务编排服务。
+ *
+ * <p>负责报价、订单创建、抢单、履约状态流转、平台介入和审计日志。
+ * 订单身份只接受 Controller 从 token 解析后的用户 ID,客户端传入的金额、距离或操作人均不可信。</p>
+ */
 @Service
 public class FlashDeliveryApplicationService {
     private static final Set<String> SERVICE_TYPES = Set.of("HELP_SEND", "HELP_PICKUP", "URGENT");
+    private static final Set<String> PACKAGE_TYPES = Set.of("DOCUMENT", "GIFT", "CLOTHING", "BEAUTY",
+            "DAILY_NECESSITIES", "FOOD_INGREDIENTS", "ELECTRONICS", "SMALL_APPLIANCE", "OTHER");
+    private static final Set<String> PACKAGE_SIZES = Set.of("SMALL", "MEDIUM", "LARGE");
+    private static final Set<String> DELIVERY_MODES = Set.of("NOW", "SCHEDULED");
+    private static final int MAX_DISTANCE_METERS = 40_000;
+    private static final long SCHEDULE_SLOT_MILLIS = 30L * 60L * 1000L;
+    private static final long MAX_SCHEDULE_DELAY_MILLIS = 3L * 24L * 60L * 60L * 1000L;
+    private static final SecureRandom PIN_RANDOM = new SecureRandom();
     private final FlashDeliveryOrderMapper orderMapper;
     private final FlashDeliveryPricingMapper pricingMapper;
     private final FlashDeliveryOrderImageMapper imageMapper;
@@ -61,13 +77,14 @@ public class FlashDeliveryApplicationService {
     }
 
     public FlashDeliveryQuoteView quote(FlashDeliveryQuoteRequest request) {
+        // 报价只使用服务端路线距离和当前启用的计价配置,避免客户端篡改金额。
         QuoteContext context = calculateQuote(request);
         FlashDeliveryQuoteView view = new FlashDeliveryQuoteView();
         view.setServiceType(context.pricing().getServiceType());
         view.setDistanceMeters(context.route().distanceMeters());
         view.setDistanceSource(context.route().source());
         view.setEstimatedDurationSeconds(context.route().durationSeconds());
-        view.setAmount(context.amount());
+        applyBreakdown(view, context.breakdown());
         view.setCurrency("TWD");
         view.setPricingVersion(context.pricing().getConfigVersion());
         return view;
@@ -81,30 +98,49 @@ public class FlashDeliveryApplicationService {
             throw fail("flash.delivery.client.request.id.invalid");
         }
         String requestId = request.getClientRequestId().trim();
+        // 用户 ID + clientRequestId 构成幂等键;重复请求直接返回第一次创建的订单。
         FlashDeliveryOrder existing = orderMapper.selectByUserRequestId(userId, requestId);
-        if (existing != null) return detail(existing, false);
+        if (existing != null) return participantDetail(existing, true, false);
         if (request.getUserNote() != null && request.getUserNote().length() > 500) {
             throw fail("flash.delivery.note.too.long");
         }
         QuoteContext quote = calculateQuote(request);
         Date now = new Date();
+        String deliveryMode = normalizeDeliveryMode(request.getDeliveryMode());
+        validateSchedule(deliveryMode, request.getScheduledPickupStartAt(), request.getScheduledPickupEndAt(), now);
+        boolean pinRequired = request.getPinRequired() == null || request.getPinRequired();
+        List<String> senderImages = validateProofUrls(request.getSenderImageUrls(), false);
         FlashDeliveryOrder order = new FlashDeliveryOrder();
         order.setOrderNo("FD" + UUID.randomUUID().toString().replace("-", "").substring(0, 24).toUpperCase(Locale.ROOT));
         order.setClientRequestId(requestId);
         order.setUserId(userId);
         order.setServiceType(request.getServiceType());
         order.setStatus(WAITING_ACCEPTANCE);
+        order.setPackageType(request.getPackageType());
+        order.setPackageSize(request.getPackageSize());
+        order.setDeliveryMode(deliveryMode);
+        order.setScheduledPickupStartAt("SCHEDULED".equals(deliveryMode) ? request.getScheduledPickupStartAt() : null);
+        order.setScheduledPickupEndAt("SCHEDULED".equals(deliveryMode) ? request.getScheduledPickupEndAt() : null);
+        order.setPinRequired(pinRequired);
+        order.setDeliveryPinCode(pinRequired ? String.format(Locale.ROOT, "%04d", PIN_RANDOM.nextInt(10_000)) : null);
         copyAddress(request.getPickup(), order, true);
         copyAddress(request.getDelivery(), order, false);
         order.setDistanceMeters(quote.route().distanceMeters());
         order.setDistanceSource(quote.route().source());
         order.setEstimatedDurationSeconds(quote.route().durationSeconds());
-        order.setAmount(quote.amount());
+        order.setAmount(quote.breakdown().getAmount());
         order.setCurrency("TWD");
         order.setPricingVersion(quote.pricing().getConfigVersion());
+        // 保存计价快照,后台调价只影响新订单,不追溯修改历史订单金额。
         order.setStartPrice(quote.pricing().getStartPrice());
+        order.setMinimumPrice(quote.pricing().getMinimumPrice());
         order.setStartDistanceMeters(quote.pricing().getStartDistanceMeters());
         order.setPerKmPrice(quote.pricing().getPerKmPrice());
+        order.setPerMinutePrice(quote.pricing().getPerMinutePrice());
+        order.setPeakMultiplier(quote.pricing().getPeakMultiplier());
+        order.setDistanceFee(quote.breakdown().getDistanceFee());
+        order.setDurationFee(quote.breakdown().getDurationFee());
+        order.setPeakFee(quote.breakdown().getPeakFee());
         order.setUserNote(trimToNull(request.getUserNote()));
         order.setVersion(0);
         order.setCreateTime(now);
@@ -112,28 +148,32 @@ public class FlashDeliveryApplicationService {
         try {
             orderMapper.insert(order);
         } catch (DataIntegrityViolationException duplicate) {
+            // 并发重复请求可能同时通过前置查询,最终由数据库唯一键完成幂等收口。
             FlashDeliveryOrder concurrent = orderMapper.selectByUserRequestId(userId, requestId);
-            if (concurrent != null) return detail(concurrent, false);
+            if (concurrent != null) return participantDetail(concurrent, true, false);
             throw duplicate;
         }
+        saveImages(order.getId(), "SENDER", "USER", userId, senderImages, now);
         writeLog(order.getId(), null, WAITING_ACCEPTANCE, "USER", userId, null, now);
-        return detail(order, false);
+        return participantDetail(order, true, false);
     }
 
-    public IPage<FlashDeliveryOrder> userOrders(Long userId, int pageNum, int pageSize,
-                                                String status, String serviceType) {
+    public IPage<FlashDeliveryOrderView> userOrders(Long userId, int pageNum, int pageSize,
+                                                    String status, String scene, String serviceType) {
+        if (hasText(status) && hasText(scene)) throw fail("flash.delivery.scene.invalid");
         QueryWrapper<FlashDeliveryOrder> query = new QueryWrapper<FlashDeliveryOrder>()
                 .eq("user_id", userId).orderByDesc("create_time");
         optionalEq(query, "status", status);
+        applyUserScene(query, scene);
         validateOptionalServiceType(serviceType);
         optionalEq(query, "service_type", serviceType);
-        return orderMapper.selectPage(page(pageNum, pageSize), query);
+        return mapPage(orderMapper.selectPage(page(pageNum, pageSize), query), this::orderView);
     }
 
     public FlashDeliveryOrderDetailView userDetail(Long userId, Long orderId) {
         FlashDeliveryOrder order = requireOrder(orderId);
         if (!Objects.equals(userId, order.getUserId())) throw fail("flash.delivery.order.not.found");
-        return detail(order, false);
+        return participantDetail(order, true, false);
     }
 
     @Transactional
@@ -165,8 +205,11 @@ public class FlashDeliveryApplicationService {
         requireRider(riderId);
         validateOptionalServiceType(serviceType);
         QueryWrapper<FlashDeliveryOrder> query = new QueryWrapper<FlashDeliveryOrder>()
-                .eq("status", WAITING_ACCEPTANCE).isNull("rider_id");
+                .eq("status", WAITING_ACCEPTANCE).isNull("rider_id")
+                .and(item -> item.eq("delivery_mode", "NOW")
+                        .or().le("scheduled_pickup_start_at", new Date()));
         optionalEq(query, "service_type", serviceType);
+        // 第一版不自动派单:加急单优先,其余订单按发布时间由骑手主动抢单。
         query.orderByAsc("case when service_type = 'URGENT' then 0 else 1 end", "create_time");
         IPage<FlashDeliveryOrder> orders = orderMapper.selectPage(page(pageNum, pageSize), query);
         Page<FlashDeliveryAvailableOrderView> result = new Page<>(orders.getCurrent(), orders.getSize(), orders.getTotal());
@@ -174,31 +217,37 @@ public class FlashDeliveryApplicationService {
         return result;
     }
 
-    public IPage<FlashDeliveryOrder> riderOrders(Long riderId, int pageNum, int pageSize, String status) {
+    public IPage<FlashDeliveryOrderView> riderOrders(Long riderId, int pageNum, int pageSize,
+                                                     String status, String scene) {
         requireRider(riderId);
+        if (hasText(status) && hasText(scene)) throw fail("flash.delivery.scene.invalid");
         QueryWrapper<FlashDeliveryOrder> query = new QueryWrapper<FlashDeliveryOrder>()
                 .eq("rider_id", riderId).orderByDesc("create_time");
         optionalEq(query, "status", status);
-        return orderMapper.selectPage(page(pageNum, pageSize), query);
+        applyRiderScene(query, scene);
+        return mapPage(orderMapper.selectPage(page(pageNum, pageSize), query), this::orderView);
     }
 
     public Object riderDetail(Long riderId, Long orderId) {
         requireRider(riderId);
         FlashDeliveryOrder order = requireOrder(orderId);
         if (WAITING_ACCEPTANCE.equals(order.getStatus()) && order.getRiderId() == null) {
+            if (!isAvailableAt(order, new Date())) throw fail("flash.delivery.order.not.found");
+            // 未接单骑手只能看到白名单字段和近似坐标,联系人及精确地址在抢单成功后开放。
             return availableView(order);
         }
         if (!Objects.equals(riderId, order.getRiderId())) throw fail("flash.delivery.order.not.found");
-        return detail(order, false);
+        return participantDetail(order, false, false);
     }
 
     @Transactional
     public FlashDeliveryOrderDetailView accept(Long riderId, Long orderId) {
         requireRider(riderId);
         Date now = new Date();
+        // 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);
-        return detail(requireOrder(orderId), false);
+        return participantDetail(requireOrder(orderId), false, false);
     }
 
     @Transactional
@@ -207,7 +256,16 @@ public class FlashDeliveryApplicationService {
     }
 
     @Transactional
-    public void deliver(Long riderId, Long orderId, FlashDeliveryProofRequest request) {
+    public void deliver(Long riderId, Long orderId, FlashDeliveryDeliverRequest request) {
+        requireRider(riderId);
+        FlashDeliveryOrder order = requireOrder(orderId);
+        if (!Objects.equals(riderId, order.getRiderId()) || !PICKED_UP.equals(order.getStatus())) {
+            throw fail("flash.delivery.transition.not.allowed");
+        }
+        if (Boolean.TRUE.equals(order.getPinRequired())
+                && (request == null || !Objects.equals(order.getDeliveryPinCode(), trimToNull(request.getPinCode())))) {
+            throw fail("flash.delivery.pin.invalid");
+        }
         transitionWithProof(riderId, orderId, request, PICKED_UP, DELIVERED, "DELIVERY", "delivered_at");
     }
 
@@ -220,28 +278,42 @@ public class FlashDeliveryApplicationService {
                                                FlashDeliveryPricingUpdateRequest request) {
         validateServiceType(serviceType);
         if (request == null || request.getStartPrice() == null || request.getStartPrice().signum() <= 0
+                || request.getMinimumPrice() == null || request.getMinimumPrice().signum() <= 0
                 || request.getStartDistanceMeters() == null || request.getStartDistanceMeters() <= 0
                 || request.getPerKmPrice() == null || request.getPerKmPrice().signum() <= 0
+                || request.getPerMinutePrice() == null || request.getPerMinutePrice().signum() < 0
+                || request.getPeakMultiplier() == null
+                || request.getPeakMultiplier().compareTo(BigDecimal.ONE) < 0
                 || request.getEnabled() == null) throw fail("flash.delivery.pricing.invalid");
         FlashDeliveryPricing pricing = findPricing(serviceType, false);
         if (pricing == null) throw fail("flash.delivery.pricing.not.found");
         int expectedVersion = pricing.getConfigVersion() == null ? 0 : pricing.getConfigVersion();
         Date now = new Date();
         BigDecimal startPrice = request.getStartPrice().setScale(2, RoundingMode.HALF_UP);
+        BigDecimal minimumPrice = request.getMinimumPrice().setScale(2, RoundingMode.HALF_UP);
         BigDecimal perKmPrice = request.getPerKmPrice().setScale(2, RoundingMode.HALF_UP);
+        BigDecimal perMinutePrice = request.getPerMinutePrice().setScale(2, RoundingMode.HALF_UP);
+        BigDecimal peakMultiplier = request.getPeakMultiplier().setScale(2, RoundingMode.HALF_UP);
         UpdateWrapper<FlashDeliveryPricing> update = new UpdateWrapper<FlashDeliveryPricing>()
                 .eq("id", pricing.getId()).eq("config_version", expectedVersion)
                 .set("start_price", startPrice)
+                .set("minimum_price", minimumPrice)
                 .set("start_distance_meters", request.getStartDistanceMeters())
                 .set("per_km_price", perKmPrice)
+                .set("per_minute_price", perMinutePrice)
+                .set("peak_multiplier", peakMultiplier)
                 .set("enabled", request.getEnabled())
                 .set("updated_by", adminId)
                 .set("update_time", now)
                 .setSql("config_version = config_version + 1");
+        // 版本条件防止两个管理员同时保存时后提交者静默覆盖先提交者。
         if (pricingMapper.update(null, update) != 1) throw fail("flash.delivery.pricing.changed");
         pricing.setStartPrice(startPrice);
+        pricing.setMinimumPrice(minimumPrice);
         pricing.setStartDistanceMeters(request.getStartDistanceMeters());
         pricing.setPerKmPrice(perKmPrice);
+        pricing.setPerMinutePrice(perMinutePrice);
+        pricing.setPeakMultiplier(peakMultiplier);
         pricing.setEnabled(request.getEnabled());
         pricing.setConfigVersion(expectedVersion + 1);
         pricing.setUpdatedBy(adminId);
@@ -262,8 +334,15 @@ public class FlashDeliveryApplicationService {
         return orderMapper.selectPage(page(pageNum, pageSize), query);
     }
 
-    public FlashDeliveryOrderDetailView adminDetail(Long orderId) {
-        return detail(requireOrder(orderId), false);
+    public FlashDeliveryAdminOrderDetailView adminDetail(Long orderId) {
+        FlashDeliveryOrder order = requireOrder(orderId);
+        FlashDeliveryAdminOrderDetailView view = new FlashDeliveryAdminOrderDetailView();
+        view.setOrder(order);
+        view.setImages(imageMapper.selectList(new QueryWrapper<FlashDeliveryOrderImage>()
+                .eq("order_id", orderId).orderByAsc("proof_type", "sort_order")));
+        view.setLogs(logMapper.selectList(new QueryWrapper<FlashDeliveryOrderLog>()
+                .eq("order_id", orderId).orderByAsc("create_time", "id")));
+        return view;
     }
 
     @Transactional
@@ -293,6 +372,12 @@ public class FlashDeliveryApplicationService {
     private QuoteContext calculateQuote(FlashDeliveryQuoteRequest request) {
         if (request == null) throw fail("flash.delivery.request.required");
         validateServiceType(request.getServiceType());
+        if (!hasText(request.getPackageType()) || !PACKAGE_TYPES.contains(request.getPackageType())) {
+            throw fail("flash.delivery.package.type.invalid");
+        }
+        if (!hasText(request.getPackageSize()) || !PACKAGE_SIZES.contains(request.getPackageSize())) {
+            throw fail("flash.delivery.package.size.invalid");
+        }
         validateAddress(request.getPickup());
         validateAddress(request.getDelivery());
         if (sameAddressText(request.getPickup(), request.getDelivery())
@@ -301,10 +386,14 @@ public class FlashDeliveryApplicationService {
             throw fail("flash.delivery.address.same");
         }
         FlashDeliveryPricing pricing = findPricing(request.getServiceType(), true);
+        // 路线服务优先使用 Google Routes;不可用时在服务内部降级为球面直线距离。
         RouteDistance route = routeService.calculate(
                 new GeoPoint(request.getPickup().getLatitude(), request.getPickup().getLongitude()),
                 new GeoPoint(request.getDelivery().getLatitude(), request.getDelivery().getLongitude()));
-        return new QuoteContext(pricing, route, pricingCalculator.calculate(pricing, route.distanceMeters()));
+        if (route.distanceMeters() > MAX_DISTANCE_METERS) throw fail("flash.delivery.distance.too.far");
+        FlashDeliveryPriceBreakdown breakdown = pricingCalculator.calculateBreakdown(pricing,
+                route.distanceMeters(), route.durationSeconds() == null ? 0 : route.durationSeconds());
+        return new QuoteContext(pricing, route, breakdown);
     }
 
     private FlashDeliveryPricing findPricing(String serviceType, boolean enabledOnly) {
@@ -318,32 +407,40 @@ public class FlashDeliveryApplicationService {
 
     private void transitionWithProof(Long riderId, Long orderId, FlashDeliveryProofRequest request,
                                      String expected, String next, String proofType, String timeColumn) {
-        List<String> urls = validateProof(request);
+        List<String> urls = validateProofUrls(request == null ? null : request.getImageUrls(), true);
         requireRider(riderId);
         Date now = new Date();
         if (orderMapper.transitionByRider(orderId, riderId, expected, next, timeColumn, now) != 1) {
             throw fail("flash.delivery.transition.not.allowed");
         }
+        saveImages(orderId, proofType, "RIDER", riderId, urls, now);
+        writeLog(orderId, expected, next, "RIDER", riderId, null, now);
+    }
+
+    /** 状态、图片和日志处于同一事务;任一图片写入失败会回滚本次业务操作。 */
+    private void saveImages(Long orderId, String proofType, String operatorType,
+                            Long operatorId, List<String> urls, Date now) {
         for (int index = 0; index < urls.size(); index++) {
             FlashDeliveryOrderImage image = new FlashDeliveryOrderImage();
             image.setOrderId(orderId);
             image.setProofType(proofType);
             image.setImageUrl(urls.get(index));
             image.setSortOrder(index);
-            image.setOperatorId(riderId);
+            image.setOperatorType(operatorType);
+            image.setOperatorId(operatorId);
             image.setCreateTime(now);
             imageMapper.insert(image);
         }
-        writeLog(orderId, expected, next, "RIDER", riderId, null, now);
     }
 
-    private List<String> validateProof(FlashDeliveryProofRequest request) {
-        if (request == null || request.getImageUrls() == null || request.getImageUrls().isEmpty()) {
+    private List<String> validateProofUrls(List<String> values, boolean required) {
+        if (values == null || values.isEmpty()) {
+            if (!required) return List.of();
             throw fail("flash.delivery.proof.required");
         }
-        if (request.getImageUrls().size() > 9) throw fail("flash.delivery.proof.too.many");
+        if (values.size() > 9) throw fail("flash.delivery.proof.too.many");
         List<String> urls = new ArrayList<>();
-        for (String value : request.getImageUrls()) {
+        for (String value : values) {
             try {
                 String url = value == null ? null : value.trim();
                 URI uri = url == null ? null : URI.create(url);
@@ -359,32 +456,51 @@ public class FlashDeliveryApplicationService {
         return urls;
     }
 
-    private FlashDeliveryOrderDetailView detail(FlashDeliveryOrder order, boolean hideEvidence) {
+    private FlashDeliveryOrderDetailView participantDetail(FlashDeliveryOrder order,
+                                                           boolean userView,
+                                                           boolean hideEvidence) {
         FlashDeliveryOrderDetailView view = new FlashDeliveryOrderDetailView();
-        view.setOrder(order);
+        view.setOrder(orderView(order));
+        if (userView) {
+            view.setDeliveryPinCode(Boolean.TRUE.equals(order.getPinRequired()) ? order.getDeliveryPinCode() : null);
+            view.setRider(riderSummary(order));
+        }
         if (hideEvidence || order.getId() == null) {
             view.setImages(List.of());
             view.setLogs(List.of());
         } else {
             view.setImages(imageMapper.selectList(new QueryWrapper<FlashDeliveryOrderImage>()
-                    .eq("order_id", order.getId()).orderByAsc("proof_type", "sort_order")));
+                            .eq("order_id", order.getId()).orderByAsc("proof_type", "sort_order"))
+                    .stream().map(this::imageView).toList());
             view.setLogs(logMapper.selectList(new QueryWrapper<FlashDeliveryOrderLog>()
-                    .eq("order_id", order.getId()).orderByAsc("create_time", "id")));
+                            .eq("order_id", order.getId()).orderByAsc("create_time", "id"))
+                    .stream().map(this::logView).toList());
         }
         return view;
     }
 
     private FlashDeliveryAvailableOrderView availableView(FlashDeliveryOrder order) {
+        // 明确逐字段组装白名单 DTO,禁止直接复制订单实体造成未来新增敏感字段意外泄露。
         FlashDeliveryAvailableOrderView view = new FlashDeliveryAvailableOrderView();
         view.setId(order.getId());
         view.setOrderNo(order.getOrderNo());
         view.setServiceType(order.getServiceType());
         view.setStatus(order.getStatus());
+        view.setPackageType(order.getPackageType());
+        view.setPackageSize(order.getPackageSize());
+        view.setDeliveryMode(order.getDeliveryMode());
+        view.setScheduledPickupStartAt(order.getScheduledPickupStartAt());
+        view.setScheduledPickupEndAt(order.getScheduledPickupEndAt());
+        view.setPickupCity(order.getPickupCity());
+        view.setPickupArea(order.getPickupArea());
+        view.setDeliveryCity(order.getDeliveryCity());
+        view.setDeliveryArea(order.getDeliveryArea());
         view.setPickupLatitudeApprox(approximate(order.getPickupLatitude()));
         view.setPickupLongitudeApprox(approximate(order.getPickupLongitude()));
         view.setDeliveryLatitudeApprox(approximate(order.getDeliveryLatitude()));
         view.setDeliveryLongitudeApprox(approximate(order.getDeliveryLongitude()));
         view.setDistanceMeters(order.getDistanceMeters());
+        view.setEstimatedDurationSeconds(order.getEstimatedDurationSeconds());
         view.setAmount(order.getAmount());
         view.setCurrency(order.getCurrency());
         view.setCreateTime(order.getCreateTime());
@@ -395,21 +511,119 @@ public class FlashDeliveryApplicationService {
         FlashDeliveryServiceView view = new FlashDeliveryServiceView();
         view.setServiceType(pricing.getServiceType());
         view.setStartPrice(pricing.getStartPrice());
+        view.setMinimumPrice(pricing.getMinimumPrice());
         view.setStartDistanceMeters(pricing.getStartDistanceMeters());
         view.setPerKmPrice(pricing.getPerKmPrice());
+        view.setPerMinutePrice(pricing.getPerMinutePrice());
+        view.setPeakMultiplier(pricing.getPeakMultiplier());
         view.setPricingVersion(pricing.getConfigVersion());
         view.setCurrency("TWD");
         return view;
     }
 
+    private FlashDeliveryOrderView orderView(FlashDeliveryOrder order) {
+        FlashDeliveryOrderView view = new FlashDeliveryOrderView();
+        view.setId(order.getId());
+        view.setOrderNo(order.getOrderNo());
+        view.setServiceType(order.getServiceType());
+        view.setStatus(order.getStatus());
+        view.setPackageType(order.getPackageType());
+        view.setPackageSize(order.getPackageSize());
+        view.setDeliveryMode(order.getDeliveryMode());
+        view.setScheduledPickupStartAt(order.getScheduledPickupStartAt());
+        view.setScheduledPickupEndAt(order.getScheduledPickupEndAt());
+        view.setPinRequired(order.getPinRequired());
+        view.setPickup(addressView(order, true));
+        view.setDelivery(addressView(order, false));
+        view.setDistanceMeters(order.getDistanceMeters());
+        view.setDistanceSource(order.getDistanceSource());
+        view.setEstimatedDurationSeconds(order.getEstimatedDurationSeconds());
+        view.setAmount(order.getAmount());
+        view.setCurrency(order.getCurrency());
+        view.setUserNote(order.getUserNote());
+        view.setAcceptedAt(order.getAcceptedAt());
+        view.setPickedUpAt(order.getPickedUpAt());
+        view.setDeliveredAt(order.getDeliveredAt());
+        view.setCompletedAt(order.getCompletedAt());
+        view.setCancelledAt(order.getCancelledAt());
+        view.setCancelReason(order.getCancelReason());
+        view.setCreateTime(order.getCreateTime());
+        view.setUpdateTime(order.getUpdateTime());
+        return view;
+    }
+
+    private FlashDeliveryAddressView addressView(FlashDeliveryOrder order, boolean pickup) {
+        FlashDeliveryAddressView view = new FlashDeliveryAddressView();
+        if (pickup) {
+            view.setName(order.getPickupName()); view.setPhone(order.getPickupPhone());
+            view.setAddress(order.getPickupAddress()); view.setAddressDetail(order.getPickupAddressDetail());
+            view.setCity(order.getPickupCity()); view.setArea(order.getPickupArea());
+            view.setHandoffMethod(order.getPickupHandoffMethod());
+            view.setLongitude(order.getPickupLongitude()); view.setLatitude(order.getPickupLatitude());
+        } else {
+            view.setName(order.getDeliveryName()); view.setPhone(order.getDeliveryPhone());
+            view.setAddress(order.getDeliveryAddress()); view.setAddressDetail(order.getDeliveryAddressDetail());
+            view.setCity(order.getDeliveryCity()); view.setArea(order.getDeliveryArea());
+            view.setHandoffMethod(order.getDeliveryHandoffMethod());
+            view.setLongitude(order.getDeliveryLongitude()); view.setLatitude(order.getDeliveryLatitude());
+        }
+        return view;
+    }
+
+    private FlashDeliveryImageView imageView(FlashDeliveryOrderImage image) {
+        FlashDeliveryImageView view = new FlashDeliveryImageView();
+        view.setProofType(image.getProofType()); view.setImageUrl(image.getImageUrl());
+        view.setSortOrder(image.getSortOrder()); view.setCreateTime(image.getCreateTime());
+        return view;
+    }
+
+    private FlashDeliveryLogView logView(FlashDeliveryOrderLog log) {
+        FlashDeliveryLogView view = new FlashDeliveryLogView();
+        view.setFromStatus(log.getFromStatus()); view.setToStatus(log.getToStatus());
+        view.setOperatorType(log.getOperatorType()); view.setReason(log.getReason());
+        view.setCreateTime(log.getCreateTime());
+        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.setStartPrice(breakdown.getStartPrice());
+        view.setMinimumPrice(breakdown.getMinimumPrice());
+        view.setDistanceFee(breakdown.getDistanceFee());
+        view.setDurationFee(breakdown.getDurationFee());
+        view.setSubtotal(breakdown.getSubtotal());
+        view.setPeakMultiplier(breakdown.getPeakMultiplier());
+        view.setPeakFee(breakdown.getPeakFee());
+        view.setAmount(breakdown.getAmount());
+    }
+
     private void copyAddress(FlashDeliveryAddressRequest source, FlashDeliveryOrder target, boolean pickup) {
         if (pickup) {
             target.setPickupName(source.getName().trim()); target.setPickupPhone(source.getPhone().trim());
             target.setPickupAddress(source.getAddress().trim()); target.setPickupAddressDetail(trimToNull(source.getAddressDetail()));
+            target.setPickupCity(trimToNull(source.getCity())); target.setPickupArea(trimToNull(source.getArea()));
+            target.setPickupHandoffMethod(trimToNull(source.getHandoffMethod()));
             target.setPickupLongitude(source.getLongitude()); target.setPickupLatitude(source.getLatitude());
         } else {
             target.setDeliveryName(source.getName().trim()); target.setDeliveryPhone(source.getPhone().trim());
             target.setDeliveryAddress(source.getAddress().trim()); target.setDeliveryAddressDetail(trimToNull(source.getAddressDetail()));
+            target.setDeliveryCity(trimToNull(source.getCity())); target.setDeliveryArea(trimToNull(source.getArea()));
+            target.setDeliveryHandoffMethod(trimToNull(source.getHandoffMethod()));
             target.setDeliveryLongitude(source.getLongitude()); target.setDeliveryLatitude(source.getLatitude());
         }
     }
@@ -420,6 +634,8 @@ public class FlashDeliveryApplicationService {
                 || address.getName().trim().length() > 64 || address.getPhone().trim().length() > 32
                 || address.getAddress().trim().length() > 255
                 || (address.getAddressDetail() != null && address.getAddressDetail().trim().length() > 255)
+                || tooLong(address.getCity(), 64) || tooLong(address.getArea(), 64)
+                || tooLong(address.getHandoffMethod(), 40)
                 || address.getLatitude().compareTo(BigDecimal.valueOf(-90)) < 0
                 || address.getLatitude().compareTo(BigDecimal.valueOf(90)) > 0
                 || address.getLongitude().compareTo(BigDecimal.valueOf(-180)) < 0
@@ -470,12 +686,67 @@ public class FlashDeliveryApplicationService {
         return new Page<>(Math.max(1, pageNum), Math.min(Math.max(1, pageSize), 100));
     }
 
+    private <T> IPage<T> mapPage(IPage<FlashDeliveryOrder> source,
+                                 Function<FlashDeliveryOrder, T> mapper) {
+        Page<T> result = new Page<>(source.getCurrent(), source.getSize(), source.getTotal());
+        result.setRecords(source.getRecords().stream().map(mapper).toList());
+        return result;
+    }
+
+    private void applyUserScene(QueryWrapper<FlashDeliveryOrder> query, String scene) {
+        if (!hasText(scene) || "all".equals(scene)) return;
+        switch (scene.trim()) {
+            case "matching" -> query.eq("status", WAITING_ACCEPTANCE);
+            case "progress" -> query.in("status", ACCEPTED, PICKED_UP, DELIVERED);
+            case "done" -> query.eq("status", COMPLETED);
+            case "cancelled" -> query.eq("status", CANCELLED);
+            default -> throw fail("flash.delivery.scene.invalid");
+        }
+    }
+
+    private void applyRiderScene(QueryWrapper<FlashDeliveryOrder> query, String scene) {
+        if (!hasText(scene) || "all".equals(scene)) return;
+        switch (scene.trim()) {
+            case "pickup" -> query.eq("status", ACCEPTED);
+            case "delivering" -> query.eq("status", PICKED_UP);
+            case "completed" -> query.in("status", DELIVERED, COMPLETED);
+            case "cancelled" -> query.eq("status", CANCELLED);
+            default -> throw fail("flash.delivery.scene.invalid");
+        }
+    }
+
+    private String normalizeDeliveryMode(String value) {
+        String mode = hasText(value) ? value.trim() : "NOW";
+        if (!DELIVERY_MODES.contains(mode)) throw fail("flash.delivery.mode.invalid");
+        return mode;
+    }
+
+    private void validateSchedule(String mode, Date start, Date end, Date now) {
+        if ("NOW".equals(mode)) {
+            if (start != null || end != null) throw fail("flash.delivery.schedule.invalid");
+            return;
+        }
+        if (start == null || end == null || start.before(now)
+                || end.getTime() - start.getTime() != SCHEDULE_SLOT_MILLIS
+                || start.getTime() - now.getTime() > MAX_SCHEDULE_DELAY_MILLIS) {
+            throw fail("flash.delivery.schedule.invalid");
+        }
+    }
+
+    private boolean isAvailableAt(FlashDeliveryOrder order, Date now) {
+        return "NOW".equals(order.getDeliveryMode())
+                || "SCHEDULED".equals(order.getDeliveryMode())
+                && order.getScheduledPickupStartAt() != null
+                && !order.getScheduledPickupStartAt().after(now);
+    }
+
     private void optionalEq(QueryWrapper<FlashDeliveryOrder> query, String column, String value) {
         if (hasText(value)) query.eq(column, value.trim());
     }
 
     private boolean hasText(String value) { return StringUtils.isNotEmpty(value) && !value.trim().isEmpty(); }
     private String trimToNull(String value) { return hasText(value) ? value.trim() : null; }
+    private boolean tooLong(String value, int max) { return value != null && value.trim().length() > max; }
     private BigDecimal approximate(BigDecimal value) { return value == null ? null : value.setScale(2, RoundingMode.HALF_UP); }
     private boolean sameAddressText(FlashDeliveryAddressRequest left, FlashDeliveryAddressRequest right) {
         return normalized(left.getAddress()).equals(normalized(right.getAddress()))
@@ -484,5 +755,6 @@ public class FlashDeliveryApplicationService {
     private String normalized(String value) { return value == null ? "" : value.trim().toLowerCase(Locale.ROOT); }
     private ServiceException fail(String key) { return new ServiceException(MessageUtils.message(key)); }
 
-    private record QuoteContext(FlashDeliveryPricing pricing, RouteDistance route, BigDecimal amount) { }
+    private record QuoteContext(FlashDeliveryPricing pricing, RouteDistance route,
+                                FlashDeliveryPriceBreakdown breakdown) { }
 }

+ 41 - 6
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryPricingCalculator.java

@@ -1,23 +1,58 @@
 package com.ruoyi.app.flashdelivery.service;
 
 import com.ruoyi.system.domain.flash.FlashDeliveryPricing;
+import com.ruoyi.app.flashdelivery.dto.FlashDeliveryPriceBreakdown;
 
 import java.math.BigDecimal;
 import java.math.RoundingMode;
 import org.springframework.stereotype.Component;
 
+/** 按计价配置计算闪送金额,所有金额统一保留两位小数。 */
 @Component
 public class FlashDeliveryPricingCalculator {
-    public BigDecimal calculate(FlashDeliveryPricing pricing, int distanceMeters) {
+    public BigDecimal calculate(FlashDeliveryPricing pricing, int distanceMeters, int durationSeconds) {
+        return calculateBreakdown(pricing, distanceMeters, durationSeconds).getAmount();
+    }
+
+    /** 按实际超距公里和路线时长生成费用明细,并应用最低价及尖峰倍率。 */
+    public FlashDeliveryPriceBreakdown calculateBreakdown(FlashDeliveryPricing pricing,
+                                                           int distanceMeters,
+                                                           int durationSeconds) {
         if (pricing == null || !Boolean.TRUE.equals(pricing.getEnabled()) || distanceMeters <= 0
                 || pricing.getStartPrice() == null || pricing.getStartPrice().signum() <= 0
+                || pricing.getMinimumPrice() == null || pricing.getMinimumPrice().signum() <= 0
                 || pricing.getStartDistanceMeters() == null || pricing.getStartDistanceMeters() <= 0
-                || pricing.getPerKmPrice() == null || pricing.getPerKmPrice().signum() <= 0) {
+                || pricing.getPerKmPrice() == null || pricing.getPerKmPrice().signum() <= 0
+                || pricing.getPerMinutePrice() == null || pricing.getPerMinutePrice().signum() < 0
+                || pricing.getPeakMultiplier() == null
+                || pricing.getPeakMultiplier().compareTo(BigDecimal.ONE) < 0
+                || durationSeconds < 0) {
             throw new IllegalArgumentException("invalid pricing or distance");
         }
-        long extraKilometres = Math.max(0L,
-                (distanceMeters - (long) pricing.getStartDistanceMeters() + 999L) / 1000L);
-        return pricing.getStartPrice().add(pricing.getPerKmPrice()
-                .multiply(BigDecimal.valueOf(extraKilometres))).setScale(2, RoundingMode.HALF_UP);
+        BigDecimal extraKilometres = BigDecimal.valueOf(Math.max(0L,
+                        distanceMeters - (long) pricing.getStartDistanceMeters()))
+                .divide(BigDecimal.valueOf(1000), 6, RoundingMode.HALF_UP);
+        BigDecimal durationMinutes = BigDecimal.valueOf(durationSeconds)
+                .divide(BigDecimal.valueOf(60), 6, RoundingMode.HALF_UP);
+        BigDecimal distanceFee = money(pricing.getPerKmPrice().multiply(extraKilometres));
+        BigDecimal durationFee = money(pricing.getPerMinutePrice().multiply(durationMinutes));
+        BigDecimal subtotal = money(pricing.getStartPrice().add(distanceFee).add(durationFee));
+        BigDecimal peakFee = money(subtotal.multiply(pricing.getPeakMultiplier().subtract(BigDecimal.ONE)));
+        BigDecimal amount = money(subtotal.add(peakFee).max(pricing.getMinimumPrice()));
+
+        FlashDeliveryPriceBreakdown result = new FlashDeliveryPriceBreakdown();
+        result.setStartPrice(money(pricing.getStartPrice()));
+        result.setMinimumPrice(money(pricing.getMinimumPrice()));
+        result.setDistanceFee(distanceFee);
+        result.setDurationFee(durationFee);
+        result.setSubtotal(subtotal);
+        result.setPeakMultiplier(pricing.getPeakMultiplier().setScale(2, RoundingMode.HALF_UP));
+        result.setPeakFee(peakFee);
+        result.setAmount(amount);
+        return result;
+    }
+
+    private BigDecimal money(BigDecimal value) {
+        return value.setScale(2, RoundingMode.HALF_UP);
     }
 }

+ 6 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryStateMachine.java

@@ -2,6 +2,10 @@ package com.ruoyi.app.flashdelivery.service;
 
 import static com.ruoyi.system.domain.flash.FlashDeliveryStatus.*;
 
+/**
+ * 闪送履约状态机。
+ * 正常路径固定为待接单、已接单、已取件、已送达、已完成;已完成和已取消均为终态。
+ */
 public final class FlashDeliveryStateMachine {
     private FlashDeliveryStateMachine() { }
 
@@ -14,10 +18,12 @@ public final class FlashDeliveryStateMachine {
     }
 
     public static boolean canUserCancel(String status) {
+        // 用户只能在实际取件前取消;取件后的异常由平台介入处理。
         return WAITING_ACCEPTANCE.equals(status) || ACCEPTED.equals(status);
     }
 
     public static boolean canAdminCancel(String status) {
+        // 平台可取消任何非终态订单,用于异常履约人工收口。
         return !COMPLETED.equals(status) && !CANCELLED.equals(status);
     }
 }

+ 6 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/service/InfoAddressBookService.java

@@ -15,6 +15,10 @@ import java.util.Date;
 import java.util.List;
 import java.util.Objects;
 
+/**
+ * 用户共享地址簿服务。
+ * 每个读取和写入条件都绑定 token 对应的 userId,避免通过地址 ID 越权访问他人资料。
+ */
 @Service
 public class InfoAddressBookService {
     private final IInfoAddressService addressService;
@@ -33,6 +37,7 @@ public class InfoAddressBookService {
     }
 
     public InfoAddress detail(Long userId, Long id) {
+        // 先查实体再核对所有权;对不存在和越权统一返回无访问权限,避免枚举地址 ID。
         InfoAddress address = id == null ? null : addressService.getById(id);
         if (address == null || !Objects.equals(userId, address.getUserId())) throw fail("address.access.denied");
         return address;
@@ -51,6 +56,7 @@ public class InfoAddressBookService {
         } else {
             address = detail(userId, request.getId());
             copy(request, address);
+            // 使用显式 set 更新,使 null 能真正清空门牌、区域等可选字段。
             UpdateWrapper<InfoAddress> update = new UpdateWrapper<InfoAddress>()
                     .eq("id", request.getId()).eq("user_id", userId)
                     .set("name", address.getName()).set("phone", address.getPhone())

+ 6 - 3
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/task/FlashDeliveryAutoCompleteTask.java

@@ -2,7 +2,6 @@ package com.ruoyi.app.flashdelivery.task;
 
 import com.ruoyi.app.flashdelivery.service.FlashDeliveryApplicationService;
 import com.ruoyi.system.domain.flash.FlashDeliveryOrder;
-import org.springframework.scheduling.annotation.Scheduled;
 import org.springframework.stereotype.Component;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
@@ -11,15 +10,19 @@ import java.time.Instant;
 import java.time.temporal.ChronoUnit;
 import java.util.Date;
 
-@Component
+/**
+ * 将送达超过 24 小时且用户未确认的订单自动收口为已完成。
+ * 由若依任务调度调用 {@code flashDeliveryAutoCompleteTask.completeDeliveredOrders()}。
+ */
+@Component("flashDeliveryAutoCompleteTask")
 public class FlashDeliveryAutoCompleteTask {
     private static final Logger log = LoggerFactory.getLogger(FlashDeliveryAutoCompleteTask.class);
     private final FlashDeliveryApplicationService service;
     public FlashDeliveryAutoCompleteTask(FlashDeliveryApplicationService service) { this.service = service; }
 
-    @Scheduled(fixedDelayString = "${flash-delivery.auto-complete.fixed-delay-ms:300000}")
     public void completeDeliveredOrders() {
         Date deadline = Date.from(Instant.now().minus(24, ChronoUnit.HOURS));
+        // 分批处理避免单次扫描过多;条件更新保证与用户确认/平台完成并发时只有一方成功。
         for (FlashDeliveryOrder order : service.autoCompleteCandidates(deadline, 200)) {
             try {
                 service.complete(null, "SYSTEM", order.getId());

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

@@ -25,6 +25,10 @@ import org.springframework.web.bind.annotation.*;
 import java.math.BigDecimal;
 import java.util.List;
 
+/**
+ * 地址簿接口。
+ * App 端接口使用 token 绑定地址所有人;平台管理接口继续使用 Spring Security 权限。
+ */
 @RestController
 @RequestMapping("/system/address")
 public class InfoAddressController extends BaseController {
@@ -32,6 +36,7 @@ public class InfoAddressController extends BaseController {
     @Autowired private InfoAddressMapper infoAddressMapper;
     @Autowired private InfoAddressBookService addressBookService;
 
+    /** 返回历史浏览器地图密钥;服务端 Routes 使用另一套不公开的密钥。 */
     @Anonymous
     @GetMapping("/getGoogleMapKey")
     public AjaxResult getGoogleMapKey() {
@@ -40,12 +45,14 @@ public class InfoAddressController extends BaseController {
                 : error(MessageUtils.message("no.map.exception"));
     }
 
+    /** 查询当前用户拥有的地址详情。 */
     @Anonymous @Auth
     @GetMapping("/getaddressxq")
     public AjaxResult getaddressxq(@RequestHeader String token, @RequestParam Long id) {
         return success(MessageUtils.message("no.obtained.success"), addressBookService.detail(userId(token), id));
     }
 
+    /** 查询当前用户地址簿中距离给定坐标最近的地址。 */
     @Anonymous @Auth
     @GetMapping("/getzuijinaddress")
     public AjaxResult getzuijinaddress(@RequestHeader String token, @RequestParam BigDecimal longitude,
@@ -54,6 +61,7 @@ public class InfoAddressController extends BaseController {
                 infoAddressMapper.getmyaddress(longitude, latitude, userId(token).intValue()));
     }
 
+    /** 搜索当前用户地址簿,置顶地址优先。 */
     @Anonymous @Auth
     @GetMapping("/getaddress")
     public AjaxResult getaddress(@RequestHeader String token,
@@ -61,12 +69,14 @@ public class InfoAddressController extends BaseController {
         return success(MessageUtils.message("no.obtained.success"), addressBookService.list(userId(token), keyword));
     }
 
+    /** 新增或修改当前用户地址,忽略客户端可能伪造的用户身份。 */
     @Anonymous @Auth
     @PostMapping("/address")
     public AjaxResult address(@RequestHeader String token, @RequestBody InfoAddressRequest request) {
         return success(addressBookService.save(userId(token), request));
     }
 
+    /** 删除当前用户拥有的地址。 */
     @Anonymous @Auth
     @DeleteMapping(value = "/{id}", headers = "token")
     public AjaxResult deleteMine(@RequestHeader String token, @PathVariable Long id) {
@@ -74,6 +84,7 @@ public class InfoAddressController extends BaseController {
         return success();
     }
 
+    /** 将当前用户拥有的地址置顶。 */
     @Anonymous @Auth
     @PostMapping("/{id}/top")
     public AjaxResult top(@RequestHeader String token, @PathVariable Long id) {

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

@@ -305,6 +305,13 @@ flash.delivery.cancel.not.allowed=The current order status cannot be cancelled t
 flash.delivery.complete.not.allowed=The order is not ready to complete
 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.package.type.invalid=Please select a valid package type
+flash.delivery.package.size.invalid=Please select a valid package size tier
+flash.delivery.distance.too.far=Delivery distance cannot exceed 40 kilometres
+flash.delivery.mode.invalid=Invalid delivery mode
+flash.delivery.schedule.invalid=Invalid scheduled pickup slot
+flash.delivery.pin.invalid=Invalid delivery PIN
+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
 address.access.denied=Address does not exist or is not accessible

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

@@ -308,6 +308,13 @@ flash.delivery.cancel.not.allowed=The current order status cannot be cancelled t
 flash.delivery.complete.not.allowed=The order is not ready to complete
 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.package.type.invalid=Please select a valid package type
+flash.delivery.package.size.invalid=Please select a valid package size tier
+flash.delivery.distance.too.far=Delivery distance cannot exceed 40 kilometres
+flash.delivery.mode.invalid=Invalid delivery mode
+flash.delivery.schedule.invalid=Invalid scheduled pickup slot
+flash.delivery.pin.invalid=Invalid delivery PIN
+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
 address.access.denied=Address does not exist or is not accessible

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

@@ -308,6 +308,13 @@ flash.delivery.cancel.not.allowed=Trạng thái hiện tại không cho phép h
 flash.delivery.complete.not.allowed=Đơn hàng chưa thể hoàn tất
 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.package.type.invalid=Vui lòng chọn loại kiện hàng hợp lệ
+flash.delivery.package.size.invalid=Vui lòng chọn mức trọng lượng kiện hàng hợp lệ
+flash.delivery.distance.too.far=Khoảng cách giao hàng không được vượt quá 40 km
+flash.delivery.mode.invalid=Phương thức giao hàng không hợp lệ
+flash.delivery.schedule.invalid=Khung giờ lấy hàng đã đặt không hợp lệ
+flash.delivery.pin.invalid=Mã PIN giao hàng không chính xác
+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
 address.access.denied=Địa chỉ không tồn tại hoặc không có quyền truy cập

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

@@ -309,6 +309,13 @@ flash.delivery.cancel.not.allowed=当前订单状态不允许这样取消
 flash.delivery.complete.not.allowed=当前订单尚不能完成
 flash.delivery.state.changed=订单状态已变化,请刷新后重试
 flash.delivery.pricing.invalid=计价配置必须为有效正数
+flash.delivery.package.type.invalid=请选择有效的包裹类型
+flash.delivery.package.size.invalid=请选择有效的包裹重量档
+flash.delivery.distance.too.far=配送距离不能超过40公里
+flash.delivery.mode.invalid=配送方式无效
+flash.delivery.schedule.invalid=预约取件时段无效
+flash.delivery.pin.invalid=交付PIN不正确
+flash.delivery.scene.invalid=订单状态分组参数无效
 flash.delivery.pricing.not.found=计价配置不存在
 flash.delivery.pricing.changed=计价配置已发生变化,请刷新后重试
 address.access.denied=地址不存在或无权访问

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

@@ -309,6 +309,13 @@ flash.delivery.cancel.not.allowed=目前訂單狀態不允許這樣取消
 flash.delivery.complete.not.allowed=目前訂單尚不能完成
 flash.delivery.state.changed=訂單狀態已變更,請重新整理後再試
 flash.delivery.pricing.invalid=計價設定必須為有效正數
+flash.delivery.package.type.invalid=請選擇有效的包裹類型
+flash.delivery.package.size.invalid=請選擇有效的包裹重量級距
+flash.delivery.distance.too.far=配送距離不能超過40公里
+flash.delivery.mode.invalid=配送方式無效
+flash.delivery.schedule.invalid=預約取件時段無效
+flash.delivery.pin.invalid=交付PIN不正確
+flash.delivery.scene.invalid=訂單狀態分組參數無效
 flash.delivery.pricing.not.found=計價設定不存在
 flash.delivery.pricing.changed=計價設定已發生變更,請重新整理後重試
 address.access.denied=地址不存在或無權存取

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

@@ -2,6 +2,7 @@ package com.ruoyi.app.flashdelivery.controller;
 
 import com.ruoyi.app.flashdelivery.dto.FlashDeliveryCreateRequest;
 import com.ruoyi.app.flashdelivery.dto.FlashDeliveryProofRequest;
+import com.ruoyi.app.flashdelivery.dto.FlashDeliveryDeliverRequest;
 import org.junit.jupiter.api.Test;
 import org.springframework.security.access.prepost.PreAuthorize;
 import org.springframework.web.bind.annotation.RequestBody;
@@ -27,6 +28,10 @@ class FlashDeliveryControllerContractTest {
                 Long.class, FlashDeliveryProofRequest.class);
         assertNotNull(pickup.getParameters()[0].getAnnotation(RequestHeader.class));
         assertNotNull(pickup.getParameters()[2].getAnnotation(RequestBody.class));
+
+        Method deliver = FlashDeliveryRiderController.class.getMethod("deliver", String.class,
+                Long.class, FlashDeliveryDeliverRequest.class);
+        assertNotNull(deliver.getParameters()[2].getAnnotation(RequestBody.class));
     }
 
     @Test

+ 13 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryAvailableOrderViewTest.java

@@ -7,6 +7,7 @@ import java.util.stream.Collectors;
 import java.util.stream.Stream;
 
 import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
 
 class FlashDeliveryAvailableOrderViewTest {
     @Test
@@ -20,4 +21,16 @@ class FlashDeliveryAvailableOrderViewTest {
             assertFalse(fields.contains(forbidden), forbidden);
         }
     }
+
+    @Test
+    void publicRiderViewContainsOnlySafeFieldsNeededBySupplementaryPrototype() {
+        Set<String> fields = Stream.of(FlashDeliveryAvailableOrderView.class.getDeclaredFields())
+                .map(java.lang.reflect.Field::getName).collect(Collectors.toSet());
+        for (String required : Set.of("packageType", "packageSize", "deliveryMode",
+                "scheduledPickupStartAt", "scheduledPickupEndAt", "pickupCity", "pickupArea",
+                "deliveryCity", "deliveryArea", "estimatedDurationSeconds")) {
+            assertTrue(fields.contains(required), required);
+        }
+        assertFalse(fields.contains("deliveryPinCode"));
+    }
 }

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

@@ -0,0 +1,26 @@
+package com.ruoyi.app.flashdelivery.dto;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.Set;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class FlashDeliveryOrderViewTest {
+    @Test
+    void roleBusinessViewExcludesPersistenceAndIdentityInternals() {
+        Set<String> fields = Stream.of(FlashDeliveryOrderView.class.getDeclaredFields())
+                .map(java.lang.reflect.Field::getName).collect(Collectors.toSet());
+        for (String forbidden : Set.of("userId", "clientRequestId", "deliveryPinCode",
+                "pricingVersion", "cancelOperatorId", "cancelOperatorType", "version")) {
+            assertFalse(fields.contains(forbidden), forbidden);
+        }
+        for (String required : Set.of("packageType", "packageSize", "deliveryMode",
+                "scheduledPickupStartAt", "pinRequired", "pickup", "delivery")) {
+            assertTrue(fields.contains(required), required);
+        }
+    }
+}

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

@@ -1,10 +1,13 @@
 package com.ruoyi.app.flashdelivery.service;
 
 import com.baomidou.mybatisplus.core.conditions.Wrapper;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.ruoyi.app.flashdelivery.dto.FlashDeliveryAddressRequest;
+import com.ruoyi.app.flashdelivery.dto.FlashDeliveryCreateRequest;
 import com.ruoyi.app.flashdelivery.dto.FlashDeliveryProofRequest;
 import com.ruoyi.app.flashdelivery.dto.FlashDeliveryPricingUpdateRequest;
 import com.ruoyi.app.flashdelivery.dto.FlashDeliveryQuoteRequest;
+import com.ruoyi.app.flashdelivery.dto.FlashDeliveryDeliverRequest;
 import com.ruoyi.app.flashdelivery.route.FlashDeliveryRouteService;
 import com.ruoyi.app.flashdelivery.route.RouteDistance;
 import com.ruoyi.system.domain.InfoUser;
@@ -18,6 +21,7 @@ import com.ruoyi.system.mapper.flash.FlashDeliveryPricingMapper;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.BeforeAll;
 import org.junit.jupiter.api.AfterAll;
+import org.mockito.ArgumentCaptor;
 import org.springframework.beans.factory.config.ConfigurableListableBeanFactory;
 import org.springframework.beans.factory.support.DefaultListableBeanFactory;
 import org.springframework.context.support.StaticMessageSource;
@@ -26,10 +30,12 @@ import org.springframework.test.util.ReflectionTestUtils;
 
 import java.math.BigDecimal;
 import java.util.List;
+import java.util.Date;
 
 import static com.ruoyi.system.domain.flash.FlashDeliveryStatus.ACCEPTED;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 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.eq;
@@ -38,6 +44,7 @@ import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.never;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
+import static org.mockito.Mockito.doAnswer;
 
 class FlashDeliveryApplicationServiceTest {
     private static ConfigurableListableBeanFactory originalBeanFactory;
@@ -67,10 +74,118 @@ class FlashDeliveryApplicationServiceTest {
         var quote = fixture.service.quote(request());
 
         assertEquals(3001, quote.getDistanceMeters());
-        assertEquals(new BigDecimal("12.90"), quote.getAmount());
+        assertEquals(new BigDecimal("108.02"), quote.getAmount());
         assertEquals("ROUTE", quote.getDistanceSource());
     }
 
+    @Test
+    void quoteRejectsRoutesLongerThanFortyKilometres() {
+        Fixture fixture = new Fixture();
+        when(fixture.pricingMapper.selectOne(any(Wrapper.class))).thenReturn(pricing());
+        when(fixture.routeService.calculate(any(), any())).thenReturn(new RouteDistance(40001, 1800, "ROUTE"));
+
+        assertThrows(ServiceException.class, () -> fixture.service.quote(request()));
+    }
+
+    @Test
+    void wrongDeliveryPinCannotPersistProofOrChangeState() {
+        Fixture fixture = new Fixture();
+        InfoUser rider = new InfoUser();
+        rider.setUserId(8L);
+        rider.setUserType("2");
+        when(fixture.userMapper.selectInfoUserByUserId(8L)).thenReturn(rider);
+        FlashDeliveryOrder order = new FlashDeliveryOrder();
+        order.setId(10L);
+        order.setRiderId(8L);
+        order.setStatus("PICKED_UP");
+        order.setPinRequired(true);
+        order.setDeliveryPinCode("4821");
+        when(fixture.orderMapper.selectById(10L)).thenReturn(order);
+        FlashDeliveryDeliverRequest request = new FlashDeliveryDeliverRequest();
+        request.setPinCode("1234");
+        request.setImageUrls(List.of("https://example.com/delivery.jpg"));
+
+        assertThrows(ServiceException.class, () -> fixture.service.deliver(8L, 10L, request));
+        verify(fixture.imageMapper, never()).insert(any(com.ruoyi.system.domain.flash.FlashDeliveryOrderImage.class));
+        verify(fixture.orderMapper, never()).transitionByRider(any(), any(), any(), any(), any(), any());
+    }
+
+    @Test
+    void correctDeliveryPinPersistsDeliveryProofAndChangesState() {
+        Fixture fixture = new Fixture();
+        InfoUser rider = new InfoUser();
+        rider.setUserId(8L);
+        rider.setUserType("2");
+        when(fixture.userMapper.selectInfoUserByUserId(8L)).thenReturn(rider);
+        FlashDeliveryOrder order = new FlashDeliveryOrder();
+        order.setId(10L);
+        order.setRiderId(8L);
+        order.setStatus("PICKED_UP");
+        order.setPinRequired(true);
+        order.setDeliveryPinCode("4821");
+        when(fixture.orderMapper.selectById(10L)).thenReturn(order);
+        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);
+
+        ArgumentCaptor<com.ruoyi.system.domain.flash.FlashDeliveryOrderImage> image =
+                ArgumentCaptor.forClass(com.ruoyi.system.domain.flash.FlashDeliveryOrderImage.class);
+        verify(fixture.imageMapper).insert(image.capture());
+        assertEquals("DELIVERY", image.getValue().getProofType());
+        assertEquals("RIDER", image.getValue().getOperatorType());
+    }
+
+    @Test
+    void scheduledCreateGeneratesUserOnlyPinAndStoresSenderProof() {
+        Fixture fixture = new Fixture();
+        when(fixture.pricingMapper.selectOne(any(Wrapper.class))).thenReturn(pricing());
+        when(fixture.routeService.calculate(any(), any())).thenReturn(new RouteDistance(3200, 600, "ROUTE"));
+        doAnswer(invocation -> {
+            FlashDeliveryOrder order = invocation.getArgument(0);
+            order.setId(99L);
+            return 1;
+        }).when(fixture.orderMapper).insert(any(FlashDeliveryOrder.class));
+        when(fixture.imageMapper.selectList(any(Wrapper.class))).thenReturn(List.of());
+        when(fixture.logMapper.selectList(any(Wrapper.class))).thenReturn(List.of());
+        FlashDeliveryCreateRequest request = createRequest();
+
+        var result = fixture.service.create(7L, request);
+
+        assertEquals(4, result.getDeliveryPinCode().length());
+        ArgumentCaptor<com.ruoyi.system.domain.flash.FlashDeliveryOrderImage> image =
+                ArgumentCaptor.forClass(com.ruoyi.system.domain.flash.FlashDeliveryOrderImage.class);
+        verify(fixture.imageMapper).insert(image.capture());
+        assertEquals("SENDER", image.getValue().getProofType());
+        assertEquals("USER", image.getValue().getOperatorType());
+        ArgumentCaptor<FlashDeliveryOrder> saved = ArgumentCaptor.forClass(FlashDeliveryOrder.class);
+        verify(fixture.orderMapper).insert(saved.capture());
+        assertEquals("SCHEDULED", saved.getValue().getDeliveryMode());
+        assertEquals("DOCUMENT", saved.getValue().getPackageType());
+    }
+
+    @Test
+    @SuppressWarnings({"rawtypes", "unchecked"})
+    void userProgressSceneFiltersAllThreeInProgressStatusesBeforePaging() {
+        Fixture fixture = new Fixture();
+        when(fixture.orderMapper.selectPage(any(com.baomidou.mybatisplus.core.metadata.IPage.class),
+                any(Wrapper.class))).thenReturn(new Page<FlashDeliveryOrder>());
+
+        fixture.service.userOrders(7L, 1, 10, null, "progress", null);
+
+        ArgumentCaptor<com.baomidou.mybatisplus.core.conditions.query.QueryWrapper> query =
+                ArgumentCaptor.forClass(com.baomidou.mybatisplus.core.conditions.query.QueryWrapper.class);
+        verify(fixture.orderMapper).selectPage(any(com.baomidou.mybatisplus.core.metadata.IPage.class), query.capture());
+        query.getValue().getSqlSegment();
+        var parameters = query.getValue().getParamNameValuePairs().values();
+        assertTrue(parameters.contains("ACCEPTED"));
+        assertTrue(parameters.contains("PICKED_UP"));
+        assertTrue(parameters.contains("DELIVERED"));
+    }
+
     @Test
     void pickupRejectsMissingProofBeforeChangingState() {
         Fixture fixture = new Fixture();
@@ -139,8 +254,11 @@ class FlashDeliveryApplicationServiceTest {
         when(fixture.pricingMapper.update(isNull(), any(Wrapper.class))).thenReturn(0);
         FlashDeliveryPricingUpdateRequest request = new FlashDeliveryPricingUpdateRequest();
         request.setStartPrice(new BigDecimal("10.00"));
+        request.setMinimumPrice(new BigDecimal("10.00"));
         request.setStartDistanceMeters(3000);
         request.setPerKmPrice(new BigDecimal("3.00"));
+        request.setPerMinutePrice(BigDecimal.ZERO);
+        request.setPeakMultiplier(BigDecimal.ONE);
         request.setEnabled(true);
 
         assertThrows(ServiceException.class,
@@ -151,12 +269,32 @@ class FlashDeliveryApplicationServiceTest {
     private FlashDeliveryQuoteRequest request() {
         FlashDeliveryQuoteRequest request = new FlashDeliveryQuoteRequest();
         request.setServiceType("HELP_SEND");
+        request.setPackageType("DOCUMENT");
+        request.setPackageSize("SMALL");
         request.setPickup(address("A", "0911111111", "25.0330", "121.5645"));
         request.setDelivery(address("B", "0922222222", "25.0478", "121.5319"));
         request.getDelivery().setAddress("second road");
         return request;
     }
 
+    private FlashDeliveryCreateRequest createRequest() {
+        FlashDeliveryCreateRequest request = new FlashDeliveryCreateRequest();
+        FlashDeliveryQuoteRequest quote = request();
+        request.setServiceType(quote.getServiceType());
+        request.setPackageType(quote.getPackageType());
+        request.setPackageSize(quote.getPackageSize());
+        request.setPickup(quote.getPickup());
+        request.setDelivery(quote.getDelivery());
+        request.setClientRequestId("flash-scheduled-001");
+        request.setDeliveryMode("SCHEDULED");
+        Date start = new Date(System.currentTimeMillis() + 60L * 60L * 1000L);
+        request.setScheduledPickupStartAt(start);
+        request.setScheduledPickupEndAt(new Date(start.getTime() + 30L * 60L * 1000L));
+        request.setPinRequired(true);
+        request.setSenderImageUrls(List.of("https://example.com/sender.jpg"));
+        return request;
+    }
+
     private FlashDeliveryAddressRequest address(String name, String phone, String lat, String lon) {
         FlashDeliveryAddressRequest address = new FlashDeliveryAddressRequest();
         address.setName(name);
@@ -172,9 +310,12 @@ class FlashDeliveryApplicationServiceTest {
         FlashDeliveryPricing pricing = new FlashDeliveryPricing();
         pricing.setId(1L);
         pricing.setServiceType("HELP_SEND");
-        pricing.setStartPrice(new BigDecimal("9.90"));
-        pricing.setStartDistanceMeters(3000);
-        pricing.setPerKmPrice(new BigDecimal("3.00"));
+        pricing.setStartPrice(new BigDecimal("70.00"));
+        pricing.setMinimumPrice(new BigDecimal("80.00"));
+        pricing.setStartDistanceMeters(1000);
+        pricing.setPerKmPrice(new BigDecimal("19.00"));
+        pricing.setPerMinutePrice(BigDecimal.ZERO);
+        pricing.setPeakMultiplier(BigDecimal.ONE);
         pricing.setEnabled(true);
         pricing.setConfigVersion(1);
         return pricing;

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

@@ -13,30 +13,44 @@ class FlashDeliveryPricingCalculatorTest {
     private final FlashDeliveryPricingCalculator calculator = new FlashDeliveryPricingCalculator();
 
     @Test
-    void chargesStartPriceAtStartDistanceBoundary() {
-        assertEquals(new BigDecimal("9.90"), calculator.calculate(pricing(), 3000));
+    void appliesMinimumPriceAtIncludedDistanceBoundary() {
+        assertEquals(new BigDecimal("80.00"), calculator.calculate(pricing(), 1000, 0));
     }
 
     @Test
-    void roundsAnyStartedExtraKilometreUp() {
-        assertEquals(new BigDecimal("12.90"), calculator.calculate(pricing(), 3001));
-        assertEquals(new BigDecimal("12.90"), calculator.calculate(pricing(), 4000));
-        assertEquals(new BigDecimal("15.90"), calculator.calculate(pricing(), 4001));
+    void returnsDistanceDurationAndPeakBreakdownUsingActualUnits() {
+        FlashDeliveryPricing pricing = pricing();
+        pricing.setPerMinutePrice(new BigDecimal("1.50"));
+        pricing.setPeakMultiplier(new BigDecimal("1.10"));
+
+        var breakdown = calculator.calculateBreakdown(pricing, 3200, 600);
+
+        assertEquals(new BigDecimal("41.80"), breakdown.getDistanceFee());
+        assertEquals(new BigDecimal("15.00"), breakdown.getDurationFee());
+        assertEquals(new BigDecimal("126.80"), breakdown.getSubtotal());
+        assertEquals(new BigDecimal("12.68"), breakdown.getPeakFee());
+        assertEquals(new BigDecimal("139.48"), breakdown.getAmount());
     }
 
     @Test
     void rejectsNonPositiveDistanceOrInvalidPricing() {
-        assertThrows(IllegalArgumentException.class, () -> calculator.calculate(pricing(), 0));
+        assertThrows(IllegalArgumentException.class, () -> calculator.calculate(pricing(), 0, 0));
         FlashDeliveryPricing pricing = pricing();
         pricing.setPerKmPrice(BigDecimal.ZERO);
-        assertThrows(IllegalArgumentException.class, () -> calculator.calculate(pricing, 3001));
+        assertThrows(IllegalArgumentException.class, () -> calculator.calculate(pricing, 3001, 0));
+        FlashDeliveryPricing discountPeak = pricing();
+        discountPeak.setPeakMultiplier(new BigDecimal("0.90"));
+        assertThrows(IllegalArgumentException.class, () -> calculator.calculate(discountPeak, 3001, 0));
     }
 
     private FlashDeliveryPricing pricing() {
         FlashDeliveryPricing pricing = new FlashDeliveryPricing();
-        pricing.setStartPrice(new BigDecimal("9.90"));
-        pricing.setStartDistanceMeters(3000);
-        pricing.setPerKmPrice(new BigDecimal("3.00"));
+        pricing.setStartPrice(new BigDecimal("70.00"));
+        pricing.setMinimumPrice(new BigDecimal("80.00"));
+        pricing.setStartDistanceMeters(1000);
+        pricing.setPerKmPrice(new BigDecimal("19.00"));
+        pricing.setPerMinutePrice(BigDecimal.ZERO);
+        pricing.setPeakMultiplier(BigDecimal.ONE);
         pricing.setEnabled(true);
         return pricing;
     }

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

@@ -3,6 +3,8 @@ 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.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
 
 import java.util.Date;
 import java.util.List;
@@ -13,8 +15,19 @@ import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
 import static org.mockito.Mockito.doThrow;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
 
 class FlashDeliveryAutoCompleteTaskTest {
+    @Test
+    void usesQuartzInvocableBeanInsteadOfLocalScheduledTrigger() throws Exception {
+        Component component = FlashDeliveryAutoCompleteTask.class.getAnnotation(Component.class);
+
+        assertEquals("flashDeliveryAutoCompleteTask", component.value());
+        assertNull(FlashDeliveryAutoCompleteTask.class.getMethod("completeDeliveredOrders")
+                .getAnnotation(Scheduled.class));
+    }
+
     @Test
     void completesEveryDueOrderThroughConditionalServiceOperation() {
         FlashDeliveryApplicationService service = mock(FlashDeliveryApplicationService.class);

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

@@ -82,7 +82,9 @@ public class InfoAddress
     /** 距离 */
     @Excel(name = "距离")
     private transient Double juli;
+    /** 是否置顶;仅影响当前用户地址簿内的排序。 */
     private Boolean isTop;
+    /** 最近一次置顶时间,用于多个置顶地址之间排序。 */
     private Date topTime;
 
     public void setId(Long id)

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

@@ -8,45 +8,83 @@ import lombok.Data;
 import java.math.BigDecimal;
 import java.util.Date;
 
+/** 独立闪送订单实体,不与餐饮订单或出租车订单共用状态。 */
 @Data
 @TableName("flash_delivery_order")
 public class FlashDeliveryOrder {
     @TableId(type = IdType.AUTO)
     private Long id;
     private String orderNo;
+    /** 客户端请求幂等号,与 userId 共同建立唯一约束。 */
     private String clientRequestId;
     private Long userId;
+    /** 抢单前为空,原子抢单成功后绑定且不允许骑手主动放弃。 */
     private Long riderId;
     private String serviceType;
     private String status;
+    /** 用户申报的包裹类别和机车可载重量档。 */
+    private String packageType;
+    private String packageSize;
+    /** NOW 为立即配送,SCHEDULED 为预约取件时段。 */
+    private String deliveryMode;
+    private Date scheduledPickupStartAt;
+    private Date scheduledPickupEndAt;
+    /** 交付 PIN 只允许订单用户和平台读取,骑手响应必须剔除。 */
+    private Boolean pinRequired;
+    private String deliveryPinCode;
+
+    /** 取件联系人和完整地址快照。 */
     private String pickupName;
     private String pickupPhone;
     private String pickupAddress;
     private String pickupAddressDetail;
+    private String pickupCity;
+    private String pickupArea;
+    private String pickupHandoffMethod;
     private BigDecimal pickupLongitude;
     private BigDecimal pickupLatitude;
+
+    /** 收件联系人和完整地址快照。 */
     private String deliveryName;
     private String deliveryPhone;
     private String deliveryAddress;
     private String deliveryAddressDetail;
+    private String deliveryCity;
+    private String deliveryArea;
+    private String deliveryHandoffMethod;
     private BigDecimal deliveryLongitude;
     private BigDecimal deliveryLatitude;
+
+    /** 服务端计算的路线结果;distanceSource 标识地图路线或降级直线距离。 */
     private Integer distanceMeters;
     private String distanceSource;
     private Integer estimatedDurationSeconds;
+
+    /** 下单时的金额和计价配置快照,后续后台调价不会修改这些字段。 */
     private BigDecimal amount;
     private String currency;
     private Integer pricingVersion;
     private BigDecimal startPrice;
+    private BigDecimal minimumPrice;
     private Integer startDistanceMeters;
     private BigDecimal perKmPrice;
+    private BigDecimal perMinutePrice;
+    private BigDecimal peakMultiplier;
+    private BigDecimal distanceFee;
+    private BigDecimal durationFee;
+    private BigDecimal peakFee;
     private String userNote;
+    /** 订单条件更新版本号,用于记录并发状态变化次数。 */
     private Integer version;
+
+    /** 各履约节点的首次成功时间。 */
     private Date acceptedAt;
     private Date pickedUpAt;
     private Date deliveredAt;
     private Date completedAt;
     private Date cancelledAt;
+
+    /** 取消审计信息,操作人类型可为 USER 或 ADMIN。 */
     private String cancelReason;
     private String cancelOperatorType;
     private Long cancelOperatorId;

+ 4 - 0
ruoyi-system/src/main/java/com/ruoyi/system/domain/flash/FlashDeliveryOrderImage.java

@@ -7,15 +7,19 @@ import lombok.Data;
 
 import java.util.Date;
 
+/** 闪送图片凭证;寄件图片可选,PICKUP 和 DELIVERY 两个阶段均至少需要一张。 */
 @Data
 @TableName("flash_delivery_order_image")
 public class FlashDeliveryOrderImage {
     @TableId(type = IdType.AUTO)
     private Long id;
     private Long orderId;
+    /** 凭证类型:SENDER(寄件)、PICKUP(取件)或 DELIVERY(送达)。 */
     private String proofType;
     private String imageUrl;
     private Integer sortOrder;
+    /** 操作人类型用于区分用户寄件图片和骑手履约图片。 */
+    private String operatorType;
     private Long operatorId;
     private Date createTime;
 }

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

@@ -7,6 +7,7 @@ import lombok.Data;
 
 import java.util.Date;
 
+/** 闪送订单状态变更审计日志。 */
 @Data
 @TableName("flash_delivery_order_log")
 public class FlashDeliveryOrderLog {
@@ -15,6 +16,7 @@ public class FlashDeliveryOrderLog {
     private Long orderId;
     private String fromStatus;
     private String toStatus;
+    /** 操作人类型:USER、RIDER、ADMIN 或 SYSTEM。 */
     private String operatorType;
     private Long operatorId;
     private String reason;

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

@@ -8,16 +8,25 @@ import lombok.Data;
 import java.math.BigDecimal;
 import java.util.Date;
 
+/** 某一闪送服务类型的后台计价配置。 */
 @Data
 @TableName("flash_delivery_pricing")
 public class FlashDeliveryPricing {
     @TableId(type = IdType.AUTO)
     private Long id;
     private String serviceType;
+    /** 起步价,覆盖 startDistanceMeters 以内的路线。 */
     private BigDecimal startPrice;
+    /** 报价应用加成后仍不得低于该金额。 */
+    private BigDecimal minimumPrice;
     private Integer startDistanceMeters;
+    /** 超出起步距离后按实际小数公里计算的单价。 */
     private BigDecimal perKmPrice;
+    private BigDecimal perMinutePrice;
+    /** 当前运营时段使用的报价倍率,1 表示无加成。 */
+    private BigDecimal peakMultiplier;
     private Boolean enabled;
+    /** 管理员并发更新使用的乐观版本号。 */
     private Integer configVersion;
     private Long updatedBy;
     private Date createTime;

+ 1 - 0
ruoyi-system/src/main/java/com/ruoyi/system/domain/flash/FlashDeliveryStatus.java

@@ -1,5 +1,6 @@
 package com.ruoyi.system.domain.flash;
 
+/** 闪送订单状态常量,状态流转规则由应用层状态机统一维护。 */
 public final class FlashDeliveryStatus {
     public static final String WAITING_ACCEPTANCE = "WAITING_ACCEPTANCE";
     public static final String ACCEPTED = "ACCEPTED";

+ 1 - 0
ruoyi-system/src/main/java/com/ruoyi/system/mapper/flash/FlashDeliveryOrderImageMapper.java

@@ -3,4 +3,5 @@ package com.ruoyi.system.mapper.flash;
 import com.baomidou.mybatisplus.core.mapper.BaseMapper;
 import com.ruoyi.system.domain.flash.FlashDeliveryOrderImage;
 
+/** 取件和送达凭证图片持久化接口。 */
 public interface FlashDeliveryOrderImageMapper extends BaseMapper<FlashDeliveryOrderImage> { }

+ 1 - 0
ruoyi-system/src/main/java/com/ruoyi/system/mapper/flash/FlashDeliveryOrderLogMapper.java

@@ -3,4 +3,5 @@ package com.ruoyi.system.mapper.flash;
 import com.baomidou.mybatisplus.core.mapper.BaseMapper;
 import com.ruoyi.system.domain.flash.FlashDeliveryOrderLog;
 
+/** 订单状态变更审计日志持久化接口。 */
 public interface FlashDeliveryOrderLogMapper extends BaseMapper<FlashDeliveryOrderLog> { }

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

@@ -7,29 +7,41 @@ import org.apache.ibatis.annotations.Param;
 import java.util.Date;
 import java.util.List;
 
+/** 闪送订单持久化接口,关键状态操作均使用带前置条件的原子 SQL。 */
 public interface FlashDeliveryOrderMapper extends BaseMapper<FlashDeliveryOrder> {
+    /** 按用户幂等键查询已经创建的订单。 */
     FlashDeliveryOrder selectByUserRequestId(@Param("userId") Long userId,
                                              @Param("clientRequestId") String clientRequestId);
 
+    /**
+     * 原子抢单,仅当订单仍为待接单且未绑定骑手时更新成功。
+     *
+     * @return 1 表示抢单成功,0 表示订单已被抢走或状态已变化
+     */
     int accept(@Param("id") Long id, @Param("riderId") Long riderId, @Param("now") Date now);
 
+    /** 按骑手归属和预期状态推进取件或送达,防止越权及并发重复操作。 */
     int transitionByRider(@Param("id") Long id, @Param("riderId") Long riderId,
                           @Param("expectedStatus") String expectedStatus,
                           @Param("nextStatus") String nextStatus,
                           @Param("timeColumn") String timeColumn, @Param("now") Date now);
 
+    /** 按订单所有人和预期状态完成用户确认收货。 */
     int transitionByUser(@Param("id") Long id, @Param("userId") Long userId,
                          @Param("expectedStatus") String expectedStatus,
                          @Param("nextStatus") String nextStatus, @Param("now") Date now);
 
+    /** 供平台和系统任务按预期状态执行条件更新。 */
     int transitionByStatus(@Param("id") Long id, @Param("expectedStatus") String expectedStatus,
                            @Param("nextStatus") String nextStatus,
                            @Param("timeColumn") String timeColumn, @Param("now") Date now);
 
+    /** 记录取消操作人和原因,并仅从调用方已校验的当前状态转为已取消。 */
     int cancel(@Param("id") Long id, @Param("expectedStatus") String expectedStatus,
                @Param("operatorType") String operatorType, @Param("operatorId") Long operatorId,
                @Param("reason") String reason, @Param("now") Date now);
 
+    /** 分批查询送达时间早于截止点且仍未完成的订单。 */
     List<FlashDeliveryOrder> selectAutoCompletable(@Param("deadline") Date deadline,
                                                    @Param("limit") int limit);
 }

+ 1 - 0
ruoyi-system/src/main/java/com/ruoyi/system/mapper/flash/FlashDeliveryPricingMapper.java

@@ -3,4 +3,5 @@ package com.ruoyi.system.mapper.flash;
 import com.baomidou.mybatisplus.core.mapper.BaseMapper;
 import com.ruoyi.system.domain.flash.FlashDeliveryPricing;
 
+/** 闪送计价配置持久化接口。 */
 public interface FlashDeliveryPricingMapper extends BaseMapper<FlashDeliveryPricing> { }

+ 26 - 0
ruoyi-system/src/main/resources/mapper/flash/FlashDeliveryOrderMapper.xml

@@ -9,16 +9,29 @@
         <result property="riderId" column="rider_id"/>
         <result property="serviceType" column="service_type"/>
         <result property="status" column="status"/>
+        <result property="packageType" column="package_type"/>
+        <result property="packageSize" column="package_size"/>
+        <result property="deliveryMode" column="delivery_mode"/>
+        <result property="scheduledPickupStartAt" column="scheduled_pickup_start_at"/>
+        <result property="scheduledPickupEndAt" column="scheduled_pickup_end_at"/>
+        <result property="pinRequired" column="pin_required"/>
+        <result property="deliveryPinCode" column="delivery_pin_code"/>
         <result property="pickupName" column="pickup_name"/>
         <result property="pickupPhone" column="pickup_phone"/>
         <result property="pickupAddress" column="pickup_address"/>
         <result property="pickupAddressDetail" column="pickup_address_detail"/>
+        <result property="pickupCity" column="pickup_city"/>
+        <result property="pickupArea" column="pickup_area"/>
+        <result property="pickupHandoffMethod" column="pickup_handoff_method"/>
         <result property="pickupLongitude" column="pickup_longitude"/>
         <result property="pickupLatitude" column="pickup_latitude"/>
         <result property="deliveryName" column="delivery_name"/>
         <result property="deliveryPhone" column="delivery_phone"/>
         <result property="deliveryAddress" column="delivery_address"/>
         <result property="deliveryAddressDetail" column="delivery_address_detail"/>
+        <result property="deliveryCity" column="delivery_city"/>
+        <result property="deliveryArea" column="delivery_area"/>
+        <result property="deliveryHandoffMethod" column="delivery_handoff_method"/>
         <result property="deliveryLongitude" column="delivery_longitude"/>
         <result property="deliveryLatitude" column="delivery_latitude"/>
         <result property="distanceMeters" column="distance_meters"/>
@@ -28,8 +41,14 @@
         <result property="currency" column="currency"/>
         <result property="pricingVersion" column="pricing_version"/>
         <result property="startPrice" column="start_price"/>
+        <result property="minimumPrice" column="minimum_price"/>
         <result property="startDistanceMeters" column="start_distance_meters"/>
         <result property="perKmPrice" column="per_km_price"/>
+        <result property="perMinutePrice" column="per_minute_price"/>
+        <result property="peakMultiplier" column="peak_multiplier"/>
+        <result property="distanceFee" column="distance_fee"/>
+        <result property="durationFee" column="duration_fee"/>
+        <result property="peakFee" column="peak_fee"/>
         <result property="userNote" column="user_note"/>
         <result property="version" column="version"/>
         <result property="acceptedAt" column="accepted_at"/>
@@ -48,12 +67,15 @@
         select * from flash_delivery_order where user_id = #{userId} and client_request_id = #{clientRequestId} limit 1
     </select>
 
+    <!-- 抢单必须在单条 SQL 中同时校验状态和骑手为空,避免并发查询后重复绑定。 -->
     <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})
     </update>
 
+    <!-- 骑手履约同时校验订单归属和预期状态,只有中单骑手能按顺序推进。 -->
     <update id="transitionByRider">
         update flash_delivery_order
         set status=#{nextStatus},
@@ -62,18 +84,21 @@
         where id=#{id} and rider_id=#{riderId} and status=#{expectedStatus}
     </update>
 
+    <!-- 用户确认收货必须同时满足订单归属和已送达状态。 -->
     <update id="transitionByUser">
         update flash_delivery_order
         set status=#{nextStatus}, completed_at=#{now}, update_time=#{now}, version = version + 1
         where id=#{id} and user_id=#{userId} and status=#{expectedStatus}
     </update>
 
+    <!-- 平台/系统完成同样使用预期状态条件,防止与用户确认并发重复成功。 -->
     <update id="transitionByStatus">
         update flash_delivery_order
         set status=#{nextStatus}, completed_at=#{now}, update_time=#{now}, version = version + 1
         where id=#{id} and status=#{expectedStatus}
     </update>
 
+    <!-- 取消时保存完整审计字段,并以调用前读取的状态作为并发条件。 -->
     <update id="cancel">
         update flash_delivery_order
         set status='CANCELLED', cancelled_at=#{now}, cancel_reason=#{reason},
@@ -82,6 +107,7 @@
         where id=#{id} and status=#{expectedStatus}
     </update>
 
+    <!-- 定时任务只扫描达到 24 小时阈值且仍处于已送达状态的订单。 -->
     <select id="selectAutoCompletable" resultMap="FlashDeliveryOrderResult">
         select * from flash_delivery_order
         where status='DELIVERED' and delivered_at &lt;= #{deadline}

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

@@ -36,6 +36,9 @@ class FlashDeliveryMapperXmlTest {
         assertEquals("amount", amount.getColumn());
         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("package_type"));
+        assertTrue(xml.contains("delivery_pin_code"));
         assertTrue(xml.contains("delivered_at &lt;="));
         assertTrue(xml.contains("version = version + 1"));
     }

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

@@ -7,6 +7,9 @@
 - 响应沿用 `AjaxResult`:`{"code":200,"msg":"...","data":...}`;分页 data 为 `records,total,current,size`。
 - 金额为 TWD 两位小数,距离为米。
 - `serviceType`:`HELP_SEND`、`HELP_PICKUP`、`URGENT`。
+- `packageType`:`DOCUMENT`、`GIFT`、`CLOTHING`、`BEAUTY`、`DAILY_NECESSITIES`、`FOOD_INGREDIENTS`、`ELECTRONICS`、`SMALL_APPLIANCE`、`OTHER`。
+- `packageSize`:`SMALL`(不超过 5kg)、`MEDIUM`(不超过 12kg)、`LARGE`(不超过 20kg);不提交精确重量。
+- `deliveryMode`:`NOW`、`SCHEDULED`。预约起止时间使用 ISO 日期时间,时段固定 30 分钟且开始时间不晚于三天后。
 - `status`:`WAITING_ACCEPTANCE`、`ACCEPTED`、`PICKED_UP`、`DELIVERED`、`COMPLETED`、`CANCELLED`。
 
 ## 地址对象
@@ -27,15 +30,15 @@
 
 | 方法 | 路径 | 请求/说明 |
 |---|---|---|
-| GET | `/system/flashDelivery/home` | 三类启用服务和计价摘要 `serviceType,startPrice,startDistanceMeters,perKmPrice,pricingVersion,currency`;不返回内部更新人、时间或地图密钥 |
-| POST | `/system/flashDelivery/quote` | `serviceType,pickup,delivery` |
-| POST | `/system/flashDelivery/orders` | 报价请求字段 + `clientRequestId` + `userNote?`;服务端重算并幂等创建 |
-| GET | `/system/flashDelivery/orders` | `pageNum,pageSize,status?,serviceType?`;仅本人 |
+| GET | `/system/flashDelivery/home` | 三类启用服务和计价摘要 `serviceType,startPrice,minimumPrice,startDistanceMeters,perKmPrice,perMinutePrice,peakMultiplier,pricingVersion,currency`;不返回内部更新人、时间或地图密钥 |
+| POST | `/system/flashDelivery/quote` | `serviceType,packageType,packageSize,pickup,delivery` |
+| POST | `/system/flashDelivery/orders` | 报价请求字段 + `clientRequestId,deliveryMode,scheduledPickupStartAt?,scheduledPickupEndAt?,pinRequired?,senderImageUrls?,userNote?`;服务端重算并幂等创建 |
+| GET | `/system/flashDelivery/orders` | `pageNum,pageSize,status?,scene?,serviceType?`;`scene=matching/progress/done/cancelled`;仅本人 |
 | GET | `/system/flashDelivery/orders/{id}` | 本人完整详情、凭证和日志 |
 | POST | `/system/flashDelivery/orders/{id}/cancel` | `{"reason":"行程变化"}`;仅待接/已接 |
 | POST | `/system/flashDelivery/orders/{id}/confirmReceipt` | 无 body;仅 DELIVERED |
 
-报价响应 data:`serviceType,distanceMeters,distanceSource,estimatedDurationSeconds?,amount,currency,pricingVersion`。地址快照字段为联系人、电话、地址、详细地址和经纬度。客户端传入的金额/距离不会被使用。
+报价响应 data:`serviceType,distanceMeters,distanceSource,estimatedDurationSeconds?,startPrice,minimumPrice,distanceFee,durationFee,subtotal,peakMultiplier,peakFee,amount,currency,pricingVersion`。地址快照字段为联系人、电话、市/区、交付方式、地址、详细地址和经纬度。客户端传入的金额/距离不会被使用。用户详情响应为 `order,images,logs,rider,deliveryPinCode?`,其中 PIN 仅在订单启用时向订单用户返回;骑手详情永不返回 PIN,用户仅在 `ACCEPTED`、`PICKED_UP` 阶段获得骑手位置。
 
 ## 骑手端
 
@@ -44,23 +47,23 @@
 | 方法 | 路径 | 请求/说明 |
 |---|---|---|
 | GET | `/system/flashDelivery/rider/orders/available` | `pageNum,pageSize,serviceType?`;加急优先,仅返回下述接单前安全字段 |
-| GET | `/system/flashDelivery/rider/orders/mine` | `pageNum,pageSize,status?`;仅本人任务 |
+| GET | `/system/flashDelivery/rider/orders/mine` | `pageNum,pageSize,status?,scene?`;`scene=pickup/delivering/completed/cancelled`;仅本人任务 |
 | GET | `/system/flashDelivery/rider/orders/{id}` | 未接单仅返回接单前安全字段;接单后仅订单骑手可见完整信息 |
 | POST | `/system/flashDelivery/rider/orders/{id}/accept` | 原子抢单,成功返回完整详情 |
 | POST | `/system/flashDelivery/rider/orders/{id}/pickup` | `{"imageUrls":["https://.../pickup.jpg"]}`;至少一张 |
-| POST | `/system/flashDelivery/rider/orders/{id}/deliver` | 同上;至少一张 |
+| POST | `/system/flashDelivery/rider/orders/{id}/deliver` | `{"imageUrls":["https://.../delivery.jpg"],"pinCode":"4821"}`;图片至少一张,启用 PIN 时必须正确 |
 
-接单前安全字段固定为:`id,orderNo,serviceType,status,pickupLatitudeApprox,pickupLongitudeApprox,deliveryLatitudeApprox,deliveryLongitudeApprox,distanceMeters,amount,currency,createTime`。近似经纬度只保留两位小数;不返回用户 ID、联系人、电话、文字地址、精确经纬度、备注、幂等请求号、凭证或日志。抢单成功后才向中单骑手返回完整详情。
+接单前安全字段固定为:`id,orderNo,serviceType,status,packageType,packageSize,deliveryMode,scheduledPickupStartAt,scheduledPickupEndAt,pickupCity,pickupArea,deliveryCity,deliveryArea,pickupLatitudeApprox,pickupLongitudeApprox,deliveryLatitudeApprox,deliveryLongitudeApprox,distanceMeters,estimatedDurationSeconds,amount,currency,createTime`。近似经纬度只保留两位小数;不返回用户 ID、联系人、电话、完整文字地址、精确经纬度、备注、PIN、幂等请求号、凭证或日志。抢单成功后才向中单骑手返回履约业务详情。
 
 ## 平台端
 
 | 方法 | 路径 | 请求/说明 |
 |---|---|---|
 | GET | `/system/flashDelivery/admin/pricing` | 三类当前计价 |
-| PUT | `/system/flashDelivery/admin/pricing/{serviceType}` | `startPrice,startDistanceMeters,perKmPrice,enabled`;正数,版本原子加一;并发覆盖返回业务错误 |
+| PUT | `/system/flashDelivery/admin/pricing/{serviceType}` | `startPrice,minimumPrice,startDistanceMeters,perKmPrice,perMinutePrice,peakMultiplier,enabled`;起步价、最低价、距离和公里价为正,每分钟价格非负,尖峰倍率不小于 1,版本原子加一;并发覆盖返回业务错误 |
 | GET | `/system/flashDelivery/admin/orders` | 分页;可按状态、类型、订单号、用户、骑手筛选 |
 | GET | `/system/flashDelivery/admin/orders/{id}` | 完整详情、凭证、日志 |
 | POST | `/system/flashDelivery/admin/orders/{id}/cancel` | `reason` 必填;非终态可取消 |
 | POST | `/system/flashDelivery/admin/orders/{id}/complete` | 无 body;仅 DELIVERED |
 
-本期没有支付、退款、结算、骑手收入、代购/垫付、重量、品类、件数、违禁品确认、骑手放弃或自动派单字段和接口。
+本期没有支付、退款、取消费、退回费、结算、骑手收入、代购/垫付、精确重量、件数、违禁品电子确认、骑手放弃或自动派单字段和接口。

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

@@ -2,17 +2,17 @@
 
 ## `flash_delivery_order`
 
-核心字段:`id`、唯一 `order_no`、与用户联合唯一的 `client_request_id`、`user_id`、`rider_id`、`service_type`、`status`;取件/收件联系人、电话、地址、详细地址和经纬度快照;`distance_meters`、`distance_source`、`estimated_duration_seconds`;`amount`、`currency`、`pricing_version`、起步价/起步距离/续程价快照;`user_note`、`version`、关键状态时间、取消审计及创建/更新时间。
+核心字段:`id`、唯一 `order_no`、与用户联合唯一的 `client_request_id`、`user_id`、`rider_id`、`service_type`、`status`;`package_type`、`package_size`;`delivery_mode`、预约开始/结束时间;`pin_required`、四位交付 PIN;取件/收件联系人、电话、市/区、交付方式、地址、详细地址和经纬度快照;`distance_meters`、`distance_source`、`estimated_duration_seconds`;`amount`、`currency`、`pricing_version`、完整计价配置与费用明细快照;`user_note`、`version`、关键状态时间、取消审计及创建/更新时间。
 
 索引:订单号唯一、用户幂等号唯一、待抢排序、用户列表、骑手列表、自动完成扫描。
 
 ## `flash_delivery_pricing`
 
-按 `service_type` 唯一,保存 `start_price`、`start_distance_meters`、`per_km_price`、`enabled`、`config_version`、`updated_by` 和时间。有效修改后版本加一,历史订单不回写。
+按 `service_type` 唯一,保存 `start_price`、`minimum_price`、`start_distance_meters`、`per_km_price`、`per_minute_price`、`peak_multiplier`、`enabled`、`config_version`、`updated_by` 和时间。有效修改后版本加一,历史订单不回写。
 
 ## `flash_delivery_order_image`
 
-保存 `order_id`、`proof_type`(PICKUP/DELIVERY)、`image_url`、`sort_order`、`operator_id`、`create_time`。
+保存 `order_id`、`proof_type`(SENDER/PICKUP/DELIVERY)、`image_url`、`sort_order`、`operator_type`、`operator_id`、`create_time`。
 
 ## `flash_delivery_order_log`
 

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

@@ -14,7 +14,7 @@
 6. 闪送
 7. 闪送 送
 
-设计稿明确展示三种服务、取件与收件地址、联系人和电话、地址簿、起步价格及下单入口;未展示重量、类别、件数、支付或代购信息
+最初的蓝湖 7 页明确展示三种服务、取件与收件地址、联系人和电话、地址簿、起步价格及下单入口,未展示重量和类别;2026-08-24 用户端/骑手端补充原型进一步明确包裹类别、重量档、预约配送、交付 PIN、寄件图片和费用明细。两套设计均未明确支付、代购、精确重量或件数规则
 
 ## 2. 架构决策
 
@@ -83,15 +83,15 @@ ruoyi-system
 
 ### 4.1 `flash_delivery_order`
 
-保存订单号、客户端请求号、用户、骑手、服务类型、取件与收件信息快照、路线距离、距离来源、预计时长、报价金额、币种、计价配置版本、备注、状态、并发版本号、关键操作时间与取消信息。
+保存订单号、客户端请求号、用户、骑手、服务类型、包裹类别、重量档、配送方式、预约时段、交付 PIN、取件与收件信息快照、路线距离、距离来源、预计时长、报价金额与费用明细、币种、计价配置版本、备注、状态、并发版本号、关键操作时间与取消信息。
 
 ### 4.2 `flash_delivery_pricing`
 
-按服务类型保存起步价、起步距离、每公里价格、启用状态、配置版本、修改人和修改时间。
+按服务类型保存起步价、最低价、起步距离、每公里价格、每分钟价格、尖峰倍率、启用状态、配置版本、修改人和修改时间。
 
 ### 4.3 `flash_delivery_order_image`
 
-保存订单、凭证类型、图片 URL、排序和创建时间。凭证类型包括 `PICKUP` 与 `DELIVERY`。
+保存订单、凭证类型、图片 URL、排序、操作人类型、操作人和创建时间。凭证类型包括 `SENDER`、`PICKUP` 与 `DELIVERY`。
 
 ### 4.4 `flash_delivery_order_log`
 
@@ -104,15 +104,15 @@ ruoyi-system
 ## 5. 计价与距离
 
 ```text
-距离 <= 起步距离:
-  价格 = 起步价
-
-距离 > 起步距离:
-  价格 = 起步价
-       + ceil((距离 - 起步距离) / 1000米) * 每公里价格
+超距公里 = max(0, 距离 - 起步距离) / 1000米
+里程费 = 超距公里 * 每公里价格
+时长费 = 预计时长秒 / 60 * 每分钟价格
+小计 = 起步价 + 里程费 + 时长费
+尖峰费 = 小计 * (尖峰倍率 - 1)
+价格 = max(最低价, 小计 + 尖峰费)
 ```
 
-金额使用十进制定点数并保留两位小数。帮送、帮取和加急送分别拥有独立配置;加急送不由客户端提交系数。报价优先使用地图路线距离,失败时降级为直线距离。创建订单时服务端必须重新报价并固化快照。
+超距公里和路线分钟按实际小数单位计费,不向上取整;里程费、时长费、小计、尖峰费和最终价格分别使用十进制定点数并保留两位小数,尖峰倍率不得小于 1。帮送、帮取和加急送分别拥有独立配置;加急送不由客户端提交系数。报价优先使用地图路线距离,失败时降级为直线距离。创建订单时服务端必须重新报价并固化快照。
 
 ## 6. 状态机
 
@@ -163,4 +163,4 @@ COMPLETED
 
 ## 9. 明确延期内容
 
-支付、退款、分账、代购、垫付、商品信息、重量、自动派单、动态附近骑手数和预计接单时间均不在本期范围。
+支付、退款、分账、骑手收入、代购、垫付、商品金额、精确重量、件数、自动派单、动态附近骑手数和预计接单时间均不在本期范围。

+ 14 - 3
specs/024-flash-delivery/plan.md

@@ -4,7 +4,7 @@
 
 ## 摘要
 
-新增独立闪送订单模块,提供帮送、帮取、加急送三种非支付配送服务。服务端负责路线距离、报价、订单快照、骑手原子抢单、取件/送达图片凭证、用户签收、24 小时自动完成、平台计价配置和异常介入;同步把共享地址簿改为基于 token 的安全接口。支付、退款、结算、代购、物品重量和自动派单不在本期范围。
+在既有独立闪送模块上补充包裹类别/重量档、立即或预约配送、可选 PIN 交付、寄件图片、费用明细、页面状态分组、骑手公开摘要和角色安全响应。服务端继续负责路线报价、骑手原子抢单、取件/送达图片、用户签收、24 小时自动完成和平台介入;支付、退款、结算、骑手收入与自动派单仍不在本期范围。
 
 ## 技术上下文
 
@@ -57,8 +57,19 @@ ruoyi-system/src/test/java/com/ruoyi/system/mapper/flash/
 4. `(user_id, client_request_id)` 唯一键保证创建幂等;冲突时返回原订单。
 5. Google Routes API 字段掩码仅取距离与时长;异常、非 2xx、空路线或无效距离降级为 Haversine。
 6. 待抢单使用专用脱敏视图;抢单后只有订单骑手可见完整电话和详细地址。
-7. 取件/送达图片在状态更新同一事务内写入;空数组、空 URL 或非 HTTP(S) URL 拒绝。
-8. 自动任务扫描送达满 24 小时订单并逐条条件完成,和用户/平台并发时只允许一次成功。
+7. 寄件图片在创建事务中写入;取件/送达图片在状态更新事务中写入。三类凭证均校验 1 至 9 个 HTTP(S) URL,取件和送达不得为空。
+8. 预约订单沿用 `WAITING_ACCEPTANCE`,可抢查询和原子抢单 SQL 同时限制 `scheduled_pickup_start_at <= now`,不增加仅为调度使用的新状态。
+9. PIN 默认启用并由服务端生成四位数字;用户详情可见,骑手响应始终剔除,送达事务先校验 PIN 再写凭证和状态。
+10. 用户/骑手使用业务视图,平台详情保留完整审计实体;待抢视图只增加类别、重量档、配送时段、预计时长和市/区级路线信息。
+11. 自动任务扫描送达满 24 小时订单并逐条条件完成,和用户/平台并发时只允许一次成功。
+
+## 补充原型增量实施批次
+
+1. 更新规格、API、数据模型和 `updatesql/sql.md`,明确旧表增量字段及索引。
+2. 先扩展计价器、Mapper XML、应用服务和 Controller 契约测试并确认红灯。
+3. 增加请求/响应 DTO、实体字段、计价明细与角色安全映射。
+4. 实现预约可抢条件、PIN 校验、寄件凭证和状态分组分页。
+5. 使用 JDK 21 运行闪送定向测试、MyBatis XML 测试、模块构建与差异检查。
 
 ## 验证策略
 

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

@@ -8,7 +8,7 @@
 
 ## 降级与精度
 
-外部路线不可用时使用 Haversine 公式计算球面直线距离,四舍五入为整数米并标记 `STRAIGHT_LINE`;路线成功标记 `ROUTE`。价格使用 `BigDecimal` 和 `decimal(10,2)`;超距公里用整数米执行向上取整,避免浮点边界误差。
+外部路线不可用时使用 Haversine 公式计算球面直线距离,四舍五入为整数米并标记 `STRAIGHT_LINE`;路线成功标记 `ROUTE`。价格使用 `BigDecimal` 和 `decimal(10,2)`;超距里程以整数米转换为实际小数公里,预计时长以秒转换为实际小数分钟,分别计算里程费和时长费后再应用不小于 1 的尖峰倍率与最低价,避免浮点边界误差。
 
 ## 并发与安全
 

+ 27 - 13
specs/024-flash-delivery/spec.md

@@ -4,9 +4,9 @@
 
 **创建日期**:2026-08-31
 
-**状态**:已实现并通过定向单元/契约测试;数据库迁移环境集成验证待执行
+**状态**:补充原型增量已实现并通过定向测试和模块构建;数据库迁移环境集成验证待执行
 
-**输入**:基于蓝湖“闪送”分组 7 个设计页面,实现帮送、帮取、加急送的完整非支付业务闭环,包括共享地址簿、路线报价、创建订单、骑手主动抢单、取件、送达、用户签收、平台配置与介入。
+**输入**:基于蓝湖“闪送”分组 7 个设计页面及 2026-08-24 用户端/骑手端补充原型,实现帮送、帮取、加急送的完整非支付业务闭环,包括共享地址簿、路线报价、包裹信息、立即/预约配送、可选 PIN 交付、创建订单、骑手主动抢单、取件、送达、用户签收、平台配置与介入。
 
 ## 用户场景与测试
 
@@ -20,12 +20,14 @@
 
 **验收场景**:
 
-1. **假如** 路线距离未超过起步距离,**当** 用户请求报价,**那么** 系统返回对应服务类型的起步价。
-2. **假如** 路线距离超过起步距离但不足下一个完整公里,**当** 用户请求报价,**那么** 超出部分按一个完整续程公里计价
+1. **假如** 按起步价、实际超距、预计时长和尖峰倍率计算后的金额低于最低价,**当** 用户请求报价,**那么** 系统返回对应服务类型的最低价。
+2. **假如** 路线距离超过起步距离但不足下一个完整公里,**当** 用户请求报价,**那么** 超出部分按实际小数公里计价,不向上取整
 3. **假如** 地图路线服务正常,**当** 用户请求报价,**那么** 系统使用路线距离并标记距离来源为路线服务。
 4. **假如** 地图路线服务超时或失败,**当** 用户请求报价,**那么** 系统使用经纬度直线距离降级报价并明确标记距离来源。
 5. **假如** 客户端提交伪造金额或距离,**当** 用户创建订单,**那么** 系统忽略客户端金额与距离并在服务端重新报价。
 6. **假如** 同一用户以相同客户端请求号重复创建订单,**当** 请求被重复处理,**那么** 系统返回同一订单且不产生重复订单。
+7. **假如** 用户选择预约配送,**当** 创建订单,**那么** 系统保存 30 分钟取件时段,预约开始前订单不进入骑手可抢列表。
+8. **假如** 路线距离超过 40 公里,**当** 用户报价或创建订单,**那么** 系统拒绝请求。
 
 ---
 
@@ -45,6 +47,9 @@
 4. **假如** 已接单骑手未提交取件图片,**当** 其确认取件,**那么** 系统拒绝状态变更。
 5. **假如** 已取件骑手未提交送达图片,**当** 其确认送达,**那么** 系统拒绝状态变更。
 6. **假如** 图片要求已满足,**当** 订单骑手按顺序确认取件和送达,**那么** 系统保存凭证、操作人和时间并完成对应状态变更。
+7. **假如** 订单启用 PIN 交付,**当** 骑手提交错误 PIN,**那么** 系统拒绝送达且不保存送达图片或改变状态。
+8. **假如** 订单启用 PIN 交付,**当** 骑手提交正确 PIN 和送达图片,**那么** 系统验证 PIN 后进入已送达状态。
+9. **假如** 骑手尚未接单,**当** 查看列表或详情,**那么** 仅返回包裹、时段、路线区域和报价等安全摘要,不返回完整门牌、联系人、电话、PIN 或内部字段。
 
 ---
 
@@ -120,15 +125,15 @@
 - **FR-001**:系统必须提供帮送、帮取和加急送三种服务类型。
 - **FR-002**:帮取只承担取件配送,不包含代购、垫付或商品金额。
 - **FR-003**:三种服务必须使用同一订单履约状态机;加急送仅在排序和计价上体现优先级。
-- **FR-004**:系统必须按服务类型维护起步价、起步距离和每公里价格,并保存配置版本与修改记录。
-- **FR-005**:距离不超过起步距离时必须按起步价计费
-- **FR-006**:距离超过起步距离时,超出部分必须按每开始一公里向上取整计费,最终金额保留两位小数。
+- **FR-004**:系统必须按服务类型维护基础价、最低价、包含距离、每公里价格、每分钟价格和尖峰倍率,并保存配置版本与修改记录。
+- **FR-005**:报价必须按“基础价 + 超出包含距离的实际公里费用 + 时长费用”计算小计,再应用尖峰倍率并保证结果不低于最低价
+- **FR-006**:距离、时长、尖峰和总金额必须返回可展示的费用明细,所有金额最终保留两位小数。
 - **FR-007**:系统必须优先使用地图路线距离,地图失败时使用经纬度直线距离并向客户端返回距离来源。
 - **FR-008**:地图密钥必须从服务端配置读取,不得返回客户端或写入业务日志。
 - **FR-009**:创建订单时必须由服务端重新计算距离和价格,并保存地址、路线、金额和计价配置快照。
 - **FR-010**:系统必须使用客户端请求号保证同一用户的订单创建幂等。
 - **FR-011**:闪送订单不得复用餐饮订单或打车订单数据模型。
-- **FR-012**:第一阶段订单不包含支付、退款、物品重量、物品类别、件数和禁寄品确认
+- **FR-012**:订单必须保存包裹类别和机车可载重量档,重量档为 `SMALL`(不超过 5kg)、`MEDIUM`(不超过 12kg)或 `LARGE`(不超过 20kg);不保存精确重量或件数
 - **FR-013**:订单必须支持用户备注,但备注不参与计价。
 - **FR-014**:骑手必须通过独立闪送待接单列表主动抢单,第一阶段不实现自动派单。
 - **FR-015**:待接单列表必须按加急优先、创建时间次优先的顺序返回。
@@ -156,18 +161,27 @@
 - **FR-037**:所有业务校验错误必须通过项目国际化消息机制返回,不得硬编码单一语言错误。
 - **FR-038**:系统必须复用现有上传能力;闪送接口仅接收上传完成后的图片 URL。
 - **FR-039**:本功能的数据库变更只能记录在 `updatesql/sql.md`,不得直接执行。
+- **FR-040**:订单必须支持 `NOW` 和 `SCHEDULED` 两种配送方式;预约时段固定为 30 分钟且不得晚于创建时间后三天。
+- **FR-041**:预约订单在预约开始时间前不得出现在可抢列表,也不得被骑手通过订单 ID 提前抢取。
+- **FR-042**:用户可选择是否启用 4 位数字交付 PIN;启用后骑手必须同时提交正确 PIN 和至少一张送达图片。
+- **FR-043**:PIN 只对订单用户和有权限的平台管理员可见,不得返回给待接单或已接单骑手,也不得写入业务日志。
+- **FR-044**:创建订单可提交 1 至 9 张可选寄件图片;凭证类型扩展为 `SENDER`、`PICKUP`、`DELIVERY` 并记录操作人类型。
+- **FR-045**:用户和骑手列表必须支持页面状态分组并在服务端完成分页,禁止先分页后由客户端过滤多个底层状态。
+- **FR-046**:用户和骑手详情必须使用角色专用响应视图,不得直接序列化订单实体、客户端幂等号、内部用户 ID 或计价审计字段。
+- **FR-047**:骑手接单后,用户可读取骑手昵称、头像和评分等公开摘要;实时位置只在配送进行状态向订单用户返回。
+- **FR-048**:路线距离不得超过 40 公里;超过限制时报价和创建均拒绝。
 
 ### 关键实体
 
 - **闪送订单**:用户发布的取件配送任务,持有地址、路线、价格、服务类型、履约状态和关键时间快照。
-- **计价配置**:某一服务类型当前启用的起步价、起步距离、每公里价格及版本信息。
-- **配送凭证**:骑手在取件或送达时提交的图片记录。
+- **计价配置**:某一服务类型当前启用的起步价、最低价、起步距离、每公里价格、每分钟价格、尖峰倍率及版本信息。
+- **配送凭证**:用户下单时的可选寄件图片,以及骑手取件或送达时提交的必填图片记录。
 - **订单状态日志**:记录订单每次有效状态变化的操作审计信息。
 - **共享地址**:属于单一用户、可供闪送和现有收货业务共同使用的联系人与地点信息。
 
 ## 成功标准
 
-- **SC-001**:三种服务的起步距离、超距不足一公里、超距整公里和加急配置报价测试全部通过。
+- **SC-001**:三种服务的最低价、实际超距小数公里、路线时长、尖峰倍率和加急配置报价测试全部通过。
 - **SC-002**:地图路线成功和失败降级场景均能在一次请求内返回可用报价,并准确标记距离来源。
 - **SC-003**:至少 20 个并发抢单请求针对同一订单时,数据库中恰好只有一名骑手成功绑定。
 - **SC-004**:跨用户地址和订单的详情、修改、删除、置顶、取消及签收测试拦截率达到 100%。
@@ -188,8 +202,8 @@
 
 ## 非目标
 
-- 不接入支付网关、付款、退款、结算或骑手收入分账。
-- 不实现代购、垫付款、商品金额、物品类别、重量、件数或禁寄品确认
+- 不接入支付网关、付款、退款、取消费、退回费、结算或骑手收入分账。
+- 不实现代购、垫付款、商品金额、精确重量、件数或禁寄品电子确认;禁运品、货值和尺寸限制由前端展示规则说明,后端只校验本期明确的重量档和最大路线距离
 - 不实现自动派单、抢单推送筛选、骑手在线状态计算或接单时间预测。
 - 不修改餐饮订单 `pos_order` 或打车订单 `taxi_order` 的业务语义。
 - 不在本功能中实现前端页面;本轮交付后端 API、数据库 SQL、自动化测试和 spec-kit 文档。

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

@@ -44,3 +44,13 @@
 - [x] T022 核对 API 契约、实现、权限和 SQL,更新任务状态
 - [x] T023 JDK 21 运行定向测试、模块构建和 `git diff --check`
 - [x] T024 核对暂存范围并准备中文提交
+
+## Phase 8:补充用户端/骑手端原型
+
+- [x] T025 更新包裹、预约、PIN、寄件凭证、费用明细和角色脱敏规格/API/SQL
+- [x] T026 先写计价明细、预约抢单条件、PIN、寄件凭证、状态分组和安全响应测试并确认红灯
+- [x] T027 扩展订单/计价/凭证实体、Mapper XML 和数据库迁移记录
+- [x] T028 实现包裹信息、预约配送、40 公里限制和服务端费用明细
+- [x] T029 实现 PIN 送达与 SENDER/PICKUP/DELIVERY 三类凭证
+- [x] T030 实现用户/骑手状态分组分页、角色业务视图和骑手公开摘要
+- [x] T031 更新 API 契约与 i18n,并运行 JDK 21 定向测试、模块构建和差异检查

+ 46 - 0
updatesql/sql.md

@@ -1077,3 +1077,49 @@ SELECT '闪送订单完成',0,6,'#','','F','1','0','flash:order:complete','#','a
 WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE perms='flash:order:complete');
 ```
 
+## 2026-08-31 闪送补充原型增量
+
+> 用途:增加包裹重量档、预约配送、交付 PIN、寄件凭证和费用明细。以下 SQL 由开发者在已执行初版闪送建表脚本的环境手动执行。
+
+```sql
+ALTER TABLE flash_delivery_pricing
+  ADD COLUMN minimum_price DECIMAL(10,2) NOT NULL DEFAULT 0.00 AFTER start_price,
+  ADD COLUMN per_minute_price DECIMAL(10,2) NOT NULL DEFAULT 0.00 AFTER per_km_price,
+  ADD COLUMN peak_multiplier DECIMAL(6,2) NOT NULL DEFAULT 1.00 AFTER per_minute_price;
+
+UPDATE flash_delivery_pricing
+SET minimum_price = start_price
+WHERE minimum_price = 0.00;
+
+ALTER TABLE flash_delivery_order
+  ADD COLUMN package_type VARCHAR(32) NOT NULL DEFAULT 'OTHER' AFTER status,
+  ADD COLUMN package_size VARCHAR(16) NOT NULL DEFAULT 'SMALL' AFTER package_type,
+  ADD COLUMN delivery_mode VARCHAR(16) NOT NULL DEFAULT 'NOW' AFTER package_size,
+  ADD COLUMN scheduled_pickup_start_at DATETIME NULL AFTER delivery_mode,
+  ADD COLUMN scheduled_pickup_end_at DATETIME NULL AFTER scheduled_pickup_start_at,
+  ADD COLUMN pin_required TINYINT(1) NOT NULL DEFAULT 0 AFTER scheduled_pickup_end_at,
+  ADD COLUMN delivery_pin_code CHAR(4) NULL AFTER pin_required,
+  ADD COLUMN pickup_city VARCHAR(64) NULL AFTER pickup_address_detail,
+  ADD COLUMN pickup_area VARCHAR(64) NULL AFTER pickup_city,
+  ADD COLUMN pickup_handoff_method VARCHAR(40) NULL AFTER pickup_area,
+  ADD COLUMN delivery_city VARCHAR(64) NULL AFTER delivery_address_detail,
+  ADD COLUMN delivery_area VARCHAR(64) NULL AFTER delivery_city,
+  ADD COLUMN delivery_handoff_method VARCHAR(40) NULL AFTER delivery_area,
+  ADD COLUMN minimum_price DECIMAL(10,2) NOT NULL DEFAULT 0.00 AFTER start_price,
+  ADD COLUMN per_minute_price DECIMAL(10,2) NOT NULL DEFAULT 0.00 AFTER per_km_price,
+  ADD COLUMN peak_multiplier DECIMAL(6,2) NOT NULL DEFAULT 1.00 AFTER per_minute_price,
+  ADD COLUMN distance_fee DECIMAL(10,2) NOT NULL DEFAULT 0.00 AFTER peak_multiplier,
+  ADD COLUMN duration_fee DECIMAL(10,2) NOT NULL DEFAULT 0.00 AFTER distance_fee,
+  ADD COLUMN peak_fee DECIMAL(10,2) NOT NULL DEFAULT 0.00 AFTER duration_fee,
+  DROP INDEX idx_flash_available,
+  ADD KEY idx_flash_available
+    (status, delivery_mode, scheduled_pickup_start_at, service_type, create_time);
+
+UPDATE flash_delivery_order
+SET minimum_price = start_price
+WHERE minimum_price = 0.00;
+
+ALTER TABLE flash_delivery_order_image
+  ADD COLUMN operator_type VARCHAR(20) NOT NULL DEFAULT 'RIDER' AFTER sort_order;
+```
+