Explorar o código

实现闪送配送服务后端接口

新增闪送报价、下单、骑手抢单、取件送达凭证、订单状态流转和自动完成能力。
补充安全地址簿、后台计价配置与订单介入接口,并完善规格、SQL、国际化和自动化测试。
支付、退款和结算能力暂不纳入本次实现。
qmj dende agora 2 horas
pai
achega
e519f86b35
Modificáronse 62 ficheiros con 2669 adicións e 134 borrados
  1. 65 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/controller/FlashDeliveryAdminController.java
  2. 66 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/controller/FlashDeliveryRiderController.java
  3. 68 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/controller/FlashDeliveryUserController.java
  4. 15 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryAddressRequest.java
  5. 23 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryAvailableOrderView.java
  6. 11 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryCreateRequest.java
  7. 10 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryHomeView.java
  8. 15 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryOrderDetailView.java
  9. 13 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryPricingUpdateRequest.java
  10. 10 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryProofRequest.java
  11. 10 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryQuoteRequest.java
  12. 16 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryQuoteView.java
  13. 8 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryReasonRequest.java
  14. 16 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryServiceView.java
  15. 20 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/InfoAddressRequest.java
  16. 45 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/route/FlashDeliveryRouteService.java
  17. 5 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/route/GeoPoint.java
  18. 88 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/route/GoogleRoutesDistanceProvider.java
  19. 3 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/route/RouteDistance.java
  20. 9 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/route/RouteDistanceProvider.java
  21. 488 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryApplicationService.java
  22. 23 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryPricingCalculator.java
  23. 23 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryStateMachine.java
  24. 107 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/service/InfoAddressBookService.java
  25. 31 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/task/FlashDeliveryAutoCompleteTask.java
  26. 68 130
      ruoyi-admin/src/main/java/com/ruoyi/app/order/InfoAddressController.java
  27. 26 0
      ruoyi-admin/src/main/resources/i18n/messages.properties
  28. 26 0
      ruoyi-admin/src/main/resources/i18n/messages_en_US.properties
  29. 26 0
      ruoyi-admin/src/main/resources/i18n/messages_vi.properties
  30. 26 0
      ruoyi-admin/src/main/resources/i18n/messages_zh_CN.properties
  31. 26 0
      ruoyi-admin/src/main/resources/i18n/messages_zh_TW.properties
  32. 60 0
      ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/controller/FlashDeliveryControllerContractTest.java
  33. 23 0
      ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryAvailableOrderViewTest.java
  34. 40 0
      ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/route/FlashDeliveryRouteServiceTest.java
  35. 194 0
      ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryApplicationServiceTest.java
  36. 44 0
      ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryI18nContractTest.java
  37. 43 0
      ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryPricingCalculatorTest.java
  38. 31 0
      ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryStateMachineTest.java
  39. 73 0
      ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/service/InfoAddressBookServiceTest.java
  40. 51 0
      ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/task/FlashDeliveryAutoCompleteTaskTest.java
  41. 3 0
      ruoyi-system/src/main/java/com/ruoyi/system/domain/InfoAddress.java
  42. 55 0
      ruoyi-system/src/main/java/com/ruoyi/system/domain/flash/FlashDeliveryOrder.java
  43. 21 0
      ruoyi-system/src/main/java/com/ruoyi/system/domain/flash/FlashDeliveryOrderImage.java
  44. 22 0
      ruoyi-system/src/main/java/com/ruoyi/system/domain/flash/FlashDeliveryOrderLog.java
  45. 25 0
      ruoyi-system/src/main/java/com/ruoyi/system/domain/flash/FlashDeliveryPricing.java
  46. 12 0
      ruoyi-system/src/main/java/com/ruoyi/system/domain/flash/FlashDeliveryStatus.java
  47. 6 0
      ruoyi-system/src/main/java/com/ruoyi/system/mapper/flash/FlashDeliveryOrderImageMapper.java
  48. 6 0
      ruoyi-system/src/main/java/com/ruoyi/system/mapper/flash/FlashDeliveryOrderLogMapper.java
  49. 35 0
      ruoyi-system/src/main/java/com/ruoyi/system/mapper/flash/FlashDeliveryOrderMapper.java
  50. 6 0
      ruoyi-system/src/main/java/com/ruoyi/system/mapper/flash/FlashDeliveryPricingMapper.java
  51. 90 0
      ruoyi-system/src/main/resources/mapper/flash/FlashDeliveryOrderMapper.xml
  52. 14 2
      ruoyi-system/src/main/resources/mapper/system/InfoAddressMapper.xml
  53. 42 0
      ruoyi-system/src/test/java/com/ruoyi/system/mapper/flash/FlashDeliveryMapperXmlTest.java
  54. 1 1
      specs/024-flash-delivery/checklists/requirements.md
  55. 66 0
      specs/024-flash-delivery/contracts/api.md
  56. 38 0
      specs/024-flash-delivery/data-model.md
  57. 78 0
      specs/024-flash-delivery/plan.md
  58. 8 0
      specs/024-flash-delivery/quickstart.md
  59. 19 0
      specs/024-flash-delivery/research.md
  60. 1 1
      specs/024-flash-delivery/spec.md
  61. 46 0
      specs/024-flash-delivery/tasks.md
  62. 130 0
      updatesql/sql.md

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

@@ -0,0 +1,65 @@
+package com.ruoyi.app.flashdelivery.controller;
+
+import com.ruoyi.app.flashdelivery.dto.FlashDeliveryPricingUpdateRequest;
+import com.ruoyi.app.flashdelivery.dto.FlashDeliveryReasonRequest;
+import com.ruoyi.app.flashdelivery.service.FlashDeliveryApplicationService;
+import com.ruoyi.common.core.controller.BaseController;
+import com.ruoyi.common.core.domain.AjaxResult;
+import com.ruoyi.common.utils.SecurityUtils;
+import com.ruoyi.common.annotation.Log;
+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}")
+    public AjaxResult updatePricing(@PathVariable String serviceType,
+                                    @RequestBody FlashDeliveryPricingUpdateRequest request) {
+        return success(service.updatePricing(SecurityUtils.getUserId(), serviceType, request));
+    }
+
+    @PreAuthorize("@ss.hasPermi('flash:order:list')")
+    @GetMapping("/orders")
+    public AjaxResult orders(@RequestParam(defaultValue = "1") int pageNum,
+                             @RequestParam(defaultValue = "10") int pageSize,
+                             @RequestParam(required = false) String status,
+                             @RequestParam(required = false) String serviceType,
+                             @RequestParam(required = false) String orderNo,
+                             @RequestParam(required = false) Long userId,
+                             @RequestParam(required = false) Long riderId) {
+        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")
+    public AjaxResult cancel(@PathVariable Long id, @RequestBody FlashDeliveryReasonRequest request) {
+        service.adminCancel(SecurityUtils.getUserId(), id, request);
+        return success();
+    }
+
+    @PreAuthorize("@ss.hasPermi('flash:order:complete')")
+    @Log(title = "闪送订单完成", businessType = BusinessType.UPDATE)
+    @PostMapping("/orders/{id}/complete")
+    public AjaxResult complete(@PathVariable Long id) {
+        if (!service.complete(SecurityUtils.getUserId(), "ADMIN", id)) {
+            return error(com.ruoyi.common.utils.MessageUtils.message("flash.delivery.complete.not.allowed"));
+        }
+        return success();
+    }
+}

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

@@ -0,0 +1,66 @@
+package com.ruoyi.app.flashdelivery.controller;
+
+import com.ruoyi.app.flashdelivery.dto.FlashDeliveryProofRequest;
+import com.ruoyi.app.flashdelivery.service.FlashDeliveryApplicationService;
+import com.ruoyi.common.annotation.Anonymous;
+import com.ruoyi.common.core.controller.BaseController;
+import com.ruoyi.common.core.domain.AjaxResult;
+import com.ruoyi.system.utils.Auth;
+import com.ruoyi.system.utils.JwtUtil;
+import org.springframework.web.bind.annotation.*;
+
+@RestController
+@RequestMapping("/system/flashDelivery/rider")
+@Anonymous
+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,
+                                @RequestParam(defaultValue = "1") int pageNum,
+                                @RequestParam(defaultValue = "10") int pageSize,
+                                @RequestParam(required = false) String serviceType) {
+        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));
+    }
+
+    @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,
+                             @RequestBody FlashDeliveryProofRequest request) {
+        service.pickup(userId(token), id, request);
+        return success();
+    }
+
+    @PostMapping("/orders/{id}/deliver")
+    @Auth
+    public AjaxResult deliver(@RequestHeader String token, @PathVariable Long id,
+                              @RequestBody FlashDeliveryProofRequest request) {
+        service.deliver(userId(token), id, request);
+        return success();
+    }
+
+    private Long userId(String token) { return Long.valueOf(new JwtUtil().getusid(token)); }
+}

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

@@ -0,0 +1,68 @@
+package com.ruoyi.app.flashdelivery.controller;
+
+import com.ruoyi.app.flashdelivery.dto.*;
+import com.ruoyi.app.flashdelivery.service.FlashDeliveryApplicationService;
+import com.ruoyi.common.annotation.Anonymous;
+import com.ruoyi.common.core.controller.BaseController;
+import com.ruoyi.common.core.domain.AjaxResult;
+import com.ruoyi.system.utils.Auth;
+import com.ruoyi.system.utils.JwtUtil;
+import org.springframework.web.bind.annotation.*;
+
+@RestController
+@RequestMapping("/system/flashDelivery")
+@Anonymous
+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) {
+        userId(token);
+        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 serviceType) {
+        return success(service.userOrders(userId(token), pageNum, pageSize, status, 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,
+                             @RequestBody FlashDeliveryReasonRequest request) {
+        service.userCancel(userId(token), id, request);
+        return success();
+    }
+
+    @PostMapping("/orders/{id}/confirmReceipt")
+    @Auth
+    public AjaxResult confirmReceipt(@RequestHeader String token, @PathVariable Long id) {
+        service.confirmReceipt(userId(token), id);
+        return success();
+    }
+
+    private Long userId(String token) { return Long.valueOf(new JwtUtil().getusid(token)); }
+}

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

@@ -0,0 +1,15 @@
+package com.ruoyi.app.flashdelivery.dto;
+
+import lombok.Data;
+
+import java.math.BigDecimal;
+
+@Data
+public class FlashDeliveryAddressRequest {
+    private String name;
+    private String phone;
+    private String address;
+    private String addressDetail;
+    private BigDecimal longitude;
+    private BigDecimal latitude;
+}

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

@@ -0,0 +1,23 @@
+package com.ruoyi.app.flashdelivery.dto;
+
+import lombok.Data;
+
+import java.math.BigDecimal;
+import java.util.Date;
+
+/** 骑手接单前可见的最小订单信息。 */
+@Data
+public class FlashDeliveryAvailableOrderView {
+    private Long id;
+    private String orderNo;
+    private String serviceType;
+    private String status;
+    private BigDecimal pickupLatitudeApprox;
+    private BigDecimal pickupLongitudeApprox;
+    private BigDecimal deliveryLatitudeApprox;
+    private BigDecimal deliveryLongitudeApprox;
+    private Integer distanceMeters;
+    private BigDecimal amount;
+    private String currency;
+    private Date createTime;
+}

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

@@ -0,0 +1,11 @@
+package com.ruoyi.app.flashdelivery.dto;
+
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+@Data
+@EqualsAndHashCode(callSuper = true)
+public class FlashDeliveryCreateRequest extends FlashDeliveryQuoteRequest {
+    private String clientRequestId;
+    private String userNote;
+}

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

@@ -0,0 +1,10 @@
+package com.ruoyi.app.flashdelivery.dto;
+
+import lombok.Data;
+
+import java.util.List;
+
+@Data
+public class FlashDeliveryHomeView {
+    private List<FlashDeliveryServiceView> services;
+}

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

@@ -0,0 +1,15 @@
+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 FlashDeliveryOrderDetailView {
+    private FlashDeliveryOrder order;
+    private List<FlashDeliveryOrderImage> images;
+    private List<FlashDeliveryOrderLog> logs;
+}

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

@@ -0,0 +1,13 @@
+package com.ruoyi.app.flashdelivery.dto;
+
+import lombok.Data;
+
+import java.math.BigDecimal;
+
+@Data
+public class FlashDeliveryPricingUpdateRequest {
+    private BigDecimal startPrice;
+    private Integer startDistanceMeters;
+    private BigDecimal perKmPrice;
+    private Boolean enabled;
+}

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

@@ -0,0 +1,10 @@
+package com.ruoyi.app.flashdelivery.dto;
+
+import lombok.Data;
+
+import java.util.List;
+
+@Data
+public class FlashDeliveryProofRequest {
+    private List<String> imageUrls;
+}

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

@@ -0,0 +1,10 @@
+package com.ruoyi.app.flashdelivery.dto;
+
+import lombok.Data;
+
+@Data
+public class FlashDeliveryQuoteRequest {
+    private String serviceType;
+    private FlashDeliveryAddressRequest pickup;
+    private FlashDeliveryAddressRequest delivery;
+}

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

@@ -0,0 +1,16 @@
+package com.ruoyi.app.flashdelivery.dto;
+
+import lombok.Data;
+
+import java.math.BigDecimal;
+
+@Data
+public class FlashDeliveryQuoteView {
+    private String serviceType;
+    private Integer distanceMeters;
+    private String distanceSource;
+    private Integer estimatedDurationSeconds;
+    private BigDecimal amount;
+    private String currency;
+    private Integer pricingVersion;
+}

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

@@ -0,0 +1,8 @@
+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/FlashDeliveryServiceView.java

@@ -0,0 +1,16 @@
+package com.ruoyi.app.flashdelivery.dto;
+
+import lombok.Data;
+
+import java.math.BigDecimal;
+
+/** 用户首页可见的服务和计价摘要。 */
+@Data
+public class FlashDeliveryServiceView {
+    private String serviceType;
+    private BigDecimal startPrice;
+    private Integer startDistanceMeters;
+    private BigDecimal perKmPrice;
+    private Integer pricingVersion;
+    private String currency;
+}

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

@@ -0,0 +1,20 @@
+package com.ruoyi.app.flashdelivery.dto;
+
+import lombok.Data;
+import java.math.BigDecimal;
+
+@Data
+public class InfoAddressRequest {
+    private Long id;
+    private String name;
+    private String phone;
+    private String address;
+    private String addressDetail;
+    private BigDecimal longitude;
+    private BigDecimal latitude;
+    private String country;
+    private String province;
+    private String city;
+    private String area;
+    private String annexes;
+}

+ 45 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/route/FlashDeliveryRouteService.java

@@ -0,0 +1,45 @@
+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;
+    private final RouteDistanceProvider provider;
+
+    public FlashDeliveryRouteService(RouteDistanceProvider provider) {
+        this.provider = provider;
+    }
+
+    public RouteDistance calculate(GeoPoint origin, GeoPoint destination) {
+        validate(origin);
+        validate(destination);
+        try {
+            RouteDistance route = provider.route(origin.latitude(), origin.longitude(),
+                    destination.latitude(), destination.longitude());
+            if (route != null && route.distanceMeters() > 0) {
+                return route;
+            }
+        } catch (RuntimeException ignored) {
+            // External route failures intentionally fall back to straight-line distance.
+        }
+        double lat1 = Math.toRadians(origin.latitude().doubleValue());
+        double lat2 = Math.toRadians(destination.latitude().doubleValue());
+        double deltaLat = lat2 - lat1;
+        double deltaLon = Math.toRadians(destination.longitude().doubleValue()
+                - origin.longitude().doubleValue());
+        double a = Math.sin(deltaLat / 2) * Math.sin(deltaLat / 2)
+                + Math.cos(lat1) * Math.cos(lat2)
+                * Math.sin(deltaLon / 2) * Math.sin(deltaLon / 2);
+        int meters = (int) Math.round(EARTH_RADIUS_METERS * 2D * Math.atan2(Math.sqrt(a), Math.sqrt(1D - a)));
+        return new RouteDistance(Math.max(1, meters), null, "STRAIGHT_LINE");
+    }
+
+    private void validate(GeoPoint point) {
+        if (point == null || point.latitude() == null || point.longitude() == null
+                || point.latitude().doubleValue() < -90D || point.latitude().doubleValue() > 90D
+                || point.longitude().doubleValue() < -180D || point.longitude().doubleValue() > 180D) {
+            throw new IllegalArgumentException("invalid coordinates");
+        }
+    }
+}

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

@@ -0,0 +1,5 @@
+package com.ruoyi.app.flashdelivery.route;
+
+import java.math.BigDecimal;
+
+public record GeoPoint(BigDecimal latitude, BigDecimal longitude) { }

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

@@ -0,0 +1,88 @@
+package com.ruoyi.app.flashdelivery.route;
+
+import com.alibaba.fastjson2.JSON;
+import com.alibaba.fastjson2.JSONArray;
+import com.alibaba.fastjson2.JSONObject;
+import com.ruoyi.common.core.domain.entity.SysDictData;
+import com.ruoyi.common.utils.DictUtils;
+import org.apache.http.client.config.RequestConfig;
+import org.apache.http.client.methods.CloseableHttpResponse;
+import org.apache.http.client.methods.HttpPost;
+import org.apache.http.entity.ContentType;
+import org.apache.http.entity.StringEntity;
+import org.apache.http.impl.client.CloseableHttpClient;
+import org.apache.http.impl.client.HttpClients;
+import org.apache.http.util.EntityUtils;
+import org.springframework.stereotype.Component;
+
+import java.math.BigDecimal;
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+
+@Component
+public class GoogleRoutesDistanceProvider implements RouteDistanceProvider {
+    private static final String ENDPOINT = "https://routes.googleapis.com/directions/v2:computeRoutes";
+    private static final RequestConfig TIMEOUTS = RequestConfig.custom()
+            .setConnectTimeout(2500).setConnectionRequestTimeout(2500).setSocketTimeout(3500).build();
+
+    @Override
+    public RouteDistance route(BigDecimal originLatitude, BigDecimal originLongitude,
+                               BigDecimal destinationLatitude, BigDecimal destinationLongitude) {
+        List<SysDictData> keys = DictUtils.getDictCache("sys_google_routes_key");
+        if (keys == null || keys.isEmpty() || keys.get(0).getDictValue() == null
+                || keys.get(0).getDictValue().isBlank()) {
+            throw new IllegalStateException("google routes key unavailable");
+        }
+        JSONObject body = new JSONObject();
+        body.put("origin", waypoint(originLatitude, originLongitude));
+        body.put("destination", waypoint(destinationLatitude, destinationLongitude));
+        body.put("travelMode", "DRIVE");
+        body.put("routingPreference", "TRAFFIC_AWARE");
+        body.put("computeAlternativeRoutes", false);
+        body.put("units", "METRIC");
+        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();
+             CloseableHttpResponse response = client.execute(request)) {
+            if (response.getStatusLine().getStatusCode() < 200 || response.getStatusLine().getStatusCode() >= 300) {
+                throw new IllegalStateException("google routes non-success response");
+            }
+            JSONObject json = JSON.parseObject(EntityUtils.toString(response.getEntity(), StandardCharsets.UTF_8));
+            JSONArray routes = json.getJSONArray("routes");
+            if (routes == null || routes.isEmpty()) {
+                throw new IllegalStateException("google routes empty response");
+            }
+            JSONObject first = routes.getJSONObject(0);
+            int meters = first.getIntValue("distanceMeters");
+            if (meters <= 0) {
+                throw new IllegalStateException("google routes invalid distance");
+            }
+            return new RouteDistance(meters, parseDuration(first.getString("duration")), "ROUTE");
+        } catch (Exception exception) {
+            throw new IllegalStateException("google routes request failed", exception);
+        }
+    }
+
+    private JSONObject waypoint(BigDecimal latitude, BigDecimal longitude) {
+        JSONObject latLng = new JSONObject();
+        latLng.put("latitude", latitude);
+        latLng.put("longitude", longitude);
+        JSONObject location = new JSONObject();
+        location.put("latLng", latLng);
+        JSONObject waypoint = new JSONObject();
+        waypoint.put("location", location);
+        return waypoint;
+    }
+
+    private Integer parseDuration(String value) {
+        if (value == null || !value.endsWith("s")) return null;
+        try {
+            return new BigDecimal(value.substring(0, value.length() - 1)).intValue();
+        } catch (NumberFormatException exception) {
+            return null;
+        }
+    }
+}

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

@@ -0,0 +1,3 @@
+package com.ruoyi.app.flashdelivery.route;
+
+public record RouteDistance(int distanceMeters, Integer durationSeconds, String source) { }

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

@@ -0,0 +1,9 @@
+package com.ruoyi.app.flashdelivery.route;
+
+import java.math.BigDecimal;
+
+@FunctionalInterface
+public interface RouteDistanceProvider {
+    RouteDistance route(BigDecimal originLatitude, BigDecimal originLongitude,
+                        BigDecimal destinationLatitude, BigDecimal destinationLongitude);
+}

+ 488 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryApplicationService.java

@@ -0,0 +1,488 @@
+package com.ruoyi.app.flashdelivery.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
+import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.ruoyi.app.flashdelivery.dto.*;
+import com.ruoyi.app.flashdelivery.route.FlashDeliveryRouteService;
+import com.ruoyi.app.flashdelivery.route.GeoPoint;
+import com.ruoyi.app.flashdelivery.route.RouteDistance;
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.common.utils.MessageUtils;
+import com.ruoyi.common.utils.StringUtils;
+import com.ruoyi.system.domain.InfoUser;
+import com.ruoyi.system.domain.flash.*;
+import com.ruoyi.system.mapper.InfoUserMapper;
+import com.ruoyi.system.mapper.flash.*;
+import org.springframework.dao.DataIntegrityViolationException;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.net.URI;
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.util.*;
+
+import static com.ruoyi.system.domain.flash.FlashDeliveryStatus.*;
+
+@Service
+public class FlashDeliveryApplicationService {
+    private static final Set<String> SERVICE_TYPES = Set.of("HELP_SEND", "HELP_PICKUP", "URGENT");
+    private final FlashDeliveryOrderMapper orderMapper;
+    private final FlashDeliveryPricingMapper pricingMapper;
+    private final FlashDeliveryOrderImageMapper imageMapper;
+    private final FlashDeliveryOrderLogMapper logMapper;
+    private final InfoUserMapper userMapper;
+    private final FlashDeliveryRouteService routeService;
+    private final FlashDeliveryPricingCalculator pricingCalculator;
+
+    public FlashDeliveryApplicationService(FlashDeliveryOrderMapper orderMapper,
+                                           FlashDeliveryPricingMapper pricingMapper,
+                                           FlashDeliveryOrderImageMapper imageMapper,
+                                           FlashDeliveryOrderLogMapper logMapper,
+                                           InfoUserMapper userMapper,
+                                           FlashDeliveryRouteService routeService,
+                                           FlashDeliveryPricingCalculator pricingCalculator) {
+        this.orderMapper = orderMapper;
+        this.pricingMapper = pricingMapper;
+        this.imageMapper = imageMapper;
+        this.logMapper = logMapper;
+        this.userMapper = userMapper;
+        this.routeService = routeService;
+        this.pricingCalculator = pricingCalculator;
+    }
+
+    public FlashDeliveryHomeView home() {
+        FlashDeliveryHomeView view = new FlashDeliveryHomeView();
+        view.setServices(pricingMapper.selectList(new QueryWrapper<FlashDeliveryPricing>()
+                .eq("enabled", true).orderByAsc("id")).stream().map(this::serviceView).toList());
+        return view;
+    }
+
+    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());
+        view.setCurrency("TWD");
+        view.setPricingVersion(context.pricing().getConfigVersion());
+        return view;
+    }
+
+    @Transactional
+    public FlashDeliveryOrderDetailView create(Long userId, FlashDeliveryCreateRequest request) {
+        requireUserId(userId);
+        if (request == null || !hasText(request.getClientRequestId())
+                || request.getClientRequestId().trim().length() > 64) {
+            throw fail("flash.delivery.client.request.id.invalid");
+        }
+        String requestId = request.getClientRequestId().trim();
+        FlashDeliveryOrder existing = orderMapper.selectByUserRequestId(userId, requestId);
+        if (existing != null) return detail(existing, false);
+        if (request.getUserNote() != null && request.getUserNote().length() > 500) {
+            throw fail("flash.delivery.note.too.long");
+        }
+        QuoteContext quote = calculateQuote(request);
+        Date now = new Date();
+        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);
+        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.setCurrency("TWD");
+        order.setPricingVersion(quote.pricing().getConfigVersion());
+        order.setStartPrice(quote.pricing().getStartPrice());
+        order.setStartDistanceMeters(quote.pricing().getStartDistanceMeters());
+        order.setPerKmPrice(quote.pricing().getPerKmPrice());
+        order.setUserNote(trimToNull(request.getUserNote()));
+        order.setVersion(0);
+        order.setCreateTime(now);
+        order.setUpdateTime(now);
+        try {
+            orderMapper.insert(order);
+        } catch (DataIntegrityViolationException duplicate) {
+            FlashDeliveryOrder concurrent = orderMapper.selectByUserRequestId(userId, requestId);
+            if (concurrent != null) return detail(concurrent, false);
+            throw duplicate;
+        }
+        writeLog(order.getId(), null, WAITING_ACCEPTANCE, "USER", userId, null, now);
+        return detail(order, false);
+    }
+
+    public IPage<FlashDeliveryOrder> userOrders(Long userId, int pageNum, int pageSize,
+                                                String status, String serviceType) {
+        QueryWrapper<FlashDeliveryOrder> query = new QueryWrapper<FlashDeliveryOrder>()
+                .eq("user_id", userId).orderByDesc("create_time");
+        optionalEq(query, "status", status);
+        validateOptionalServiceType(serviceType);
+        optionalEq(query, "service_type", serviceType);
+        return orderMapper.selectPage(page(pageNum, pageSize), query);
+    }
+
+    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);
+    }
+
+    @Transactional
+    public void userCancel(Long userId, Long orderId, FlashDeliveryReasonRequest request) {
+        FlashDeliveryOrder order = requireOrder(orderId);
+        if (!Objects.equals(userId, order.getUserId())) throw fail("flash.delivery.order.not.found");
+        if (!FlashDeliveryStateMachine.canUserCancel(order.getStatus())) throw fail("flash.delivery.cancel.not.allowed");
+        String reason = requiredReason(request);
+        Date now = new Date();
+        if (orderMapper.cancel(orderId, order.getStatus(), "USER", userId, reason, now) != 1) {
+            throw fail("flash.delivery.state.changed");
+        }
+        writeLog(orderId, order.getStatus(), CANCELLED, "USER", userId, reason, now);
+    }
+
+    @Transactional
+    public void confirmReceipt(Long userId, Long orderId) {
+        FlashDeliveryOrder order = requireOrder(orderId);
+        if (!Objects.equals(userId, order.getUserId())) throw fail("flash.delivery.order.not.found");
+        Date now = new Date();
+        if (orderMapper.transitionByUser(orderId, userId, DELIVERED, COMPLETED, now) != 1) {
+            throw fail("flash.delivery.complete.not.allowed");
+        }
+        writeLog(orderId, DELIVERED, COMPLETED, "USER", userId, null, now);
+    }
+
+    public IPage<FlashDeliveryAvailableOrderView> available(Long riderId, int pageNum, int pageSize,
+                                                            String serviceType) {
+        requireRider(riderId);
+        validateOptionalServiceType(serviceType);
+        QueryWrapper<FlashDeliveryOrder> query = new QueryWrapper<FlashDeliveryOrder>()
+                .eq("status", WAITING_ACCEPTANCE).isNull("rider_id");
+        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());
+        result.setRecords(orders.getRecords().stream().map(this::availableView).toList());
+        return result;
+    }
+
+    public IPage<FlashDeliveryOrder> riderOrders(Long riderId, int pageNum, int pageSize, String status) {
+        requireRider(riderId);
+        QueryWrapper<FlashDeliveryOrder> query = new QueryWrapper<FlashDeliveryOrder>()
+                .eq("rider_id", riderId).orderByDesc("create_time");
+        optionalEq(query, "status", status);
+        return orderMapper.selectPage(page(pageNum, pageSize), query);
+    }
+
+    public Object riderDetail(Long riderId, Long orderId) {
+        requireRider(riderId);
+        FlashDeliveryOrder order = requireOrder(orderId);
+        if (WAITING_ACCEPTANCE.equals(order.getStatus()) && order.getRiderId() == null) {
+            return availableView(order);
+        }
+        if (!Objects.equals(riderId, order.getRiderId())) throw fail("flash.delivery.order.not.found");
+        return detail(order, false);
+    }
+
+    @Transactional
+    public FlashDeliveryOrderDetailView accept(Long riderId, Long orderId) {
+        requireRider(riderId);
+        Date now = new Date();
+        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);
+    }
+
+    @Transactional
+    public void pickup(Long riderId, Long orderId, FlashDeliveryProofRequest request) {
+        transitionWithProof(riderId, orderId, request, ACCEPTED, PICKED_UP, "PICKUP", "picked_up_at");
+    }
+
+    @Transactional
+    public void deliver(Long riderId, Long orderId, FlashDeliveryProofRequest request) {
+        transitionWithProof(riderId, orderId, request, PICKED_UP, DELIVERED, "DELIVERY", "delivered_at");
+    }
+
+    public List<FlashDeliveryPricing> adminPricing() {
+        return pricingMapper.selectList(new QueryWrapper<FlashDeliveryPricing>().orderByAsc("id"));
+    }
+
+    @Transactional
+    public FlashDeliveryPricing updatePricing(Long adminId, String serviceType,
+                                               FlashDeliveryPricingUpdateRequest request) {
+        validateServiceType(serviceType);
+        if (request == null || request.getStartPrice() == null || request.getStartPrice().signum() <= 0
+                || request.getStartDistanceMeters() == null || request.getStartDistanceMeters() <= 0
+                || request.getPerKmPrice() == null || request.getPerKmPrice().signum() <= 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 perKmPrice = request.getPerKmPrice().setScale(2, RoundingMode.HALF_UP);
+        UpdateWrapper<FlashDeliveryPricing> update = new UpdateWrapper<FlashDeliveryPricing>()
+                .eq("id", pricing.getId()).eq("config_version", expectedVersion)
+                .set("start_price", startPrice)
+                .set("start_distance_meters", request.getStartDistanceMeters())
+                .set("per_km_price", perKmPrice)
+                .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.setStartDistanceMeters(request.getStartDistanceMeters());
+        pricing.setPerKmPrice(perKmPrice);
+        pricing.setEnabled(request.getEnabled());
+        pricing.setConfigVersion(expectedVersion + 1);
+        pricing.setUpdatedBy(adminId);
+        pricing.setUpdateTime(now);
+        return pricing;
+    }
+
+    public IPage<FlashDeliveryOrder> adminOrders(int pageNum, int pageSize, String status,
+                                                 String serviceType, String orderNo,
+                                                 Long userId, Long riderId) {
+        validateOptionalServiceType(serviceType);
+        QueryWrapper<FlashDeliveryOrder> query = new QueryWrapper<FlashDeliveryOrder>().orderByDesc("create_time");
+        optionalEq(query, "status", status);
+        optionalEq(query, "service_type", serviceType);
+        if (hasText(orderNo)) query.like("order_no", orderNo.trim());
+        if (userId != null) query.eq("user_id", userId);
+        if (riderId != null) query.eq("rider_id", riderId);
+        return orderMapper.selectPage(page(pageNum, pageSize), query);
+    }
+
+    public FlashDeliveryOrderDetailView adminDetail(Long orderId) {
+        return detail(requireOrder(orderId), false);
+    }
+
+    @Transactional
+    public void adminCancel(Long adminId, Long orderId, FlashDeliveryReasonRequest request) {
+        FlashDeliveryOrder order = requireOrder(orderId);
+        if (!FlashDeliveryStateMachine.canAdminCancel(order.getStatus())) throw fail("flash.delivery.cancel.not.allowed");
+        String reason = requiredReason(request);
+        Date now = new Date();
+        if (orderMapper.cancel(orderId, order.getStatus(), "ADMIN", adminId, reason, now) != 1) {
+            throw fail("flash.delivery.state.changed");
+        }
+        writeLog(orderId, order.getStatus(), CANCELLED, "ADMIN", adminId, reason, now);
+    }
+
+    @Transactional
+    public boolean complete(Long operatorId, String operatorType, Long orderId) {
+        Date now = new Date();
+        if (orderMapper.transitionByStatus(orderId, DELIVERED, COMPLETED, "completed_at", now) != 1) return false;
+        writeLog(orderId, DELIVERED, COMPLETED, operatorType, operatorId, null, now);
+        return true;
+    }
+
+    public List<FlashDeliveryOrder> autoCompleteCandidates(Date deadline, int limit) {
+        return orderMapper.selectAutoCompletable(deadline, Math.min(Math.max(limit, 1), 500));
+    }
+
+    private QuoteContext calculateQuote(FlashDeliveryQuoteRequest request) {
+        if (request == null) throw fail("flash.delivery.request.required");
+        validateServiceType(request.getServiceType());
+        validateAddress(request.getPickup());
+        validateAddress(request.getDelivery());
+        if (sameAddressText(request.getPickup(), request.getDelivery())
+                || request.getPickup().getLatitude().compareTo(request.getDelivery().getLatitude()) == 0
+                && request.getPickup().getLongitude().compareTo(request.getDelivery().getLongitude()) == 0) {
+            throw fail("flash.delivery.address.same");
+        }
+        FlashDeliveryPricing pricing = findPricing(request.getServiceType(), true);
+        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()));
+    }
+
+    private FlashDeliveryPricing findPricing(String serviceType, boolean enabledOnly) {
+        QueryWrapper<FlashDeliveryPricing> query = new QueryWrapper<FlashDeliveryPricing>()
+                .eq("service_type", serviceType);
+        if (enabledOnly) query.eq("enabled", true);
+        FlashDeliveryPricing pricing = pricingMapper.selectOne(query);
+        if (enabledOnly && pricing == null) throw fail("flash.delivery.service.unavailable");
+        return pricing;
+    }
+
+    private void transitionWithProof(Long riderId, Long orderId, FlashDeliveryProofRequest request,
+                                     String expected, String next, String proofType, String timeColumn) {
+        List<String> urls = validateProof(request);
+        requireRider(riderId);
+        Date now = new Date();
+        if (orderMapper.transitionByRider(orderId, riderId, expected, next, timeColumn, now) != 1) {
+            throw fail("flash.delivery.transition.not.allowed");
+        }
+        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.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()) {
+            throw fail("flash.delivery.proof.required");
+        }
+        if (request.getImageUrls().size() > 9) throw fail("flash.delivery.proof.too.many");
+        List<String> urls = new ArrayList<>();
+        for (String value : request.getImageUrls()) {
+            try {
+                String url = value == null ? null : value.trim();
+                URI uri = url == null ? null : URI.create(url);
+                if (uri == null || url.length() > 1000 || uri.getHost() == null
+                        || !("http".equalsIgnoreCase(uri.getScheme()) || "https".equalsIgnoreCase(uri.getScheme()))) {
+                    throw new IllegalArgumentException();
+                }
+                urls.add(url);
+            } catch (IllegalArgumentException exception) {
+                throw fail("flash.delivery.proof.url.invalid");
+            }
+        }
+        return urls;
+    }
+
+    private FlashDeliveryOrderDetailView detail(FlashDeliveryOrder order, boolean hideEvidence) {
+        FlashDeliveryOrderDetailView view = new FlashDeliveryOrderDetailView();
+        view.setOrder(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")));
+            view.setLogs(logMapper.selectList(new QueryWrapper<FlashDeliveryOrderLog>()
+                    .eq("order_id", order.getId()).orderByAsc("create_time", "id")));
+        }
+        return view;
+    }
+
+    private FlashDeliveryAvailableOrderView availableView(FlashDeliveryOrder order) {
+        FlashDeliveryAvailableOrderView view = new FlashDeliveryAvailableOrderView();
+        view.setId(order.getId());
+        view.setOrderNo(order.getOrderNo());
+        view.setServiceType(order.getServiceType());
+        view.setStatus(order.getStatus());
+        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.setAmount(order.getAmount());
+        view.setCurrency(order.getCurrency());
+        view.setCreateTime(order.getCreateTime());
+        return view;
+    }
+
+    private FlashDeliveryServiceView serviceView(FlashDeliveryPricing pricing) {
+        FlashDeliveryServiceView view = new FlashDeliveryServiceView();
+        view.setServiceType(pricing.getServiceType());
+        view.setStartPrice(pricing.getStartPrice());
+        view.setStartDistanceMeters(pricing.getStartDistanceMeters());
+        view.setPerKmPrice(pricing.getPerKmPrice());
+        view.setPricingVersion(pricing.getConfigVersion());
+        view.setCurrency("TWD");
+        return view;
+    }
+
+    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.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.setDeliveryLongitude(source.getLongitude()); target.setDeliveryLatitude(source.getLatitude());
+        }
+    }
+
+    private void validateAddress(FlashDeliveryAddressRequest address) {
+        if (address == null || !hasText(address.getName()) || !hasText(address.getPhone())
+                || !hasText(address.getAddress()) || address.getLatitude() == null || address.getLongitude() == null
+                || address.getName().trim().length() > 64 || address.getPhone().trim().length() > 32
+                || address.getAddress().trim().length() > 255
+                || (address.getAddressDetail() != null && address.getAddressDetail().trim().length() > 255)
+                || address.getLatitude().compareTo(BigDecimal.valueOf(-90)) < 0
+                || address.getLatitude().compareTo(BigDecimal.valueOf(90)) > 0
+                || address.getLongitude().compareTo(BigDecimal.valueOf(-180)) < 0
+                || address.getLongitude().compareTo(BigDecimal.valueOf(180)) > 0) {
+            throw fail("flash.delivery.address.invalid");
+        }
+    }
+
+    private void requireRider(Long riderId) {
+        InfoUser rider = riderId == null ? null : userMapper.selectInfoUserByUserId(riderId);
+        if (rider == null || !"2".equals(rider.getUserType())) throw fail("flash.delivery.rider.required");
+    }
+
+    private FlashDeliveryOrder requireOrder(Long orderId) {
+        FlashDeliveryOrder order = orderId == null ? null : orderMapper.selectById(orderId);
+        if (order == null) throw fail("flash.delivery.order.not.found");
+        return order;
+    }
+
+    private void writeLog(Long orderId, String from, String to, String operatorType,
+                          Long operatorId, String reason, Date now) {
+        FlashDeliveryOrderLog log = new FlashDeliveryOrderLog();
+        log.setOrderId(orderId); log.setFromStatus(from); log.setToStatus(to);
+        log.setOperatorType(operatorType); log.setOperatorId(operatorId); log.setReason(reason); log.setCreateTime(now);
+        logMapper.insert(log);
+    }
+
+    private String requiredReason(FlashDeliveryReasonRequest request) {
+        if (request == null || !hasText(request.getReason()) || request.getReason().trim().length() > 500) {
+            throw fail("flash.delivery.cancel.reason.required");
+        }
+        return request.getReason().trim();
+    }
+
+    private void validateServiceType(String value) {
+        if (!hasText(value) || !SERVICE_TYPES.contains(value)) throw fail("flash.delivery.service.type.invalid");
+    }
+
+    private void validateOptionalServiceType(String value) {
+        if (hasText(value)) validateServiceType(value);
+    }
+
+    private void requireUserId(Long userId) {
+        if (userId == null || userId <= 0) throw fail("flash.delivery.auth.required");
+    }
+
+    private Page<FlashDeliveryOrder> page(int pageNum, int pageSize) {
+        return new Page<>(Math.max(1, pageNum), Math.min(Math.max(1, pageSize), 100));
+    }
+
+    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 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()))
+                && normalized(left.getAddressDetail()).equals(normalized(right.getAddressDetail()));
+    }
+    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) { }
+}

+ 23 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryPricingCalculator.java

@@ -0,0 +1,23 @@
+package com.ruoyi.app.flashdelivery.service;
+
+import com.ruoyi.system.domain.flash.FlashDeliveryPricing;
+
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import org.springframework.stereotype.Component;
+
+@Component
+public class FlashDeliveryPricingCalculator {
+    public BigDecimal calculate(FlashDeliveryPricing pricing, int distanceMeters) {
+        if (pricing == null || !Boolean.TRUE.equals(pricing.getEnabled()) || distanceMeters <= 0
+                || pricing.getStartPrice() == null || pricing.getStartPrice().signum() <= 0
+                || pricing.getStartDistanceMeters() == null || pricing.getStartDistanceMeters() <= 0
+                || pricing.getPerKmPrice() == null || pricing.getPerKmPrice().signum() <= 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);
+    }
+}

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

@@ -0,0 +1,23 @@
+package com.ruoyi.app.flashdelivery.service;
+
+import static com.ruoyi.system.domain.flash.FlashDeliveryStatus.*;
+
+public final class FlashDeliveryStateMachine {
+    private FlashDeliveryStateMachine() { }
+
+    public static boolean canTransition(String from, String to) {
+        return WAITING_ACCEPTANCE.equals(from) && ACCEPTED.equals(to)
+                || ACCEPTED.equals(from) && PICKED_UP.equals(to)
+                || PICKED_UP.equals(from) && DELIVERED.equals(to)
+                || DELIVERED.equals(from) && COMPLETED.equals(to)
+                || canAdminCancel(from) && CANCELLED.equals(to);
+    }
+
+    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);
+    }
+}

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

@@ -0,0 +1,107 @@
+package com.ruoyi.app.flashdelivery.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
+import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
+import com.ruoyi.app.flashdelivery.dto.InfoAddressRequest;
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.common.utils.MessageUtils;
+import com.ruoyi.system.domain.InfoAddress;
+import com.ruoyi.system.service.IInfoAddressService;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.math.BigDecimal;
+import java.util.Date;
+import java.util.List;
+import java.util.Objects;
+
+@Service
+public class InfoAddressBookService {
+    private final IInfoAddressService addressService;
+
+    public InfoAddressBookService(IInfoAddressService addressService) { this.addressService = addressService; }
+
+    public List<InfoAddress> list(Long userId, String keyword) {
+        QueryWrapper<InfoAddress> query = new QueryWrapper<InfoAddress>().eq("user_id", userId);
+        if (keyword != null && !keyword.trim().isEmpty()) {
+            String value = keyword.trim();
+            query.and(item -> item.like("name", value).or().like("phone", value)
+                    .or().like("address", value).or().like("address_detail", value));
+        }
+        query.orderByDesc("is_top").orderByDesc("top_time").orderByDesc("id");
+        return addressService.list(query);
+    }
+
+    public InfoAddress detail(Long userId, Long id) {
+        InfoAddress address = id == null ? null : addressService.getById(id);
+        if (address == null || !Objects.equals(userId, address.getUserId())) throw fail("address.access.denied");
+        return address;
+    }
+
+    @Transactional
+    public InfoAddress save(Long userId, InfoAddressRequest request) {
+        validate(request);
+        InfoAddress address;
+        if (request.getId() == null) {
+            address = new InfoAddress();
+            address.setUserId(userId);
+            copy(request, address);
+            address.setIsTop(false);
+            addressService.save(address);
+        } else {
+            address = detail(userId, request.getId());
+            copy(request, address);
+            UpdateWrapper<InfoAddress> update = new UpdateWrapper<InfoAddress>()
+                    .eq("id", request.getId()).eq("user_id", userId)
+                    .set("name", address.getName()).set("phone", address.getPhone())
+                    .set("address", address.getAddress()).set("address_detail", address.getAddressDetail())
+                    .set("longitude", address.getLongitude()).set("latitude", address.getLatitude())
+                    .set("country", address.getCountry()).set("province", address.getProvince())
+                    .set("city", address.getCity()).set("area", address.getArea())
+                    .set("annexes", address.getAnnexes());
+            if (!addressService.update(update)) throw fail("address.access.denied");
+        }
+        return address;
+    }
+
+    public void delete(Long userId, Long id) {
+        if (!addressService.remove(new QueryWrapper<InfoAddress>().eq("id", id).eq("user_id", userId))) {
+            throw fail("address.access.denied");
+        }
+    }
+
+    public void top(Long userId, Long id) {
+        detail(userId, id);
+        if (!addressService.update(new UpdateWrapper<InfoAddress>().eq("id", id).eq("user_id", userId)
+                .set("is_top", true).set("top_time", new Date()))) throw fail("address.access.denied");
+    }
+
+    private void validate(InfoAddressRequest request) {
+        if (request == null || blank(request.getName()) || blank(request.getPhone()) || blank(request.getAddress())
+                || tooLong(request.getName(), 64) || tooLong(request.getPhone(), 32)
+                || tooLong(request.getAddress(), 255) || tooLong(request.getAddressDetail(), 255)
+                || tooLong(request.getCountry(), 64) || tooLong(request.getProvince(), 64)
+                || tooLong(request.getCity(), 64) || tooLong(request.getArea(), 64)
+                || tooLong(request.getAnnexes(), 1000)
+                || invalid(request.getLatitude(), -90, 90) || invalid(request.getLongitude(), -180, 180)) {
+            throw fail("address.data.invalid");
+        }
+    }
+
+    private boolean invalid(BigDecimal value, int min, int max) {
+        return value == null || value.compareTo(BigDecimal.valueOf(min)) < 0
+                || value.compareTo(BigDecimal.valueOf(max)) > 0;
+    }
+
+    private boolean blank(String value) { return value == null || value.trim().isEmpty(); }
+    private boolean tooLong(String value, int maximum) { return value != null && value.trim().length() > maximum; }
+    private String trim(String value) { return blank(value) ? null : value.trim(); }
+    private void copy(InfoAddressRequest from, InfoAddress to) {
+        to.setName(from.getName().trim()); to.setPhone(from.getPhone().trim());
+        to.setAddress(from.getAddress().trim()); to.setAddressDetail(trim(from.getAddressDetail()));
+        to.setLongitude(from.getLongitude()); to.setLatitude(from.getLatitude());
+        to.setCountry(trim(from.getCountry())); to.setProvince(trim(from.getProvince()));
+        to.setCity(trim(from.getCity())); to.setArea(trim(from.getArea())); to.setAnnexes(trim(from.getAnnexes()));
+    }
+    private ServiceException fail(String key) { return new ServiceException(MessageUtils.message(key)); }
+}

+ 31 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/task/FlashDeliveryAutoCompleteTask.java

@@ -0,0 +1,31 @@
+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;
+
+import java.time.Instant;
+import java.time.temporal.ChronoUnit;
+import java.util.Date;
+
+@Component
+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());
+            } catch (RuntimeException exception) {
+                log.error("Flash delivery auto-complete failed, orderId={}", order.getId(), exception);
+            }
+        }
+    }
+}

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

@@ -1,187 +1,125 @@
 package com.ruoyi.app.order;
 
-import java.math.BigDecimal;
-import java.util.List;
-
-import com.ruoyi.common.core.domain.entity.SysDictData;
-import com.ruoyi.common.utils.DictUtils;
-import jakarta.servlet.http.HttpServletResponse;
-
-import com.alibaba.fastjson.JSONObject;
-import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
+import com.ruoyi.app.flashdelivery.dto.InfoAddressRequest;
+import com.ruoyi.app.flashdelivery.service.InfoAddressBookService;
 import com.ruoyi.common.annotation.Anonymous;
-import com.ruoyi.system.domain.PosOrder;
-import com.ruoyi.system.mapper.InfoAddressMapper;
-import com.ruoyi.system.utils.Auth;
-import com.ruoyi.system.utils.JwtUtil;
-import org.springframework.security.access.prepost.PreAuthorize;
-import org.springframework.beans.factory.annotation.Autowired;
-import org.springframework.web.bind.annotation.*;
 import com.ruoyi.common.annotation.Log;
 import com.ruoyi.common.core.controller.BaseController;
 import com.ruoyi.common.core.domain.AjaxResult;
+import com.ruoyi.common.core.domain.entity.SysDictData;
+import com.ruoyi.common.core.page.TableDataInfo;
 import com.ruoyi.common.enums.BusinessType;
-import com.ruoyi.system.domain.InfoAddress;
-import com.ruoyi.system.service.IInfoAddressService;
+import com.ruoyi.common.utils.DictUtils;
 import com.ruoyi.common.utils.MessageUtils;
 import com.ruoyi.common.utils.poi.ExcelUtil;
-import com.ruoyi.common.core.page.TableDataInfo;
+import com.ruoyi.system.domain.InfoAddress;
+import com.ruoyi.system.mapper.InfoAddressMapper;
+import com.ruoyi.system.service.IInfoAddressService;
+import com.ruoyi.system.utils.Auth;
+import com.ruoyi.system.utils.JwtUtil;
+import jakarta.servlet.http.HttpServletResponse;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.*;
+
+import java.math.BigDecimal;
+import java.util.List;
 
-/**
- * addressController
- *
- * @author ruoyi
- * @date 2023-05-24
- */
 @RestController
 @RequestMapping("/system/address")
-public class InfoAddressController extends BaseController
-{
-    @Autowired
-    private IInfoAddressService infoAddressService;
-    @Autowired
-    private InfoAddressMapper infoAddressMapper;
-
-    /**
-     * 返回谷歌地图key
-     * @return
-     */
+public class InfoAddressController extends BaseController {
+    @Autowired private IInfoAddressService infoAddressService;
+    @Autowired private InfoAddressMapper infoAddressMapper;
+    @Autowired private InfoAddressBookService addressBookService;
+
     @Anonymous
     @GetMapping("/getGoogleMapKey")
-    public AjaxResult getGoogleMapKey(){
-        List<SysDictData> dictData= DictUtils.getDictCache("sys_googlemap_key");
-        if (dictData != null && !dictData.isEmpty()) {
-            return success("成功", dictData.get(0).getDictValue());
-        }
-        return error("未找到Google地图配置");
-    }
-    //删除收货地址
-    @Anonymous
-    @GetMapping("/deleaddress")
-    public AjaxResult deleaddress(@RequestParam Long id)
-    {
-        return toAjax(infoAddressService.deleteById(id));
+    public AjaxResult getGoogleMapKey() {
+        List<SysDictData> data = DictUtils.getDictCache("sys_googlemap_key");
+        return data != null && !data.isEmpty() ? success(data.get(0).getDictValue())
+                : error(MessageUtils.message("no.map.exception"));
     }
 
-    //获取用户收货地址详请
-    @Anonymous
+    @Anonymous @Auth
     @GetMapping("/getaddressxq")
-    public AjaxResult getaddressxq(@RequestParam Long id)
-    {
-        InfoAddress address = infoAddressService.getById(id);
-        return success(MessageUtils.message("no.obtained.success"), address);
+    public AjaxResult getaddressxq(@RequestHeader String token, @RequestParam Long id) {
+        return success(MessageUtils.message("no.obtained.success"), addressBookService.detail(userId(token), id));
     }
-    //根据距离获取我的地址
-    @Anonymous
-    @Auth
+
+    @Anonymous @Auth
     @GetMapping("/getzuijinaddress")
-    public AjaxResult getzuijinaddress(@RequestHeader String token,
-                                       @RequestParam BigDecimal longitude,
-                                       @RequestParam BigDecimal latitude)
-    {
-        JwtUtil jwtUtil = new JwtUtil();
-        String id = jwtUtil.getusid(token);
-        List<InfoAddress> list = infoAddressMapper.getmyaddress(longitude,latitude, Integer.valueOf(id));
-        return success(MessageUtils.message("no.obtained.success"), list);
+    public AjaxResult getzuijinaddress(@RequestHeader String token, @RequestParam BigDecimal longitude,
+                                       @RequestParam BigDecimal latitude) {
+        return success(MessageUtils.message("no.obtained.success"),
+                infoAddressMapper.getmyaddress(longitude, latitude, userId(token).intValue()));
     }
-    //获取用户收货地址信息
-    @Anonymous
-    @Auth
+
+    @Anonymous @Auth
     @GetMapping("/getaddress")
-    public AjaxResult getaddress(@RequestHeader String token)
-    {
-        JwtUtil jwtUtil = new JwtUtil();
-        String id = jwtUtil.getusid(token);
-        QueryWrapper<InfoAddress> queryWrapper= new QueryWrapper<>();
-        queryWrapper.eq("user_id",id);
-        List<InfoAddress> list = infoAddressService.list(queryWrapper);
-        return success(MessageUtils.message("no.obtained.success"), list);
+    public AjaxResult getaddress(@RequestHeader String token,
+                                 @RequestParam(required = false) String keyword) {
+        return success(MessageUtils.message("no.obtained.success"), addressBookService.list(userId(token), keyword));
     }
 
-    //添加用户收货地址信息
-    @Anonymous
-    @Auth
+    @Anonymous @Auth
     @PostMapping("/address")
-    public AjaxResult address(@RequestHeader String token,@RequestBody InfoAddress infoAddress)
-    {
-        JwtUtil jwtUtil = new JwtUtil();
-        String id = jwtUtil.getusid(token);
-        infoAddress.setUserId(Long.valueOf(id));
-        boolean org = infoAddressService.saveOrUpdate(infoAddress);
-        if(org){
-            return success();
-        }else {
-            return error();
-        }
+    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) {
+        addressBookService.delete(userId(token), id);
+        return success();
     }
 
+    @Anonymous @Auth
+    @PostMapping("/{id}/top")
+    public AjaxResult top(@RequestHeader String token, @PathVariable Long id) {
+        addressBookService.top(userId(token), id);
+        return success();
+    }
 
-    /**
-     * 查询address列表
-     */
     @PreAuthorize("@ss.hasPermi('system:address:list')")
     @GetMapping("/list")
-    public TableDataInfo list(InfoAddress infoAddress)
-    {
+    public TableDataInfo list(InfoAddress infoAddress) {
         startPage();
-        List<InfoAddress> list = infoAddressService.selectInfoAddressList(infoAddress);
-        return getDataTable(list);
+        return getDataTable(infoAddressService.selectInfoAddressList(infoAddress));
     }
 
-    /**
-     * 导出address列表
-     */
     @PreAuthorize("@ss.hasPermi('system:address:export')")
     @Log(title = "address", businessType = BusinessType.EXPORT)
     @PostMapping("/export")
-    public void export(HttpServletResponse response, InfoAddress infoAddress)
-    {
-        List<InfoAddress> list = infoAddressService.selectInfoAddressList(infoAddress);
-        ExcelUtil<InfoAddress> util = new ExcelUtil<InfoAddress>(InfoAddress.class);
-        util.exportExcel(response, list, MessageUtils.message("no.export.excel.address"));
+    public void export(HttpServletResponse response, InfoAddress infoAddress) {
+        new ExcelUtil<>(InfoAddress.class).exportExcel(response,
+                infoAddressService.selectInfoAddressList(infoAddress), MessageUtils.message("no.export.excel.address"));
     }
 
-    /**
-     * 获取address详细信息
-     */
     @PreAuthorize("@ss.hasPermi('system:address:query')")
-    @GetMapping(value = "/{id}")
-    public AjaxResult getInfo(@PathVariable("id") Long id)
-    {
-        return success(infoAddressService.selectInfoAddressById(id));
-    }
+    @GetMapping("/{id}")
+    public AjaxResult getInfo(@PathVariable Long id) { return success(infoAddressService.selectInfoAddressById(id)); }
 
-    /**
-     * 新增address
-     */
     @PreAuthorize("@ss.hasPermi('system:address:add')")
     @Log(title = "address", businessType = BusinessType.INSERT)
     @PostMapping
-    public AjaxResult add(@RequestBody InfoAddress infoAddress)
-    {
+    public AjaxResult add(@RequestBody InfoAddress infoAddress) {
         return toAjax(infoAddressService.insertInfoAddress(infoAddress));
     }
 
-    /**
-     * 修改address
-     */
     @PreAuthorize("@ss.hasPermi('system:address:edit')")
     @Log(title = "address", businessType = BusinessType.UPDATE)
     @PutMapping
-    public AjaxResult edit(@RequestBody InfoAddress infoAddress)
-    {
+    public AjaxResult edit(@RequestBody InfoAddress infoAddress) {
         return toAjax(infoAddressService.updateInfoAddress(infoAddress));
     }
 
-    /**
-     * 删除address
-     */
     @PreAuthorize("@ss.hasPermi('system:address:remove')")
     @Log(title = "address", businessType = BusinessType.DELETE)
-	@DeleteMapping("/{ids}")
-    public AjaxResult remove(@PathVariable Long[] ids)
-    {
+    @DeleteMapping("/{ids}")
+    public AjaxResult remove(@PathVariable Long[] ids) {
         return toAjax(infoAddressService.deleteInfoAddressByIds(ids));
     }
+
+    private Long userId(String token) { return Long.valueOf(new JwtUtil().getusid(token)); }
 }

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

@@ -283,3 +283,29 @@ merchant.subaccount.access.denied=You cannot manage this subaccount
 merchant.subaccount.create.failed=Failed to create subaccount
 merchant.subaccount.update.failed=Failed to update subaccount
 merchant.subaccount.platform.managed=Subaccounts can only be managed from the dedicated merchant subaccount entry
+
+# Flash delivery
+flash.delivery.request.required=Request data is required
+flash.delivery.auth.required=Please sign in first
+flash.delivery.client.request.id.invalid=Client request ID is required and must not exceed 64 characters
+flash.delivery.note.too.long=The note must not exceed 500 characters
+flash.delivery.service.type.invalid=Unsupported flash delivery service type
+flash.delivery.service.unavailable=This flash delivery service is currently unavailable
+flash.delivery.address.invalid=Contact, address, or coordinates are invalid
+flash.delivery.address.same=Pickup and delivery locations must be different
+flash.delivery.order.not.found=Flash delivery order does not exist or is not accessible
+flash.delivery.order.already.accepted=This order has already been accepted
+flash.delivery.rider.required=Only rider accounts can perform this operation
+flash.delivery.proof.required=At least one proof image is required
+flash.delivery.proof.url.invalid=Proof image URL is invalid
+flash.delivery.proof.too.many=At most 9 proof images are allowed
+flash.delivery.transition.not.allowed=The current order status does not allow this operation
+flash.delivery.cancel.reason.required=A cancellation reason is required
+flash.delivery.cancel.not.allowed=The current order status cannot be cancelled this way
+flash.delivery.complete.not.allowed=The order is not ready to complete
+flash.delivery.state.changed=The order status changed; please refresh and retry
+flash.delivery.pricing.invalid=Pricing values must be valid positive numbers
+flash.delivery.pricing.not.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
+address.data.invalid=Contact, address, or coordinates are invalid

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

@@ -286,3 +286,29 @@ merchant.subaccount.access.denied=You cannot manage this subaccount
 merchant.subaccount.create.failed=Failed to create subaccount
 merchant.subaccount.update.failed=Failed to update subaccount
 merchant.subaccount.platform.managed=Subaccounts can only be managed from the dedicated merchant subaccount entry
+
+# Flash delivery
+flash.delivery.request.required=Request data is required
+flash.delivery.auth.required=Please sign in first
+flash.delivery.client.request.id.invalid=Client request ID is required and must not exceed 64 characters
+flash.delivery.note.too.long=The note must not exceed 500 characters
+flash.delivery.service.type.invalid=Unsupported flash delivery service type
+flash.delivery.service.unavailable=This flash delivery service is currently unavailable
+flash.delivery.address.invalid=Contact, address, or coordinates are invalid
+flash.delivery.address.same=Pickup and delivery locations must be different
+flash.delivery.order.not.found=Flash delivery order does not exist or is not accessible
+flash.delivery.order.already.accepted=This order has already been accepted
+flash.delivery.rider.required=Only rider accounts can perform this operation
+flash.delivery.proof.required=At least one proof image is required
+flash.delivery.proof.url.invalid=Proof image URL is invalid
+flash.delivery.proof.too.many=At most 9 proof images are allowed
+flash.delivery.transition.not.allowed=The current order status does not allow this operation
+flash.delivery.cancel.reason.required=A cancellation reason is required
+flash.delivery.cancel.not.allowed=The current order status cannot be cancelled this way
+flash.delivery.complete.not.allowed=The order is not ready to complete
+flash.delivery.state.changed=The order status changed; please refresh and retry
+flash.delivery.pricing.invalid=Pricing values must be valid positive numbers
+flash.delivery.pricing.not.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
+address.data.invalid=Contact, address, or coordinates are invalid

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

@@ -286,3 +286,29 @@ merchant.subaccount.access.denied=Bạn không có quyền quản lý tài kho
 merchant.subaccount.create.failed=Tạo tài khoản phụ thất bại
 merchant.subaccount.update.failed=Cập nhật tài khoản phụ thất bại
 merchant.subaccount.platform.managed=Tài khoản phụ chỉ được quản lý tại mục quản lý chuyên dụng
+
+# Giao hàng nhanh
+flash.delivery.request.required=Dữ liệu yêu cầu không được để trống
+flash.delivery.auth.required=Vui lòng đăng nhập trước
+flash.delivery.client.request.id.invalid=Mã yêu cầu không được để trống và tối đa 64 ký tự
+flash.delivery.note.too.long=Ghi chú không được vượt quá 500 ký tự
+flash.delivery.service.type.invalid=Loại dịch vụ giao hàng nhanh không được hỗ trợ
+flash.delivery.service.unavailable=Dịch vụ giao hàng nhanh hiện không khả dụng
+flash.delivery.address.invalid=Thông tin liên hệ, địa chỉ hoặc tọa độ không hợp lệ
+flash.delivery.address.same=Địa điểm lấy và giao hàng phải khác nhau
+flash.delivery.order.not.found=Đơn giao hàng không tồn tại hoặc không có quyền truy cập
+flash.delivery.order.already.accepted=Đơn hàng đã được tài xế khác nhận
+flash.delivery.rider.required=Chỉ tài khoản tài xế mới được thực hiện thao tác này
+flash.delivery.proof.required=Cần ít nhất một ảnh chứng từ
+flash.delivery.proof.url.invalid=Địa chỉ ảnh chứng từ không hợp lệ
+flash.delivery.proof.too.many=Chỉ được tải lên tối đa 9 ảnh chứng từ
+flash.delivery.transition.not.allowed=Trạng thái hiện tại không cho phép thao tác này
+flash.delivery.cancel.reason.required=Phải nhập lý do hủy
+flash.delivery.cancel.not.allowed=Trạng thái hiện tại không cho phép hủy theo cách này
+flash.delivery.complete.not.allowed=Đơn hàng chưa thể hoàn tất
+flash.delivery.state.changed=Trạng thái đơn đã thay đổi, vui lòng tải lại và thử lại
+flash.delivery.pricing.invalid=Cấu hình giá phải là số dương hợp lệ
+flash.delivery.pricing.not.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
+address.data.invalid=Thông tin liên hệ, địa chỉ hoặc tọa độ không hợp lệ

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

@@ -287,3 +287,29 @@ merchant.subaccount.access.denied=无权管理该分管账号
 merchant.subaccount.create.failed=创建分管账号失败
 merchant.subaccount.update.failed=更新分管账号失败
 merchant.subaccount.platform.managed=分管账号只能通过专用入口管理
+
+# 闪送配送
+flash.delivery.request.required=请求数据不能为空
+flash.delivery.auth.required=请先登录
+flash.delivery.client.request.id.invalid=客户端请求号不能为空且不能超过64个字符
+flash.delivery.note.too.long=备注不能超过500个字符
+flash.delivery.service.type.invalid=不支持的闪送服务类型
+flash.delivery.service.unavailable=当前闪送服务暂不可用
+flash.delivery.address.invalid=联系人、地址或经纬度信息无效
+flash.delivery.address.same=取件地址和收件地址不能相同
+flash.delivery.order.not.found=闪送订单不存在或无权访问
+flash.delivery.order.already.accepted=订单已被其他骑手接走
+flash.delivery.rider.required=只有骑手账号可以执行此操作
+flash.delivery.proof.required=请至少上传一张凭证图片
+flash.delivery.proof.url.invalid=凭证图片地址无效
+flash.delivery.proof.too.many=凭证图片最多上传9张
+flash.delivery.transition.not.allowed=当前订单状态不允许此操作
+flash.delivery.cancel.reason.required=取消原因不能为空
+flash.delivery.cancel.not.allowed=当前订单状态不允许这样取消
+flash.delivery.complete.not.allowed=当前订单尚不能完成
+flash.delivery.state.changed=订单状态已变化,请刷新后重试
+flash.delivery.pricing.invalid=计价配置必须为有效正数
+flash.delivery.pricing.not.found=计价配置不存在
+flash.delivery.pricing.changed=计价配置已发生变化,请刷新后重试
+address.access.denied=地址不存在或无权访问
+address.data.invalid=联系人、地址或经纬度信息无效

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

@@ -287,3 +287,29 @@ merchant.subaccount.access.denied=無權管理該分管帳號
 merchant.subaccount.create.failed=建立分管帳號失敗
 merchant.subaccount.update.failed=更新分管帳號失敗
 merchant.subaccount.platform.managed=分管帳號只能透過專用入口管理
+
+# 閃送配送
+flash.delivery.request.required=請求資料不能為空
+flash.delivery.auth.required=請先登入
+flash.delivery.client.request.id.invalid=客戶端請求號不能為空且不能超過64個字元
+flash.delivery.note.too.long=備註不能超過500個字元
+flash.delivery.service.type.invalid=不支援的閃送服務類型
+flash.delivery.service.unavailable=目前閃送服務暫不可用
+flash.delivery.address.invalid=聯絡人、地址或經緯度資訊無效
+flash.delivery.address.same=取件地址和收件地址不能相同
+flash.delivery.order.not.found=閃送訂單不存在或無權存取
+flash.delivery.order.already.accepted=訂單已被其他騎手接走
+flash.delivery.rider.required=只有騎手帳號可以執行此操作
+flash.delivery.proof.required=請至少上傳一張憑證圖片
+flash.delivery.proof.url.invalid=憑證圖片網址無效
+flash.delivery.proof.too.many=憑證圖片最多上傳9張
+flash.delivery.transition.not.allowed=目前訂單狀態不允許此操作
+flash.delivery.cancel.reason.required=取消原因不能為空
+flash.delivery.cancel.not.allowed=目前訂單狀態不允許這樣取消
+flash.delivery.complete.not.allowed=目前訂單尚不能完成
+flash.delivery.state.changed=訂單狀態已變更,請重新整理後再試
+flash.delivery.pricing.invalid=計價設定必須為有效正數
+flash.delivery.pricing.not.found=計價設定不存在
+flash.delivery.pricing.changed=計價設定已發生變更,請重新整理後重試
+address.access.denied=地址不存在或無權存取
+address.data.invalid=聯絡人、地址或經緯度資訊無效

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

@@ -0,0 +1,60 @@
+package com.ruoyi.app.flashdelivery.controller;
+
+import com.ruoyi.app.flashdelivery.dto.FlashDeliveryCreateRequest;
+import com.ruoyi.app.flashdelivery.dto.FlashDeliveryProofRequest;
+import org.junit.jupiter.api.Test;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestHeader;
+import com.ruoyi.common.annotation.Log;
+import com.ruoyi.system.utils.Auth;
+
+import java.lang.reflect.Method;
+
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class FlashDeliveryControllerContractTest {
+
+    @Test
+    void appControllersReadIdentityFromTokenAndUseRequestBodies() throws Exception {
+        Method create = FlashDeliveryUserController.class.getMethod("create", String.class,
+                FlashDeliveryCreateRequest.class);
+        assertNotNull(create.getParameters()[0].getAnnotation(RequestHeader.class));
+        assertNotNull(create.getParameters()[1].getAnnotation(RequestBody.class));
+
+        Method pickup = FlashDeliveryRiderController.class.getMethod("pickup", String.class,
+                Long.class, FlashDeliveryProofRequest.class);
+        assertNotNull(pickup.getParameters()[0].getAnnotation(RequestHeader.class));
+        assertNotNull(pickup.getParameters()[2].getAnnotation(RequestBody.class));
+    }
+
+    @Test
+    void everyAdminEndpointDeclaresPlatformPermission() {
+        for (Method method : FlashDeliveryAdminController.class.getDeclaredMethods()) {
+            if (method.isSynthetic()) continue;
+            assertNotNull(method.getAnnotation(PreAuthorize.class), method.getName());
+            assertTrue(method.getAnnotation(PreAuthorize.class).value().contains("flash:"));
+        }
+    }
+
+    @Test
+    void pricingAndOrderInterventionsAreOperationAudited() throws Exception {
+        assertNotNull(FlashDeliveryAdminController.class.getMethod("updatePricing", String.class,
+                com.ruoyi.app.flashdelivery.dto.FlashDeliveryPricingUpdateRequest.class).getAnnotation(Log.class));
+        assertNotNull(FlashDeliveryAdminController.class.getMethod("cancel", Long.class,
+                com.ruoyi.app.flashdelivery.dto.FlashDeliveryReasonRequest.class).getAnnotation(Log.class));
+        assertNotNull(FlashDeliveryAdminController.class.getMethod("complete", Long.class).getAnnotation(Log.class));
+    }
+
+    @Test
+    void everyAppEndpointDeclaresMethodLevelTokenAuthentication() {
+        for (Class<?> controller : new Class<?>[]{FlashDeliveryUserController.class,
+                FlashDeliveryRiderController.class}) {
+            for (Method method : controller.getDeclaredMethods()) {
+                if (!java.lang.reflect.Modifier.isPublic(method.getModifiers())) continue;
+                assertNotNull(method.getAnnotation(Auth.class), controller.getSimpleName() + "." + method.getName());
+            }
+        }
+    }
+}

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

@@ -0,0 +1,23 @@
+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;
+
+class FlashDeliveryAvailableOrderViewTest {
+    @Test
+    void publicRiderViewCannotSerializeSensitiveOrderFields() {
+        Set<String> fields = Stream.of(FlashDeliveryAvailableOrderView.class.getDeclaredFields())
+                .map(java.lang.reflect.Field::getName).collect(Collectors.toSet());
+        for (String forbidden : Set.of("userId", "clientRequestId", "pickupName", "pickupPhone",
+                "pickupAddress", "pickupAddressDetail", "pickupLatitude", "pickupLongitude",
+                "deliveryName", "deliveryPhone", "deliveryAddress", "deliveryAddressDetail",
+                "deliveryLatitude", "deliveryLongitude", "userNote")) {
+            assertFalse(fields.contains(forbidden), forbidden);
+        }
+    }
+}

+ 40 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/route/FlashDeliveryRouteServiceTest.java

@@ -0,0 +1,40 @@
+package com.ruoyi.app.flashdelivery.route;
+
+import org.junit.jupiter.api.Test;
+
+import java.math.BigDecimal;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class FlashDeliveryRouteServiceTest {
+
+    @Test
+    void keepsDrivingRouteWhenProviderSucceeds() {
+        RouteDistanceProvider provider = (a, b, c, d) -> new RouteDistance(5210, 780, "ROUTE");
+        FlashDeliveryRouteService service = new FlashDeliveryRouteService(provider);
+
+        RouteDistance result = service.calculate(point("25.0330", "121.5645"),
+                point("25.0478", "121.5319"));
+
+        assertEquals(5210, result.distanceMeters());
+        assertEquals(780, result.durationSeconds());
+        assertEquals("ROUTE", result.source());
+    }
+
+    @Test
+    void fallsBackToStraightLineWhenProviderFails() {
+        RouteDistanceProvider provider = (a, b, c, d) -> { throw new IllegalStateException("down"); };
+        FlashDeliveryRouteService service = new FlashDeliveryRouteService(provider);
+
+        RouteDistance result = service.calculate(point("25.0330", "121.5645"),
+                point("25.0478", "121.5319"));
+
+        assertTrue(result.distanceMeters() >= 3600 && result.distanceMeters() <= 3800);
+        assertEquals("STRAIGHT_LINE", result.source());
+    }
+
+    private GeoPoint point(String latitude, String longitude) {
+        return new GeoPoint(new BigDecimal(latitude), new BigDecimal(longitude));
+    }
+}

+ 194 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryApplicationServiceTest.java

@@ -0,0 +1,194 @@
+package com.ruoyi.app.flashdelivery.service;
+
+import com.baomidou.mybatisplus.core.conditions.Wrapper;
+import com.ruoyi.app.flashdelivery.dto.FlashDeliveryAddressRequest;
+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.route.FlashDeliveryRouteService;
+import com.ruoyi.app.flashdelivery.route.RouteDistance;
+import com.ruoyi.system.domain.InfoUser;
+import com.ruoyi.system.domain.flash.FlashDeliveryOrder;
+import com.ruoyi.system.domain.flash.FlashDeliveryPricing;
+import com.ruoyi.system.mapper.InfoUserMapper;
+import com.ruoyi.system.mapper.flash.FlashDeliveryOrderImageMapper;
+import com.ruoyi.system.mapper.flash.FlashDeliveryOrderLogMapper;
+import com.ruoyi.system.mapper.flash.FlashDeliveryOrderMapper;
+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.springframework.beans.factory.config.ConfigurableListableBeanFactory;
+import org.springframework.beans.factory.support.DefaultListableBeanFactory;
+import org.springframework.context.support.StaticMessageSource;
+import com.ruoyi.common.utils.spring.SpringUtils;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import java.math.BigDecimal;
+import java.util.List;
+
+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 com.ruoyi.common.exception.ServiceException;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.ArgumentMatchers.isNull;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+class FlashDeliveryApplicationServiceTest {
+    private static ConfigurableListableBeanFactory originalBeanFactory;
+
+    @BeforeAll
+    static void initializeMessages() {
+        originalBeanFactory = (ConfigurableListableBeanFactory)
+                ReflectionTestUtils.getField(SpringUtils.class, "beanFactory");
+        DefaultListableBeanFactory beanFactory = new DefaultListableBeanFactory();
+        StaticMessageSource messageSource = new StaticMessageSource();
+        messageSource.setUseCodeAsDefaultMessage(true);
+        beanFactory.registerSingleton("messageSource", messageSource);
+        new SpringUtils().postProcessBeanFactory(beanFactory);
+    }
+
+    @AfterAll
+    static void restoreMessages() {
+        new SpringUtils().postProcessBeanFactory(originalBeanFactory);
+    }
+
+    @Test
+    void quoteUsesServerRouteAndEnabledPricing() {
+        Fixture fixture = new Fixture();
+        when(fixture.pricingMapper.selectOne(any(Wrapper.class))).thenReturn(pricing());
+        when(fixture.routeService.calculate(any(), any())).thenReturn(new RouteDistance(3001, 600, "ROUTE"));
+
+        var quote = fixture.service.quote(request());
+
+        assertEquals(3001, quote.getDistanceMeters());
+        assertEquals(new BigDecimal("12.90"), quote.getAmount());
+        assertEquals("ROUTE", quote.getDistanceSource());
+    }
+
+    @Test
+    void pickupRejectsMissingProofBeforeChangingState() {
+        Fixture fixture = new Fixture();
+        InfoUser rider = new InfoUser();
+        rider.setUserId(8L);
+        rider.setUserType("2");
+        when(fixture.userMapper.selectInfoUserByUserId(8L)).thenReturn(rider);
+        FlashDeliveryProofRequest request = new FlashDeliveryProofRequest();
+        request.setImageUrls(List.of());
+
+        assertThrows(RuntimeException.class, () -> fixture.service.pickup(8L, 10L, request));
+        verify(fixture.orderMapper, never()).transitionByRider(any(), any(), any(), any(), any(), any());
+    }
+
+    @Test
+    void atomicAcceptFailureDoesNotBindSecondRider() {
+        Fixture fixture = new Fixture();
+        InfoUser rider = new InfoUser();
+        rider.setUserId(8L);
+        rider.setUserType("2");
+        when(fixture.userMapper.selectInfoUserByUserId(8L)).thenReturn(rider);
+        when(fixture.orderMapper.accept(eq(10L), eq(8L), any())).thenReturn(0);
+
+        assertThrows(RuntimeException.class, () -> fixture.service.accept(8L, 10L));
+        verify(fixture.orderMapper, never()).selectById(10L);
+    }
+
+    @Test
+    void oversizedProofUrlIsRejectedBeforeStateTransition() {
+        Fixture fixture = new Fixture();
+        InfoUser rider = new InfoUser();
+        rider.setUserId(8L);
+        rider.setUserType("2");
+        when(fixture.userMapper.selectInfoUserByUserId(8L)).thenReturn(rider);
+        FlashDeliveryProofRequest request = new FlashDeliveryProofRequest();
+        request.setImageUrls(List.of("https://example.com/" + "a".repeat(1000)));
+
+        assertThrows(RuntimeException.class, () -> fixture.service.pickup(8L, 10L, request));
+        verify(fixture.orderMapper, never()).transitionByRider(any(), any(), any(), any(), any(), any());
+    }
+
+    @Test
+    void missingServiceTypeReturnsBusinessErrorInsteadOfNullPointer() {
+        Fixture fixture = new Fixture();
+        FlashDeliveryQuoteRequest request = request();
+        request.setServiceType(null);
+        assertThrows(ServiceException.class, () -> fixture.service.quote(request));
+    }
+
+    @Test
+    void equalTextAddressesAreRejectedEvenWhenCoordinatesDiffer() {
+        Fixture fixture = new Fixture();
+        FlashDeliveryQuoteRequest request = request();
+        request.getDelivery().setAddress(request.getPickup().getAddress());
+        request.getDelivery().setAddressDetail(request.getPickup().getAddressDetail());
+        when(fixture.pricingMapper.selectOne(any(Wrapper.class))).thenReturn(pricing());
+        when(fixture.routeService.calculate(any(), any())).thenReturn(new RouteDistance(1000, 100, "ROUTE"));
+
+        assertThrows(ServiceException.class, () -> fixture.service.quote(request));
+    }
+
+    @Test
+    void concurrentPricingUpdateIsRejected() {
+        Fixture fixture = new Fixture();
+        when(fixture.pricingMapper.selectOne(any(Wrapper.class))).thenReturn(pricing());
+        when(fixture.pricingMapper.update(isNull(), any(Wrapper.class))).thenReturn(0);
+        FlashDeliveryPricingUpdateRequest request = new FlashDeliveryPricingUpdateRequest();
+        request.setStartPrice(new BigDecimal("10.00"));
+        request.setStartDistanceMeters(3000);
+        request.setPerKmPrice(new BigDecimal("3.00"));
+        request.setEnabled(true);
+
+        assertThrows(ServiceException.class,
+                () -> fixture.service.updatePricing(1L, "HELP_SEND", request));
+        verify(fixture.pricingMapper, never()).updateById(any(FlashDeliveryPricing.class));
+    }
+
+    private FlashDeliveryQuoteRequest request() {
+        FlashDeliveryQuoteRequest request = new FlashDeliveryQuoteRequest();
+        request.setServiceType("HELP_SEND");
+        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 FlashDeliveryAddressRequest address(String name, String phone, String lat, String lon) {
+        FlashDeliveryAddressRequest address = new FlashDeliveryAddressRequest();
+        address.setName(name);
+        address.setPhone(phone);
+        address.setAddress("road");
+        address.setAddressDetail("door");
+        address.setLatitude(new BigDecimal(lat));
+        address.setLongitude(new BigDecimal(lon));
+        return address;
+    }
+
+    private FlashDeliveryPricing pricing() {
+        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.setEnabled(true);
+        pricing.setConfigVersion(1);
+        return pricing;
+    }
+
+    private static class Fixture {
+        final FlashDeliveryOrderMapper orderMapper = mock(FlashDeliveryOrderMapper.class);
+        final FlashDeliveryPricingMapper pricingMapper = mock(FlashDeliveryPricingMapper.class);
+        final FlashDeliveryOrderImageMapper imageMapper = mock(FlashDeliveryOrderImageMapper.class);
+        final FlashDeliveryOrderLogMapper logMapper = mock(FlashDeliveryOrderLogMapper.class);
+        final InfoUserMapper userMapper = mock(InfoUserMapper.class);
+        final FlashDeliveryRouteService routeService = mock(FlashDeliveryRouteService.class);
+        final FlashDeliveryApplicationService service = new FlashDeliveryApplicationService(
+                orderMapper, pricingMapper, imageMapper, logMapper, userMapper,
+                routeService, new FlashDeliveryPricingCalculator());
+    }
+}

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

@@ -0,0 +1,44 @@
+package com.ruoyi.app.flashdelivery.service;
+
+import org.junit.jupiter.api.Test;
+
+import java.io.InputStream;
+import java.util.List;
+import java.util.Properties;
+
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class FlashDeliveryI18nContractTest {
+    private static final List<String> FILES = List.of("messages.properties", "messages_zh_CN.properties",
+            "messages_zh_TW.properties", "messages_en_US.properties", "messages_vi.properties");
+    private static final List<String> KEYS = List.of(
+            "flash.delivery.request.required", "flash.delivery.auth.required",
+            "flash.delivery.client.request.id.invalid", "flash.delivery.note.too.long",
+            "flash.delivery.service.type.invalid", "flash.delivery.service.unavailable",
+            "flash.delivery.address.invalid", "flash.delivery.address.same",
+            "flash.delivery.order.not.found", "flash.delivery.order.already.accepted",
+            "flash.delivery.rider.required", "flash.delivery.proof.required",
+            "flash.delivery.proof.url.invalid", "flash.delivery.proof.too.many",
+            "flash.delivery.transition.not.allowed",
+            "flash.delivery.cancel.reason.required", "flash.delivery.cancel.not.allowed",
+            "flash.delivery.complete.not.allowed", "flash.delivery.state.changed",
+            "flash.delivery.pricing.invalid", "flash.delivery.pricing.not.found",
+            "flash.delivery.pricing.changed",
+            "address.access.denied", "address.data.invalid");
+
+    @Test
+    void everySupportedBundleContainsEveryFlashDeliveryMessage() throws Exception {
+        for (String file : FILES) {
+            Properties properties = new Properties();
+            try (InputStream input = getClass().getClassLoader().getResourceAsStream("i18n/" + file)) {
+                assertNotNull(input, file);
+                properties.load(input);
+            }
+            for (String key : KEYS) {
+                assertTrue(properties.containsKey(key), file + " missing " + key);
+                assertTrue(!properties.getProperty(key).isBlank(), file + " blank " + key);
+            }
+        }
+    }
+}

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

@@ -0,0 +1,43 @@
+package com.ruoyi.app.flashdelivery.service;
+
+import com.ruoyi.system.domain.flash.FlashDeliveryPricing;
+import org.junit.jupiter.api.Test;
+
+import java.math.BigDecimal;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+class FlashDeliveryPricingCalculatorTest {
+
+    private final FlashDeliveryPricingCalculator calculator = new FlashDeliveryPricingCalculator();
+
+    @Test
+    void chargesStartPriceAtStartDistanceBoundary() {
+        assertEquals(new BigDecimal("9.90"), calculator.calculate(pricing(), 3000));
+    }
+
+    @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));
+    }
+
+    @Test
+    void rejectsNonPositiveDistanceOrInvalidPricing() {
+        assertThrows(IllegalArgumentException.class, () -> calculator.calculate(pricing(), 0));
+        FlashDeliveryPricing pricing = pricing();
+        pricing.setPerKmPrice(BigDecimal.ZERO);
+        assertThrows(IllegalArgumentException.class, () -> calculator.calculate(pricing, 3001));
+    }
+
+    private FlashDeliveryPricing pricing() {
+        FlashDeliveryPricing pricing = new FlashDeliveryPricing();
+        pricing.setStartPrice(new BigDecimal("9.90"));
+        pricing.setStartDistanceMeters(3000);
+        pricing.setPerKmPrice(new BigDecimal("3.00"));
+        pricing.setEnabled(true);
+        return pricing;
+    }
+}

+ 31 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryStateMachineTest.java

@@ -0,0 +1,31 @@
+package com.ruoyi.app.flashdelivery.service;
+
+import org.junit.jupiter.api.Test;
+
+import static com.ruoyi.system.domain.flash.FlashDeliveryStatus.*;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class FlashDeliveryStateMachineTest {
+
+    @Test
+    void acceptsOnlyTheDeliveryLifecycleTransitions() {
+        assertTrue(FlashDeliveryStateMachine.canTransition(WAITING_ACCEPTANCE, ACCEPTED));
+        assertTrue(FlashDeliveryStateMachine.canTransition(ACCEPTED, PICKED_UP));
+        assertTrue(FlashDeliveryStateMachine.canTransition(PICKED_UP, DELIVERED));
+        assertTrue(FlashDeliveryStateMachine.canTransition(DELIVERED, COMPLETED));
+        assertFalse(FlashDeliveryStateMachine.canTransition(ACCEPTED, DELIVERED));
+        assertFalse(FlashDeliveryStateMachine.canTransition(COMPLETED, CANCELLED));
+        assertFalse(FlashDeliveryStateMachine.canTransition(CANCELLED, WAITING_ACCEPTANCE));
+    }
+
+    @Test
+    void userAndAdminCancellationRulesAreDifferent() {
+        assertTrue(FlashDeliveryStateMachine.canUserCancel(WAITING_ACCEPTANCE));
+        assertTrue(FlashDeliveryStateMachine.canUserCancel(ACCEPTED));
+        assertFalse(FlashDeliveryStateMachine.canUserCancel(PICKED_UP));
+        assertTrue(FlashDeliveryStateMachine.canAdminCancel(PICKED_UP));
+        assertTrue(FlashDeliveryStateMachine.canAdminCancel(DELIVERED));
+        assertFalse(FlashDeliveryStateMachine.canAdminCancel(COMPLETED));
+    }
+}

+ 73 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/service/InfoAddressBookServiceTest.java

@@ -0,0 +1,73 @@
+package com.ruoyi.app.flashdelivery.service;
+
+import com.ruoyi.app.flashdelivery.dto.InfoAddressRequest;
+import com.baomidou.mybatisplus.core.conditions.Wrapper;
+import com.ruoyi.system.domain.InfoAddress;
+import com.ruoyi.system.service.IInfoAddressService;
+import org.junit.jupiter.api.Test;
+
+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 static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+class InfoAddressBookServiceTest {
+
+    @Test
+    void updateCannotOverwriteAnotherUsersAddress() {
+        IInfoAddressService addresses = mock(IInfoAddressService.class);
+        InfoAddress other = new InfoAddress();
+        other.setId(9L);
+        other.setUserId(2L);
+        when(addresses.getById(9L)).thenReturn(other);
+        InfoAddressRequest request = validRequest();
+        request.setId(9L);
+
+        assertThrows(RuntimeException.class, () -> new InfoAddressBookService(addresses).save(1L, request));
+        verify(addresses, never()).update(any(), any());
+        verify(addresses, never()).update(any(Wrapper.class));
+    }
+
+    @Test
+    void createAlwaysUsesAuthenticatedUser() {
+        IInfoAddressService addresses = mock(IInfoAddressService.class);
+        InfoAddressRequest request = validRequest();
+        new InfoAddressBookService(addresses).save(7L, request);
+
+        var captor = org.mockito.ArgumentCaptor.forClass(InfoAddress.class);
+        verify(addresses).save(captor.capture());
+        assertEquals(7L, captor.getValue().getUserId());
+    }
+
+    @Test
+    void updateCanExplicitlyClearOptionalAddressFields() {
+        IInfoAddressService addresses = mock(IInfoAddressService.class);
+        InfoAddress existing = new InfoAddress();
+        existing.setId(9L);
+        existing.setUserId(1L);
+        existing.setAddressDetail("old detail");
+        when(addresses.getById(9L)).thenReturn(existing);
+        when(addresses.update(any(Wrapper.class))).thenReturn(true);
+        InfoAddressRequest request = validRequest();
+        request.setId(9L);
+        request.setAddressDetail(null);
+
+        new InfoAddressBookService(addresses).save(1L, request);
+
+        var captor = org.mockito.ArgumentCaptor.forClass(Wrapper.class);
+        verify(addresses).update(captor.capture());
+        assertTrue(captor.getValue().getSqlSet().contains("address_detail"));
+    }
+
+    private InfoAddressRequest validRequest() {
+        InfoAddressRequest request = new InfoAddressRequest();
+        request.setName("A"); request.setPhone("0911111111"); request.setAddress("road");
+        request.setLongitude(java.math.BigDecimal.valueOf(121.5));
+        request.setLatitude(java.math.BigDecimal.valueOf(25));
+        return request;
+    }
+}

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

@@ -0,0 +1,51 @@
+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 java.util.Date;
+import java.util.List;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+import static org.mockito.Mockito.doThrow;
+
+class FlashDeliveryAutoCompleteTaskTest {
+    @Test
+    void completesEveryDueOrderThroughConditionalServiceOperation() {
+        FlashDeliveryApplicationService service = mock(FlashDeliveryApplicationService.class);
+        FlashDeliveryOrder first = new FlashDeliveryOrder(); first.setId(1L);
+        FlashDeliveryOrder second = new FlashDeliveryOrder(); second.setId(2L);
+        when(service.autoCompleteCandidates(any(Date.class), anyInt())).thenReturn(List.of(first, second));
+
+        new FlashDeliveryAutoCompleteTask(service).completeDeliveredOrders();
+
+        verify(service).complete(null, "SYSTEM", 1L);
+        verify(service).complete(null, "SYSTEM", 2L);
+    }
+
+    @Test
+    void oneFailedOrderDoesNotStopTheRemainingBatch() {
+        FlashDeliveryApplicationService service = mock(FlashDeliveryApplicationService.class);
+        FlashDeliveryOrder first = new FlashDeliveryOrder(); first.setId(1L);
+        FlashDeliveryOrder second = new FlashDeliveryOrder(); second.setId(2L);
+        when(service.autoCompleteCandidates(any(Date.class), anyInt())).thenReturn(List.of(first, second));
+        doThrow(new IllegalStateException("one bad row")).when(service).complete(null, "SYSTEM", 1L);
+
+        ch.qos.logback.classic.Logger logger = (ch.qos.logback.classic.Logger)
+                org.slf4j.LoggerFactory.getLogger(FlashDeliveryAutoCompleteTask.class);
+        ch.qos.logback.classic.Level previous = logger.getLevel();
+        logger.setLevel(ch.qos.logback.classic.Level.OFF);
+        try {
+            new FlashDeliveryAutoCompleteTask(service).completeDeliveredOrders();
+        } finally {
+            logger.setLevel(previous);
+        }
+
+        verify(service).complete(null, "SYSTEM", 2L);
+    }
+}

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

@@ -1,6 +1,7 @@
 package com.ruoyi.system.domain;
 
 import java.math.BigDecimal;
+import java.util.Date;
 import com.baomidou.mybatisplus.annotation.IdType;
 import com.baomidou.mybatisplus.annotation.TableId;
 import com.baomidou.mybatisplus.annotation.TableName;
@@ -81,6 +82,8 @@ public class InfoAddress
     /** 距离 */
     @Excel(name = "距离")
     private transient Double juli;
+    private Boolean isTop;
+    private Date topTime;
 
     public void setId(Long id)
     {

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

@@ -0,0 +1,55 @@
+package com.ruoyi.system.domain.flash;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+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;
+    private String clientRequestId;
+    private Long userId;
+    private Long riderId;
+    private String serviceType;
+    private String status;
+    private String pickupName;
+    private String pickupPhone;
+    private String pickupAddress;
+    private String pickupAddressDetail;
+    private BigDecimal pickupLongitude;
+    private BigDecimal pickupLatitude;
+    private String deliveryName;
+    private String deliveryPhone;
+    private String deliveryAddress;
+    private String deliveryAddressDetail;
+    private BigDecimal deliveryLongitude;
+    private BigDecimal deliveryLatitude;
+    private Integer distanceMeters;
+    private String distanceSource;
+    private Integer estimatedDurationSeconds;
+    private BigDecimal amount;
+    private String currency;
+    private Integer pricingVersion;
+    private BigDecimal startPrice;
+    private Integer startDistanceMeters;
+    private BigDecimal perKmPrice;
+    private String userNote;
+    private Integer version;
+    private Date acceptedAt;
+    private Date pickedUpAt;
+    private Date deliveredAt;
+    private Date completedAt;
+    private Date cancelledAt;
+    private String cancelReason;
+    private String cancelOperatorType;
+    private Long cancelOperatorId;
+    private Date createTime;
+    private Date updateTime;
+}

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

@@ -0,0 +1,21 @@
+package com.ruoyi.system.domain.flash;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.util.Date;
+
+@Data
+@TableName("flash_delivery_order_image")
+public class FlashDeliveryOrderImage {
+    @TableId(type = IdType.AUTO)
+    private Long id;
+    private Long orderId;
+    private String proofType;
+    private String imageUrl;
+    private Integer sortOrder;
+    private Long operatorId;
+    private Date createTime;
+}

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

@@ -0,0 +1,22 @@
+package com.ruoyi.system.domain.flash;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.util.Date;
+
+@Data
+@TableName("flash_delivery_order_log")
+public class FlashDeliveryOrderLog {
+    @TableId(type = IdType.AUTO)
+    private Long id;
+    private Long orderId;
+    private String fromStatus;
+    private String toStatus;
+    private String operatorType;
+    private Long operatorId;
+    private String reason;
+    private Date createTime;
+}

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

@@ -0,0 +1,25 @@
+package com.ruoyi.system.domain.flash;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+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;
+    private BigDecimal startPrice;
+    private Integer startDistanceMeters;
+    private BigDecimal perKmPrice;
+    private Boolean enabled;
+    private Integer configVersion;
+    private Long updatedBy;
+    private Date createTime;
+    private Date updateTime;
+}

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

@@ -0,0 +1,12 @@
+package com.ruoyi.system.domain.flash;
+
+public final class FlashDeliveryStatus {
+    public static final String WAITING_ACCEPTANCE = "WAITING_ACCEPTANCE";
+    public static final String ACCEPTED = "ACCEPTED";
+    public static final String PICKED_UP = "PICKED_UP";
+    public static final String DELIVERED = "DELIVERED";
+    public static final String COMPLETED = "COMPLETED";
+    public static final String CANCELLED = "CANCELLED";
+
+    private FlashDeliveryStatus() { }
+}

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

@@ -0,0 +1,6 @@
+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> { }

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

@@ -0,0 +1,6 @@
+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> { }

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

@@ -0,0 +1,35 @@
+package com.ruoyi.system.mapper.flash;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.ruoyi.system.domain.flash.FlashDeliveryOrder;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.Date;
+import java.util.List;
+
+public interface FlashDeliveryOrderMapper extends BaseMapper<FlashDeliveryOrder> {
+    FlashDeliveryOrder selectByUserRequestId(@Param("userId") Long userId,
+                                             @Param("clientRequestId") String clientRequestId);
+
+    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);
+}

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

@@ -0,0 +1,6 @@
+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> { }

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

@@ -0,0 +1,90 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.ruoyi.system.mapper.flash.FlashDeliveryOrderMapper">
+    <resultMap id="FlashDeliveryOrderResult" type="FlashDeliveryOrder">
+        <id property="id" column="id"/>
+        <result property="orderNo" column="order_no"/>
+        <result property="clientRequestId" column="client_request_id"/>
+        <result property="userId" column="user_id"/>
+        <result property="riderId" column="rider_id"/>
+        <result property="serviceType" column="service_type"/>
+        <result property="status" column="status"/>
+        <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="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="deliveryLongitude" column="delivery_longitude"/>
+        <result property="deliveryLatitude" column="delivery_latitude"/>
+        <result property="distanceMeters" column="distance_meters"/>
+        <result property="distanceSource" column="distance_source"/>
+        <result property="estimatedDurationSeconds" column="estimated_duration_seconds"/>
+        <result property="amount" column="amount"/>
+        <result property="currency" column="currency"/>
+        <result property="pricingVersion" column="pricing_version"/>
+        <result property="startPrice" column="start_price"/>
+        <result property="startDistanceMeters" column="start_distance_meters"/>
+        <result property="perKmPrice" column="per_km_price"/>
+        <result property="userNote" column="user_note"/>
+        <result property="version" column="version"/>
+        <result property="acceptedAt" column="accepted_at"/>
+        <result property="pickedUpAt" column="picked_up_at"/>
+        <result property="deliveredAt" column="delivered_at"/>
+        <result property="completedAt" column="completed_at"/>
+        <result property="cancelledAt" column="cancelled_at"/>
+        <result property="cancelReason" column="cancel_reason"/>
+        <result property="cancelOperatorType" column="cancel_operator_type"/>
+        <result property="cancelOperatorId" column="cancel_operator_id"/>
+        <result property="createTime" column="create_time"/>
+        <result property="updateTime" column="update_time"/>
+    </resultMap>
+
+    <select id="selectByUserRequestId" resultMap="FlashDeliveryOrderResult">
+        select * from flash_delivery_order where user_id = #{userId} and client_request_id = #{clientRequestId} limit 1
+    </select>
+
+    <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
+    </update>
+
+    <update id="transitionByRider">
+        update flash_delivery_order
+        set status=#{nextStatus},
+            <choose><when test="timeColumn == 'picked_up_at'">picked_up_at=#{now},</when><otherwise>delivered_at=#{now},</otherwise></choose>
+            update_time=#{now}, version = version + 1
+        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},
+            cancel_operator_type=#{operatorType}, cancel_operator_id=#{operatorId},
+            update_time=#{now}, version = version + 1
+        where id=#{id} and status=#{expectedStatus}
+    </update>
+
+    <select id="selectAutoCompletable" resultMap="FlashDeliveryOrderResult">
+        select * from flash_delivery_order
+        where status='DELIVERED' and delivered_at &lt;= #{deadline}
+        order by delivered_at asc limit #{limit}
+    </select>
+</mapper>

+ 14 - 2
ruoyi-system/src/main/resources/mapper/system/InfoAddressMapper.xml

@@ -17,10 +17,13 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         <result property="province"    column="province"    />
         <result property="city"    column="city"    />
         <result property="area"    column="area"    />
+        <result property="annexes" column="annexes" />
+        <result property="isTop" column="is_top" />
+        <result property="topTime" column="top_time" />
     </resultMap>
 
     <sql id="selectInfoAddressVo">
-        select id, user_id, name, phone, address, address_detail, longitude, latitude, country, province, city, area from info_address
+        select id, user_id, name, phone, address, address_detail, longitude, latitude, country, province, city, area, annexes, is_top, top_time from info_address
     </sql>
 
     <select id="selectInfoAddressList" parameterType="InfoAddress" resultMap="InfoAddressResult">
@@ -56,6 +59,9 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             <if test="province != null">province,</if>
             <if test="city != null">city,</if>
             <if test="area != null">area,</if>
+            <if test="annexes != null">annexes,</if>
+            <if test="isTop != null">is_top,</if>
+            <if test="topTime != null">top_time,</if>
          </trim>
         <trim prefix="values (" suffix=")" suffixOverrides=",">
             <if test="userId != null">#{userId},</if>
@@ -69,6 +75,9 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             <if test="province != null">#{province},</if>
             <if test="city != null">#{city},</if>
             <if test="area != null">#{area},</if>
+            <if test="annexes != null">#{annexes},</if>
+            <if test="isTop != null">#{isTop},</if>
+            <if test="topTime != null">#{topTime},</if>
          </trim>
     </insert>
 
@@ -86,6 +95,9 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             <if test="province != null">province = #{province},</if>
             <if test="city != null">city = #{city},</if>
             <if test="area != null">area = #{area},</if>
+            <if test="annexes != null">annexes = #{annexes},</if>
+            <if test="isTop != null">is_top = #{isTop},</if>
+            <if test="topTime != null">top_time = #{topTime},</if>
         </trim>
         where id = #{id}
     </update>
@@ -100,4 +112,4 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             #{id}
         </foreach>
     </delete>
-</mapper>
+</mapper>

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

@@ -0,0 +1,42 @@
+package com.ruoyi.system.mapper.flash;
+
+import com.ruoyi.system.domain.flash.FlashDeliveryOrder;
+import org.apache.ibatis.builder.xml.XMLMapperBuilder;
+import org.apache.ibatis.mapping.ResultMapping;
+import org.apache.ibatis.session.Configuration;
+import org.junit.jupiter.api.Test;
+
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class FlashDeliveryMapperXmlTest {
+
+    @Test
+    void orderMapperMapsSnapshotsAndUsesConditionalAtomicUpdates() throws Exception {
+        String resource = "mapper/flash/FlashDeliveryOrderMapper.xml";
+        Configuration configuration = new Configuration();
+        configuration.getTypeAliasRegistry().registerAlias("FlashDeliveryOrder", FlashDeliveryOrder.class);
+        String xml;
+        try (InputStream input = getClass().getClassLoader().getResourceAsStream(resource)) {
+            assertNotNull(input);
+            xml = new String(input.readAllBytes(), StandardCharsets.UTF_8);
+        }
+        try (InputStream input = getClass().getClassLoader().getResourceAsStream(resource)) {
+            new XMLMapperBuilder(input, configuration, resource, configuration.getSqlFragments()).parse();
+        }
+
+        ResultMapping amount = configuration
+                .getResultMap("com.ruoyi.system.mapper.flash.FlashDeliveryOrderMapper.FlashDeliveryOrderResult")
+                .getResultMappings().stream().filter(item -> "amount".equals(item.getProperty()))
+                .findFirst().orElseThrow();
+        assertEquals("amount", amount.getColumn());
+        assertTrue(xml.contains("status = 'WAITING_ACCEPTANCE'"));
+        assertTrue(xml.contains("rider_id is null"));
+        assertTrue(xml.contains("delivered_at &lt;="));
+        assertTrue(xml.contains("version = version + 1"));
+    }
+}

+ 1 - 1
specs/024-flash-delivery/checklists/requirements.md

@@ -47,4 +47,4 @@
 
 - [x] 规格具备进入用户书面审核的条件
 - [x] 用户已授权不等待书面规格审核并连续推进
-- [ ] 已进入 `plan.md` 和 `tasks.md` 阶段
+- [x] 已进入 `plan.md` 和 `tasks.md` 阶段

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

@@ -0,0 +1,66 @@
+# 闪送配送 API 契约
+
+## 通用约定
+
+- 用户/骑手端要求请求头 `token`,身份字段不进入请求体。
+- 平台权限:`flash:pricing:list/edit`、`flash:order:list/query/cancel/complete`。
+- 响应沿用 `AjaxResult`:`{"code":200,"msg":"...","data":...}`;分页 data 为 `records,total,current,size`。
+- 金额为 TWD 两位小数,距离为米。
+- `serviceType`:`HELP_SEND`、`HELP_PICKUP`、`URGENT`。
+- `status`:`WAITING_ACCEPTANCE`、`ACCEPTED`、`PICKED_UP`、`DELIVERED`、`COMPLETED`、`CANCELLED`。
+
+## 地址对象
+
+```json
+{"name":"王小明","phone":"0912345678","address":"台北市信义区市府路1号","addressDetail":"3楼A室","longitude":121.5645,"latitude":25.0330,"country":"TW","province":"台北市","city":"台北市","area":"信义区","annexes":""}
+```
+
+| 方法 | 路径 | 参数/说明 |
+|---|---|---|
+| GET | `/system/address/getaddress` | header token;query `keyword?`;仅本人,搜索并置顶优先 |
+| GET | `/system/address/getaddressxq` | header token;query `id`;仅本人 |
+| POST | `/system/address/address` | header token;body 地址对象,可含 `id`;忽略 userId |
+| DELETE | `/system/address/{id}` | header token;仅本人 |
+| POST | `/system/address/{id}/top` | header token;仅本人 |
+
+## 用户端
+
+| 方法 | 路径 | 请求/说明 |
+|---|---|---|
+| 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/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`。地址快照字段为联系人、电话、地址、详细地址和经纬度。客户端传入的金额/距离不会被使用。
+
+## 骑手端
+
+所有接口要求 token 用户 `userType=2`。
+
+| 方法 | 路径 | 请求/说明 |
+|---|---|---|
+| GET | `/system/flashDelivery/rider/orders/available` | `pageNum,pageSize,serviceType?`;加急优先,仅返回下述接单前安全字段 |
+| GET | `/system/flashDelivery/rider/orders/mine` | `pageNum,pageSize,status?`;仅本人任务 |
+| 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` | 同上;至少一张 |
+
+接单前安全字段固定为:`id,orderNo,serviceType,status,pickupLatitudeApprox,pickupLongitudeApprox,deliveryLatitudeApprox,deliveryLongitudeApprox,distanceMeters,amount,currency,createTime`。近似经纬度只保留两位小数;不返回用户 ID、联系人、电话、文字地址、精确经纬度、备注、幂等请求号、凭证或日志。抢单成功后才向中单骑手返回完整详情。
+
+## 平台端
+
+| 方法 | 路径 | 请求/说明 |
+|---|---|---|
+| GET | `/system/flashDelivery/admin/pricing` | 三类当前计价 |
+| PUT | `/system/flashDelivery/admin/pricing/{serviceType}` | `startPrice,startDistanceMeters,perKmPrice,enabled`;正数,版本原子加一;并发覆盖返回业务错误 |
+| 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 |
+
+本期没有支付、退款、结算、骑手收入、代购/垫付、重量、品类、件数、违禁品确认、骑手放弃或自动派单字段和接口。

+ 38 - 0
specs/024-flash-delivery/data-model.md

@@ -0,0 +1,38 @@
+# 数据模型:闪送配送服务
+
+## `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`、关键状态时间、取消审计及创建/更新时间。
+
+索引:订单号唯一、用户幂等号唯一、待抢排序、用户列表、骑手列表、自动完成扫描。
+
+## `flash_delivery_pricing`
+
+按 `service_type` 唯一,保存 `start_price`、`start_distance_meters`、`per_km_price`、`enabled`、`config_version`、`updated_by` 和时间。有效修改后版本加一,历史订单不回写。
+
+## `flash_delivery_order_image`
+
+保存 `order_id`、`proof_type`(PICKUP/DELIVERY)、`image_url`、`sort_order`、`operator_id`、`create_time`。
+
+## `flash_delivery_order_log`
+
+保存 `order_id`、`from_status`、`to_status`、`operator_type`(USER/RIDER/ADMIN/SYSTEM)、`operator_id`、`reason`、`create_time`。
+
+## `info_address` 增量
+
+新增 `is_top tinyint(1) not null default 0` 和 `top_time datetime null`。列表按 `is_top desc, top_time desc, id desc`。
+
+## 状态转换
+
+| 当前 | 操作/角色 | 下一状态 |
+|---|---|---|
+| WAITING_ACCEPTANCE | 抢单/RIDER | ACCEPTED |
+| WAITING_ACCEPTANCE | 取消/USER、ADMIN | CANCELLED |
+| ACCEPTED | 取件且含图片/指定 RIDER | PICKED_UP |
+| ACCEPTED | 取消/USER、ADMIN | CANCELLED |
+| PICKED_UP | 送达且含图片/指定 RIDER | DELIVERED |
+| PICKED_UP | 异常取消/ADMIN | CANCELLED |
+| DELIVERED | 签收或自动/USER、SYSTEM、ADMIN | COMPLETED |
+| DELIVERED | 异常取消/ADMIN | CANCELLED |
+
+`COMPLETED`、`CANCELLED` 是终态。

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

@@ -0,0 +1,78 @@
+# 实施计划:闪送配送服务
+
+**分支**:`test` | **日期**:2026-08-31 | **规格**:[spec.md](./spec.md)
+
+## 摘要
+
+新增独立闪送订单模块,提供帮送、帮取、加急送三种非支付配送服务。服务端负责路线距离、报价、订单快照、骑手原子抢单、取件/送达图片凭证、用户签收、24 小时自动完成、平台计价配置和异常介入;同步把共享地址簿改为基于 token 的安全接口。支付、退款、结算、代购、物品重量和自动派单不在本期范围。
+
+## 技术上下文
+
+- **语言/版本**:Java 21
+- **主要依赖**:Spring Boot、Spring MVC、Spring Security、MyBatis/MyBatis-Plus、Apache HttpClient 4、fastjson2
+- **存储**:MySQL;迁移仅记录在 `updatesql/sql.md`
+- **测试**:JUnit 5、Mockito、Spring MockMvc、MyBatis XML 解析测试
+- **目标平台**:现有 RuoYi 多模块 REST API 服务端
+- **性能目标**:列表分页;抢单和状态流转单条条件更新;地图超时后立即降级
+- **约束**:保持 `ruoyi-admin -> ruoyi-system`;Controller 不接收 Map;App 身份只从 token 解析;业务错误全部 i18n;不执行数据库迁移
+- **范围**:24 个闪送/地址端点、4 张新表、1 张地址表增量、五套语言资源、定向自动化测试
+
+## 规范检查
+
+- [x] 独立 `flash_delivery_*` 模型,不复用 `pos_order`/`taxi_order`
+- [x] HTTP 集成只放在 `ruoyi-admin`
+- [x] Controller 使用显式 DTO 和参数注解
+- [x] 用户与骑手接口通过 token 取身份,平台接口使用明确权限
+- [x] 金额、距离和状态由服务端计算与校验
+- [x] 地址、订单和凭证执行所有权/角色校验
+- [x] SQL 只写 `updatesql/sql.md`
+- [x] 支付能力明确延期
+
+## 源码结构
+
+```text
+ruoyi-system/src/main/java/com/ruoyi/system/
+├── domain/flash/                 # 订单、计价、图片、日志实体
+└── mapper/flash/                 # 持久化、原子抢单、条件状态流转
+ruoyi-system/src/main/resources/mapper/flash/*.xml
+
+ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/
+├── controller/                   # 用户、骑手、平台 Controller
+├── dto/                          # 明确请求 DTO 与响应视图
+├── service/                      # 报价、状态机、权限和脱敏
+├── route/                        # Google Routes 与直线降级
+└── task/                         # 送达 24 小时自动完成
+
+ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/
+ruoyi-system/src/test/java/com/ruoyi/system/mapper/flash/
+```
+
+地址簿沿用 `InfoAddress`、`IInfoAddressService` 和 `InfoAddressController`,增加请求 DTO、置顶字段、按用户条件查询/更新及所有权测试。
+
+## 核心实现决策
+
+1. `FlashDeliveryApplicationService` 是事务边界;报价只读,创建和每个状态操作同时写订单与日志。
+2. 抢单执行带 `status='WAITING_ACCEPTANCE' AND rider_id IS NULL` 的单条 UPDATE;受影响行数不是 1 即已被抢走。
+3. 其余状态变更携带订单、操作者、预期状态和版本条件,阻止并发覆盖。
+4. `(user_id, client_request_id)` 唯一键保证创建幂等;冲突时返回原订单。
+5. Google Routes API 字段掩码仅取距离与时长;异常、非 2xx、空路线或无效距离降级为 Haversine。
+6. 待抢单使用专用脱敏视图;抢单后只有订单骑手可见完整电话和详细地址。
+7. 取件/送达图片在状态更新同一事务内写入;空数组、空 URL 或非 HTTP(S) URL 拒绝。
+8. 自动任务扫描送达满 24 小时订单并逐条条件完成,和用户/平台并发时只允许一次成功。
+
+## 验证策略
+
+测试先行:先写测试并确认因实现缺失而失败,再添加最小生产代码。最终统一执行:
+
+```powershell
+$env:JAVA_HOME='C:\Users\qmj\.jdks\graalvm-jdk-21.0.7'
+$env:PATH="$env:JAVA_HOME\bin;$env:PATH"
+mvn -pl ruoyi-system -am -Dtest=FlashDeliveryMapperXmlTest -Dsurefire.failIfNoSpecifiedTests=false test
+mvn -pl ruoyi-admin -am -Dtest='FlashDelivery*Test,InfoAddressControllerSecurityTest' -Dsurefire.failIfNoSpecifiedTests=false test
+mvn -pl ruoyi-admin -am -DskipTests package
+git diff --check
+```
+
+## 复杂度跟踪
+
+无规范违例。四张新表分别承担订单快照、可变计价、图片凭证和审计日志;应用层留在 admin 是外部 HTTP 依赖和模块边界共同决定的。

+ 8 - 0
specs/024-flash-delivery/quickstart.md

@@ -0,0 +1,8 @@
+# 闪送 API 联调快速开始
+
+1. 开发者人工执行 `updatesql/sql.md` 中 2026-08-31 闪送迁移,并在字典 `sys_google_routes_key` 配置仅供后端使用、已启用 Google Routes API 的独立密钥;不得复用匿名接口可读取的 `sys_googlemap_key`。
+2. 启动 `ruoyi-admin`;用户和骑手请求携带现有 `token` 请求头,平台请求使用后台权限。
+3. 调用 `/system/flashDelivery/home`、`/quote`、`/orders`;创建时服务端重新报价。
+4. 骑手从 `/rider/orders/available` 拉取并 `/accept`;图片先走现有上传接口,再把 URL 传给 `/pickup` 和 `/deliver`。
+5. 用户 `/confirmReceipt` 完成;送达满 24 小时未操作则自动完成。
+6. 完整字段见 [API 契约](contracts/api.md)。

+ 19 - 0
specs/024-flash-delivery/research.md

@@ -0,0 +1,19 @@
+# 技术调研:闪送配送服务
+
+## Google 路线距离
+
+采用 Google Routes API v2 的 `POST https://routes.googleapis.com/directions/v2:computeRoutes`。请求头使用 `X-Goog-Api-Key` 和 `X-Goog-FieldMask: routes.distanceMeters,routes.duration`,请求体使用取件、收件经纬度、`DRIVE`、`TRAFFIC_AWARE`。参考 Google 官方 [Compute Routes](https://developers.google.com/maps/documentation/routes/reference/rest/v2/TopLevel/computeRoutes)、[计算路线](https://developers.google.com/maps/documentation/routes/compute_route_directions) 和 [字段掩码](https://developers.google.com/maps/documentation/routes/choose_fields)。
+
+服务端只从字典 `sys_google_routes_key` 读取独立密钥,不复用可能由现有匿名接口返回的浏览器密钥 `sys_googlemap_key`,也不在响应或日志中输出服务端密钥。该密钥需启用 Routes API 并限制服务端来源。连接/读取超时、HTTP 非成功、JSON 无路线或距离不大于 0 均降级。
+
+## 降级与精度
+
+外部路线不可用时使用 Haversine 公式计算球面直线距离,四舍五入为整数米并标记 `STRAIGHT_LINE`;路线成功标记 `ROUTE`。价格使用 `BigDecimal` 和 `decimal(10,2)`;超距公里用整数米执行向上取整,避免浮点边界误差。
+
+## 并发与安全
+
+- 创建由 `(user_id, client_request_id)` 唯一键最终保证幂等。
+- 抢单和状态变更使用条件 UPDATE,不先查后改。
+- App 身份只来自 `JwtUtil.getusid(token)`,骑手额外校验 `userType=2`。
+- 平台接口使用 `@PreAuthorize`;凭证仅接受 HTTP(S) URL。
+- 待抢订单通过专用视图脱敏,不直接序列化数据库实体。

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

@@ -4,7 +4,7 @@
 
 **创建日期**:2026-08-31
 
-**状态**:草稿,等待书面规格审核
+**状态**:已实现并通过定向单元/契约测试;数据库迁移环境集成验证待执行
 
 **输入**:基于蓝湖“闪送”分组 7 个设计页面,实现帮送、帮取、加急送的完整非支付业务闭环,包括共享地址簿、路线报价、创建订单、骑手主动抢单、取件、送达、用户签收、平台配置与介入。
 

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

@@ -0,0 +1,46 @@
+# 任务:闪送配送服务
+
+## Phase 1:测试红灯与数据层
+
+- [x] T001 编写计价、状态机、路线测试并确认因实现缺失而失败
+- [x] T002 编写 MyBatis XML 契约测试,覆盖映射、原子抢单和自动完成条件并确认失败
+- [x] T003 在 `updatesql/sql.md` 记录四张新表、地址字段、索引、计价种子和后台权限 SQL
+- [x] T004 新增闪送实体、Mapper、XML 与地址置顶/安全条件方法,使数据层测试转绿
+
+## Phase 2:US1 报价与订单创建
+
+- [x] T005 [US1] 先写报价边界、路线降级、配置停用、无效地址、重算和幂等测试并确认红灯
+- [x] T006 [US1] 实现 Routes 客户端、Haversine 降级、计价器和配置读取
+- [x] T007 [US1] 实现 home/quote/create DTO、服务与 Controller,保存快照和创建日志
+- [x] T008 [US1] 运行 US1 定向测试转绿
+
+## Phase 3:US2 骑手抢单和配送
+
+- [x] T009 [US2] 先写骑手角色、脱敏、排序、原子抢单、越权、图片必填测试并确认红灯
+- [x] T010 [US2] 实现骑手 available/mine/detail/accept/pickup/deliver
+- [x] T011 [US2] 同事务保存图片和状态日志,运行 US2 测试转绿
+
+## Phase 4:US3 安全共享地址簿
+
+- [x] T012 [US3] 先写跨用户拒绝、userId 忽略和搜索排序测试并确认红灯
+- [x] T013 [US3] 新增地址 DTO,修复匿名删除/详情和实体直绑,提供 DELETE/top
+- [x] T014 [US3] 更新地址 Mapper/XML 并运行地址安全测试转绿
+
+## Phase 5:US4 用户跟踪、取消和签收
+
+- [x] T015 [US4] 先写本人查询、越权、取消、签收、终态和自动完成并发测试并确认红灯
+- [x] T016 [US4] 实现用户列表/详情/cancel/confirmReceipt
+- [x] T017 [US4] 实现 24 小时自动完成任务,运行 US4 测试转绿
+
+## Phase 6:US5 平台配置和介入
+
+- [x] T018 [US5] 先写权限注解、计价版本、查询、异常取消和平台完成测试并确认红灯
+- [x] T019 [US5] 实现平台 pricing 和 orders API
+- [x] T020 [US5] 运行 US5 测试转绿
+
+## Phase 7:交付
+
+- [x] T021 为五个 i18n 文件添加一致的闪送/地址错误 key 与一致性测试
+- [x] T022 核对 API 契约、实现、权限和 SQL,更新任务状态
+- [x] T023 JDK 21 运行定向测试、模块构建和 `git diff --check`
+- [x] T024 核对暂存范围并准备中文提交

+ 130 - 0
updatesql/sql.md

@@ -947,3 +947,133 @@ CREATE TABLE merchant_subaccount_store (
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='商家分管账号门店授权';
 ```
 
+## 2026-08-31 闪送配送服务(024-flash-delivery)
+
+```sql
+-- 仅记录迁移脚本,由开发者确认环境后手动执行;Codex 不直接执行数据库变更。
+-- 运维另行在字典 sys_google_routes_key 配置仅供后端使用的 Google Routes API 密钥;
+-- 不得复用会被现有匿名接口返回的 sys_googlemap_key,且不得把真实密钥写入本迁移文件。
+ALTER TABLE info_address
+  ADD COLUMN is_top TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否置顶' AFTER annexes,
+  ADD COLUMN top_time DATETIME NULL COMMENT '置顶时间' AFTER is_top,
+  ADD KEY idx_info_address_user_top (user_id, is_top, top_time, id);
+
+CREATE TABLE flash_delivery_pricing (
+  id BIGINT NOT NULL AUTO_INCREMENT,
+  service_type VARCHAR(20) NOT NULL,
+  start_price DECIMAL(10,2) NOT NULL,
+  start_distance_meters INT NOT NULL,
+  per_km_price DECIMAL(10,2) NOT NULL,
+  enabled TINYINT(1) NOT NULL DEFAULT 1,
+  config_version INT NOT NULL DEFAULT 1,
+  updated_by BIGINT NULL,
+  create_time DATETIME NOT NULL,
+  update_time DATETIME NOT NULL,
+  PRIMARY KEY (id),
+  UNIQUE KEY uk_flash_pricing_service_type (service_type)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='闪送计价配置';
+
+CREATE TABLE flash_delivery_order (
+  id BIGINT NOT NULL AUTO_INCREMENT,
+  order_no VARCHAR(32) NOT NULL,
+  client_request_id VARCHAR(64) NOT NULL,
+  user_id BIGINT NOT NULL,
+  rider_id BIGINT NULL,
+  service_type VARCHAR(20) NOT NULL,
+  status VARCHAR(24) NOT NULL,
+  pickup_name VARCHAR(64) NOT NULL,
+  pickup_phone VARCHAR(32) NOT NULL,
+  pickup_address VARCHAR(255) NOT NULL,
+  pickup_address_detail VARCHAR(255) NULL,
+  pickup_longitude DECIMAL(10,7) NOT NULL,
+  pickup_latitude DECIMAL(10,7) NOT NULL,
+  delivery_name VARCHAR(64) NOT NULL,
+  delivery_phone VARCHAR(32) NOT NULL,
+  delivery_address VARCHAR(255) NOT NULL,
+  delivery_address_detail VARCHAR(255) NULL,
+  delivery_longitude DECIMAL(10,7) NOT NULL,
+  delivery_latitude DECIMAL(10,7) NOT NULL,
+  distance_meters INT NOT NULL,
+  distance_source VARCHAR(20) NOT NULL,
+  estimated_duration_seconds INT NULL,
+  amount DECIMAL(10,2) NOT NULL,
+  currency CHAR(3) NOT NULL DEFAULT 'TWD',
+  pricing_version INT NOT NULL,
+  start_price DECIMAL(10,2) NOT NULL,
+  start_distance_meters INT NOT NULL,
+  per_km_price DECIMAL(10,2) NOT NULL,
+  user_note VARCHAR(500) NULL,
+  version INT NOT NULL DEFAULT 0,
+  accepted_at DATETIME NULL,
+  picked_up_at DATETIME NULL,
+  delivered_at DATETIME NULL,
+  completed_at DATETIME NULL,
+  cancelled_at DATETIME NULL,
+  cancel_reason VARCHAR(500) NULL,
+  cancel_operator_type VARCHAR(20) NULL,
+  cancel_operator_id BIGINT NULL,
+  create_time DATETIME NOT NULL,
+  update_time DATETIME NOT NULL,
+  PRIMARY KEY (id),
+  UNIQUE KEY uk_flash_order_no (order_no),
+  UNIQUE KEY uk_flash_order_request (user_id, client_request_id),
+  KEY idx_flash_available (status, service_type, create_time),
+  KEY idx_flash_user (user_id, create_time),
+  KEY idx_flash_rider (rider_id, status, create_time),
+  KEY idx_flash_auto_complete (status, delivered_at)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='闪送配送订单';
+
+CREATE TABLE flash_delivery_order_image (
+  id BIGINT NOT NULL AUTO_INCREMENT,
+  order_id BIGINT NOT NULL,
+  proof_type VARCHAR(20) NOT NULL,
+  image_url VARCHAR(1000) NOT NULL,
+  sort_order INT NOT NULL DEFAULT 0,
+  operator_id BIGINT NOT NULL,
+  create_time DATETIME NOT NULL,
+  PRIMARY KEY (id),
+  KEY idx_flash_image_order (order_id, proof_type, sort_order)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='闪送取送凭证';
+
+CREATE TABLE flash_delivery_order_log (
+  id BIGINT NOT NULL AUTO_INCREMENT,
+  order_id BIGINT NOT NULL,
+  from_status VARCHAR(24) NULL,
+  to_status VARCHAR(24) NOT NULL,
+  operator_type VARCHAR(20) NOT NULL,
+  operator_id BIGINT NULL,
+  reason VARCHAR(500) NULL,
+  create_time DATETIME NOT NULL,
+  PRIMARY KEY (id),
+  KEY idx_flash_log_order (order_id, create_time)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='闪送状态审计日志';
+
+-- 初始值仅用于系统启用前的后台调整:3 公里起步,普通 9.90、加急 12.50。
+INSERT INTO flash_delivery_pricing
+  (service_type, start_price, start_distance_meters, per_km_price, enabled, config_version, create_time, update_time)
+VALUES
+  ('HELP_SEND', 9.90, 3000, 3.00, 1, 1, NOW(), NOW()),
+  ('HELP_PICKUP', 9.90, 3000, 3.00, 1, 1, NOW(), NOW()),
+  ('URGENT', 12.50, 3000, 4.00, 1, 1, NOW(), NOW());
+
+-- 平台功能权限;按需挂到实际闪送菜单并分配给管理员角色。
+INSERT INTO sys_menu (menu_name,parent_id,order_num,path,component,menu_type,visible,status,perms,icon,create_by,create_time,remark)
+SELECT '闪送计价查看',0,1,'#','','F','1','0','flash:pricing:list','#','admin',NOW(),'闪送后台权限'
+WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE perms='flash:pricing:list');
+INSERT INTO sys_menu (menu_name,parent_id,order_num,path,component,menu_type,visible,status,perms,icon,create_by,create_time,remark)
+SELECT '闪送计价修改',0,2,'#','','F','1','0','flash:pricing:edit','#','admin',NOW(),'闪送后台权限'
+WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE perms='flash:pricing:edit');
+INSERT INTO sys_menu (menu_name,parent_id,order_num,path,component,menu_type,visible,status,perms,icon,create_by,create_time,remark)
+SELECT '闪送订单查看',0,3,'#','','F','1','0','flash:order:list','#','admin',NOW(),'闪送后台权限'
+WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE perms='flash:order:list');
+INSERT INTO sys_menu (menu_name,parent_id,order_num,path,component,menu_type,visible,status,perms,icon,create_by,create_time,remark)
+SELECT '闪送订单详情',0,4,'#','','F','1','0','flash:order:query','#','admin',NOW(),'闪送后台权限'
+WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE perms='flash:order:query');
+INSERT INTO sys_menu (menu_name,parent_id,order_num,path,component,menu_type,visible,status,perms,icon,create_by,create_time,remark)
+SELECT '闪送订单取消',0,5,'#','','F','1','0','flash:order:cancel','#','admin',NOW(),'闪送后台权限'
+WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE perms='flash:order:cancel');
+INSERT INTO sys_menu (menu_name,parent_id,order_num,path,component,menu_type,visible,status,perms,icon,create_by,create_time,remark)
+SELECT '闪送订单完成',0,6,'#','','F','1','0','flash:order:complete','#','admin',NOW(),'闪送后台权限'
+WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE perms='flash:order:complete');
+```
+