Jelajahi Sumber

精简闪送 App 订单接口

qmj 2 hari lalu
induk
melakukan
979662d8e9
24 mengubah file dengan 787 tambahan dan 347 penghapusan
  1. 89 99
      docs/flash-delivery-app-api.md
  2. 12 19
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/controller/FlashDeliveryRiderController.java
  3. 4 7
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/controller/FlashDeliveryUserController.java
  4. 0 35
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryAvailableOrderView.java
  5. 0 14
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryImageView.java
  6. 0 15
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryLogView.java
  7. 8 5
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryOrderDetailView.java
  8. 30 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryRiderOrderListView.java
  9. 16 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryRiderOrderPageView.java
  10. 27 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryUserOrderListView.java
  11. 186 101
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryApplicationService.java
  12. 2 0
      ruoyi-admin/src/main/resources/i18n/messages.properties
  13. 2 0
      ruoyi-admin/src/main/resources/i18n/messages_en_US.properties
  14. 2 0
      ruoyi-admin/src/main/resources/i18n/messages_vi.properties
  15. 2 0
      ruoyi-admin/src/main/resources/i18n/messages_zh_CN.properties
  16. 2 0
      ruoyi-admin/src/main/resources/i18n/messages_zh_TW.properties
  17. 80 0
      ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/controller/FlashDeliveryControllerContractTest.java
  18. 0 36
      ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryAvailableOrderViewTest.java
  19. 14 0
      ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryOrderViewTest.java
  20. 40 0
      ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryRiderOrderListViewTest.java
  21. 254 7
      ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryApplicationServiceTest.java
  22. 1 1
      specs/024-flash-delivery/quickstart.md
  23. 1 1
      specs/024-flash-delivery/spec.md
  24. 15 7
      specs/024-flash-delivery/tasks.md

+ 89 - 99
docs/flash-delivery-app-api.md

@@ -94,8 +94,8 @@ App 必须以响应体 `code` 判断业务是否成功,不能只依赖 HTTP 
 }
 ```
 
-- `pageNum` 默认 `1`,小于 `1` 时按 `1` 处理。
-- `pageSize` 默认 `10`,范围为 `1` 至 `100`。
+- `page` 默认 `1`,小于 `1` 时按 `1` 处理。
+- `size` 默认 `10`,范围为 `1` 至 `100`。
 - App 至少读取 `records`、`total`、`current` 和 `size`。
 
 ### 2.4 时间、金额和距离
@@ -265,9 +265,9 @@ https://api.example.com/profile/upload/2026/09/01/example.jpg
 
 取件和收件地址不能是相同地址文本,也不能使用完全相同的经纬度。
 
-### 6.2 订单摘要 `order`
+### 6.2 订单字段
 
-用户订单列表、骑手已接订单列表和完整详情中的 `order` 使用以下字段:
+App 完整详情使用以下订单字段,响应的 `data` 直接返回这些字段,不再套用 `order`、`images`、`logs` 包装。
 
 | 字段 | 类型 | 说明 |
 |---|---|---|
@@ -298,30 +298,47 @@ https://api.example.com/profile/upload/2026/09/01/example.jpg
 | `createTime` | datetime | 创建时间 |
 | `updateTime` | datetime | 更新时间 |
 
-### 6.3 完整详情对象
+用户列表摘要固定字段为:`id,orderNo,serviceType,status,packageType,deliveryMode,scheduledPickupStartAt,scheduledPickupEndAt,pickupAddress,pickupDetailAddress,deliveryAddress,deliveryDetailAddress,estimatedDurationSeconds,amount,currency,deliveredAt,createTime`。用户列表不返回联系人、电话、精确坐标、照片、日志或计价审计字段。
 
-用户详情、中单骑手详情及抢单成功响应的数据结构为:
+骑手列表摘要固定字段为:`id,orderNo,serviceType,status,packageType,packageSize,deliveryMode,scheduledPickupStartAt,scheduledPickupEndAt,pinRequired,pickupAddress,pickupDetailAddress,deliveryAddress,deliveryDetailAddress,pickupDistanceMeters,distanceMeters,estimatedDurationSeconds,amount,currency,createTime`。`newTask` 分页响应额外返回 `nearbyTaskCount` 和 `highestOrderAmount`,不返回尖峰倍率或骑手收入字段。
+
+### 6.3 App 完整详情对象
+
+用户创建订单、用户订单详情、中单骑手详情及抢单成功响应的 `data` 结构为:
 
 ```json
 {
-  "order": {},
-  "images": [
-    {
-      "proofType": "PICKUP",
-      "imageUrl": "https://api.example.com/profile/upload/pickup.jpg",
-      "sortOrder": 0,
-      "createTime": "2026-09-01T10:30:00+08:00"
-    }
-  ],
-  "logs": [
-    {
-      "fromStatus": "ACCEPTED",
-      "toStatus": "PICKED_UP",
-      "operatorType": "RIDER",
-      "reason": null,
-      "createTime": "2026-09-01T10:30:00+08:00"
-    }
+  "id": 101,
+  "orderNo": "FD1234567890",
+  "serviceType": "URGENT",
+  "status": "ACCEPTED",
+  "packageType": "DOCUMENT",
+  "packageSize": "SMALL",
+  "deliveryMode": "NOW",
+  "scheduledPickupStartAt": null,
+  "scheduledPickupEndAt": null,
+  "pinRequired": true,
+  "pickup": {},
+  "delivery": {},
+  "distanceMeters": 4200,
+  "distanceSource": "ROUTE",
+  "estimatedDurationSeconds": 900,
+  "amount": 90,
+  "currency": "TWD",
+  "userNote": null,
+  "acceptedAt": "2026-09-01T10:20:00+08:00",
+  "pickedUpAt": null,
+  "deliveredAt": null,
+  "completedAt": null,
+  "cancelledAt": null,
+  "cancelReason": null,
+  "createTime": "2026-09-01T10:00:00+08:00",
+  "updateTime": "2026-09-01T10:20:00+08:00",
+  "senderImageUrls": [
+    "https://api.example.com/profile/upload/sender-1.jpg"
   ],
+  "pickupImageUrls": [],
+  "deliveryImageUrls": [],
   "rider": {
     "name": "陈骑手",
     "avatar": "https://api.example.com/avatar.jpg",
@@ -334,12 +351,14 @@ https://api.example.com/profile/upload/2026/09/01/example.jpg
 }
 ```
 
-- `proofType` 为 `SENDER`、`PICKUP` 或 `DELIVERY`
-- `operatorType` 为 `USER`、`RIDER`、`ADMIN` 或 `SYSTEM`
+- `senderImageUrls`、`pickupImageUrls` 和 `deliveryImageUrls` 分别表示寄件、取件和送达图片数组
+- App 详情不返回原始状态日志;页面使用 `status`、`acceptedAt`、`pickedUpAt`、`deliveredAt`、`completedAt` 和 `cancelledAt` 展示进度
 - `deliveryPinCode` 只向订单所属用户返回,并且只在启用 PIN 时出现;骑手响应永不返回此字段。
 - `rider` 只向用户返回。订单未接单时为 `null`。
 - 骑手位置只在订单状态为 `ACCEPTED` 或 `PICKED_UP` 时向用户返回,其他状态下经纬度为 `null`。
 
+平台订单详情是审计专用契约,仍可返回 `{order,images,logs}`;App 不应依赖或解析该结构。
+
 ## 7. 用户端接口
 
 所有接口均要求用户登录 `token`。
@@ -482,30 +501,17 @@ POST /system/flashDelivery/orders
 ### 7.4 查询用户订单列表
 
 ```http
-GET /system/flashDelivery/orders?pageNum=1&pageSize=10&scene=progress&serviceType=HELP_SEND
+GET /system/flashDelivery/orders?page=1&size=10
 ```
 
 查询参数:
 
 | 参数 | 必填 | 说明 |
 |---|---:|---|
-| `pageNum` | 否 | 默认 `1` |
-| `pageSize` | 否 | 默认 `10`,最大 `100` |
-| `status` | 否 | 精确状态筛选 |
-| `scene` | 否 | 页面状态分组,见下表 |
-| `serviceType` | 否 | 服务类型筛选 |
+| `page` | 否 | 默认 `1`,小于 `1` 时按 `1` 处理 |
+| `size` | 否 | 默认 `10`,范围为 `1` 至 `100` |
 
-`scene` 可选值:
-
-| 值 | 包含状态 |
-|---|---|
-| `all` 或不传 | 全部 |
-| `matching` | `WAITING_ACCEPTANCE` |
-| `progress` | `ACCEPTED`、`PICKED_UP`、`DELIVERED` |
-| `done` | `COMPLETED` |
-| `cancelled` | `CANCELLED` |
-
-`status` 和 `scene` 不能同时传。响应 `data.records` 中每项为订单摘要对象。
+仅查询当前用户,按创建时间倒序返回全部状态的订单摘要。接口不接受 `status`、`scene` 或 `serviceType`;响应 `data.records` 中每项为用户列表摘要对象。
 
 ### 7.5 查询用户订单详情
 
@@ -513,7 +519,7 @@ GET /system/flashDelivery/orders?pageNum=1&pageSize=10&scene=progress&serviceTyp
 GET /system/flashDelivery/orders/{id}
 ```
 
-仅订单所属用户可访问。响应 `data` 为完整详情对象,包括订单快照、图片、状态日志、骑手摘要和可选的交付 PIN
+仅订单所属用户可访问。响应 `data` 直接为 App 完整订单字段,包括订单快照、三类图片数组、骑手摘要和可选的交付 PIN,不返回原始状态日志
 
 ### 7.6 用户取消订单
 
@@ -545,21 +551,39 @@ POST /system/flashDelivery/orders/{id}/confirmReceipt
 
 所有接口均要求骑手登录 `token`,且账号 `userType` 必须为 `2`。
 
-### 8.1 查询待抢订单
+### 8.1 查询骑手订单列表
 
 ```http
-GET /system/flashDelivery/rider/orders/available?pageNum=1&pageSize=10&serviceType=URGENT
+GET /system/flashDelivery/rider/orders?page=1&size=10&tab=newTask&longitude=121.5&latitude=25.0
 ```
 
-查询参数:`pageNum`、`pageSize`、可选的 `serviceType`。
+查询参数:
 
-返回规则:
+| 参数 | 必填 | 说明 |
+|---|---:|---|
+| `page` | 否 | 默认 `1`,小于 `1` 时按 `1` 处理 |
+| `size` | 否 | 默认 `10`,范围为 `1` 至 `100` |
+| `tab` | 否 | 默认 `newTask`;可选 `newTask`、`toPickup`、`delivering`、`completed` 或 `cancelled` |
+| `longitude` | 否 | 骑手当前位置经度;`newTask` 传入时用于附近范围、取件点距离和排序,范围 `-180` 至 `180` |
+| `latitude` | 否 | 骑手当前位置纬度;`newTask` 传入时用于附近范围、取件点距离和排序,范围 `-90` 至 `90` |
+
+页签映射:
 
-- 只返回已到可接单时间的 `WAITING_ACCEPTANCE` 订单。
-- 加急订单优先,其余按创建时间升序。
-- 只返回脱敏安全字段,不返回联系人、电话、门牌、详细地址、精确坐标、用户备注、PIN、图片或日志。
+| `tab` | 对应状态 | 查询范围 |
+|---|---|---|
+| `newTask` | `WAITING_ACCEPTANCE` | 当前可抢任务 |
+| `toPickup` | `ACCEPTED` | 当前骑手本人任务 |
+| `delivering` | `PICKED_UP` | 当前骑手本人任务 |
+| `completed` | `DELIVERED`、`COMPLETED` | 当前骑手本人任务 |
+| `cancelled` | `CANCELLED` | 当前骑手本人任务 |
 
-`data.records` 每项结构:
+除 `newTask` 外只查询当前骑手本人任务。闪送没有退款流程,不提供外卖列表中的 `refund` 页签。
+
+`newTask` 的 `longitude` 和 `latitude` 仅用于附近范围筛选、计算取件点距离和排序;其他页签忽略坐标。列表按页签和骑手范围完成服务端过滤后再分页,不由 App 拉取完整集合后过滤。
+
+`newTask` 分页响应的 `data` 除 `records`、`total`、`current`、`size` 外,还返回 `nearbyTaskCount` 和 `highestOrderAmount`。不返回尖峰倍率或骑手收入字段。
+
+所有页签的 `data.records` 使用骑手列表摘要字段:
 
 ```json
 {
@@ -572,14 +596,12 @@ GET /system/flashDelivery/rider/orders/available?pageNum=1&pageSize=10&serviceTy
   "deliveryMode": "NOW",
   "scheduledPickupStartAt": null,
   "scheduledPickupEndAt": null,
-  "pickupCity": "台北市",
-  "pickupArea": "信义区",
-  "deliveryCity": "台北市",
-  "deliveryArea": "大安区",
-  "pickupLatitudeApprox": 25.03,
-  "pickupLongitudeApprox": 121.56,
-  "deliveryLatitudeApprox": 25.03,
-  "deliveryLongitudeApprox": 121.54,
+  "pinRequired": true,
+  "pickupAddress": "台北市信义区市府路1号",
+  "pickupDetailAddress": "3楼A室",
+  "deliveryAddress": "台北市大安区信义路三段56号",
+  "deliveryDetailAddress": "1楼",
+  "pickupDistanceMeters": 420,
   "distanceMeters": 4200,
   "estimatedDurationSeconds": 900,
   "amount": 90,
@@ -588,57 +610,25 @@ GET /system/flashDelivery/rider/orders/available?pageNum=1&pageSize=10&serviceTy
 }
 ```
 
-近似经纬度只保留两位小数,不得当作取件或送达导航终点。
-
-### 8.2 查询骑手自己的订单
-
-```http
-GET /system/flashDelivery/rider/orders/mine?pageNum=1&pageSize=10&scene=pickup
-```
-
-查询参数:
-
-| 参数 | 必填 | 说明 |
-|---|---:|---|
-| `pageNum` | 否 | 默认 `1` |
-| `pageSize` | 否 | 默认 `10`,最大 `100` |
-| `status` | 否 | 精确状态筛选 |
-| `scene` | 否 | 页面状态分组 |
+接单前列表可展示完整的取件、送达文字地址(包括详细地址),但不得返回联系人、电话、实际 PIN、精确坐标、用户备注、图片凭证、状态日志、用户 ID、幂等号或计价审计字段。列表中的文字地址不能作为精确坐标使用。
 
-`scene` 可选值:
-
-| 值 | 包含状态 |
-|---|---|
-| `all` 或不传 | 全部 |
-| `pickup` | `ACCEPTED` |
-| `delivering` | `PICKED_UP` |
-| `completed` | `DELIVERED`、`COMPLETED` |
-| `cancelled` | `CANCELLED` |
-
-`status` 和 `scene` 不能同时传。响应 `data.records` 中每项为完整订单摘要,包括联系人、地址和精确坐标。
-
-### 8.3 查询骑手订单详情
+### 8.2 查询骑手订单详情
 
 ```http
 GET /system/flashDelivery/rider/orders/{id}
 ```
 
-响应具有两种结构:
-
-- 订单仍待接且未被其他骑手接走:`data` 直接返回 8.1 的脱敏安全对象。
-- 当前骑手已经中单:`data` 返回 6.3 的完整详情对象。
-
-App 可通过 `data.order` 是否存在区分:存在表示完整详情,不存在表示接单前安全摘要。被其他骑手接走、预约时间未到或无权访问时返回业务错误。
+响应 `data` 始终直接返回订单字段,顶层结构保持稳定,不使用 `{order,images,logs}` 包装,也不要求 App 判断 `data.order`。接单前隐藏联系人、电话、实际 PIN、精确坐标和履约图片;当前骑手接单后,详情接口才补齐其有权限查看的完整履约信息。
 
-### 8.4 抢单
+### 8.3 抢单
 
 ```http
 POST /system/flashDelivery/rider/orders/{id}/accept
 ```
 
-无请求体。抢单成功后状态变为 `ACCEPTED`,响应 `data` 为完整详情对象;订单已被其他骑手抢走时返回“订单已被其他骑手接走”。
+无请求体。抢单成功后状态变为 `ACCEPTED`,响应 `data` 直接返回完整订单字段;订单已被其他骑手抢走时返回“订单已被其他骑手接走”。
 
-### 8.5 确认取件
+### 8.4 确认取件
 
 ```http
 POST /system/flashDelivery/rider/orders/{id}/pickup
@@ -656,7 +646,7 @@ POST /system/flashDelivery/rider/orders/{id}/pickup
 
 仅订单骑手可在 `ACCEPTED` 状态调用。图片至少 1 张、最多 9 张;成功后状态变为 `PICKED_UP`。
 
-### 8.6 确认送达
+### 8.5 确认送达
 
 ```http
 POST /system/flashDelivery/rider/orders/{id}/deliver
@@ -787,7 +777,7 @@ POST /system/address/{id}/top
 - 分页列表从 `data.records` 和 `data.total` 取值。
 - 图片先上传,再向闪送接口提交完整 HTTP(S) URL。
 - 用户只在待接单或已接单状态显示取消入口,只在已送达状态显示确认收货入口。
-- 骑手抢单前不使用近似坐标导航,抢单成功后再读取完整地址
+- 骑手抢单前可展示完整文字地址但不使用坐标导航;抢单成功后再读取联系人、电话、精确坐标和履约图片
 - 骑手严格按 `ACCEPTED -> PICKED_UP -> DELIVERED` 顺序操作。
 - 启用 PIN 时,骑手送达必须提交用户端详情显示的四位 PIN。
 - 对抢单冲突、状态变化和重复点击,均以刷新后的服务端订单状态为准。

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

@@ -10,6 +10,8 @@ import com.ruoyi.system.utils.Auth;
 import com.ruoyi.system.utils.JwtUtil;
 import org.springframework.web.bind.annotation.*;
 
+import java.math.BigDecimal;
+
 /** 骑手端闪送接口;仅 userType=2 的 token 可执行接单和履约操作。 */
 @RestController
 @RequestMapping("/system/flashDelivery/rider")
@@ -18,28 +20,19 @@ 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")
+    /** 按外卖订单同名页签查询可抢任务或当前骑手自己的任务。 */
+    @GetMapping("/orders")
     @Auth
-    public AjaxResult mine(@RequestHeader String token,
-                           @RequestParam(defaultValue = "1") int pageNum,
-                           @RequestParam(defaultValue = "10") int pageSize,
-                           @RequestParam(required = false) String status,
-                           @RequestParam(required = false) String scene) {
-        return success(service.riderOrders(userId(token), pageNum, pageSize, status, scene));
+    public AjaxResult orders(@RequestHeader String token,
+                             @RequestParam(defaultValue = "1") int page,
+                             @RequestParam(defaultValue = "10") int size,
+                             @RequestParam(defaultValue = "newTask") String tab,
+                             @RequestParam(required = false) BigDecimal longitude,
+                             @RequestParam(required = false) BigDecimal latitude) {
+        return success(service.riderOrders(userId(token), page, size, tab, longitude, latitude));
     }
 
-    /** 未接单时返回安全摘要,中单后仅中单骑手可见完整信息。 */
+    /** 接单前隐藏敏感字段;接单后仅中单骑手可见完整履约信息。 */
     @GetMapping("/orders/{id}")
     @Auth
     public AjaxResult detail(@RequestHeader String token, @PathVariable Long id) {

+ 4 - 7
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/controller/FlashDeliveryUserController.java

@@ -41,15 +41,12 @@ public class FlashDeliveryUserController extends BaseController {
     @GetMapping("/orders")
     @Auth
     public AjaxResult orders(@RequestHeader String token,
-                             @RequestParam(defaultValue = "1") int pageNum,
-                             @RequestParam(defaultValue = "10") int pageSize,
-                             @RequestParam(required = false) String status,
-                             @RequestParam(required = false) String scene,
-                             @RequestParam(required = false) String serviceType) {
-        return success(service.userOrders(userId(token), pageNum, pageSize, status, scene, serviceType));
+                             @RequestParam(defaultValue = "1") int page,
+                             @RequestParam(defaultValue = "10") int size) {
+        return success(service.userOrders(userId(token), page, size));
     }
 
-    /** 查询本人订单详情、履约图片和状态日志。 */
+    /** 查询本人订单详情,直接返回订单字段和按类型分组的履约图片。 */
     @GetMapping("/orders/{id}")
     @Auth
     public AjaxResult detail(@RequestHeader String token, @PathVariable Long id) {

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

@@ -1,35 +0,0 @@
-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 String packageType;
-    private String packageSize;
-    private String deliveryMode;
-    private Date scheduledPickupStartAt;
-    private Date scheduledPickupEndAt;
-    /** 接单前只返回市/区,不返回街道、门牌或联系人。 */
-    private String pickupCity;
-    private String pickupArea;
-    private String deliveryCity;
-    private String deliveryArea;
-    /** 仅保留两位小数的区域级坐标,接单前不返回精确地址。 */
-    private BigDecimal pickupLatitudeApprox;
-    private BigDecimal pickupLongitudeApprox;
-    private BigDecimal deliveryLatitudeApprox;
-    private BigDecimal deliveryLongitudeApprox;
-    private Integer distanceMeters;
-    private Integer estimatedDurationSeconds;
-    private Long amount;
-    private String currency;
-    private Date createTime;
-}

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

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

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

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

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

@@ -2,15 +2,18 @@ package com.ruoyi.app.flashdelivery.dto;
 
 import com.fasterxml.jackson.annotation.JsonInclude;
 import lombok.Data;
+import lombok.EqualsAndHashCode;
 
 import java.util.List;
 
-/** 用户或中单骑手详情;PIN 仅在订单用户响应中赋值。 */
+/** App 订单详情;data 直接序列化本对象,不再套 order/images/logs。 */
 @Data
-public class FlashDeliveryOrderDetailView {
-    private FlashDeliveryOrderView order;
-    private List<FlashDeliveryImageView> images;
-    private List<FlashDeliveryLogView> logs;
+@EqualsAndHashCode(callSuper = true)
+public class FlashDeliveryOrderDetailView extends FlashDeliveryOrderView {
+    private List<String> senderImageUrls = List.of();
+    private List<String> pickupImageUrls = List.of();
+    private List<String> deliveryImageUrls = List.of();
+    @JsonInclude(JsonInclude.Include.NON_NULL)
     private FlashDeliveryRiderSummaryView rider;
     @JsonInclude(JsonInclude.Include.NON_NULL)
     private String deliveryPinCode;

+ 30 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryRiderOrderListView.java

@@ -0,0 +1,30 @@
+package com.ruoyi.app.flashdelivery.dto;
+
+import lombok.Data;
+
+import java.util.Date;
+
+/** 骑手端闪送任务卡片,不包含联系人、电话、精确坐标或内部字段。 */
+@Data
+public class FlashDeliveryRiderOrderListView {
+    private Long id;
+    private String orderNo;
+    private String serviceType;
+    private String status;
+    private String packageType;
+    private String packageSize;
+    private String deliveryMode;
+    private Date scheduledPickupStartAt;
+    private Date scheduledPickupEndAt;
+    private Boolean pinRequired;
+    private String pickupAddress;
+    private String pickupDetailAddress;
+    private String deliveryAddress;
+    private String deliveryDetailAddress;
+    private Integer pickupDistanceMeters;
+    private Integer distanceMeters;
+    private Integer estimatedDurationSeconds;
+    private Long amount;
+    private String currency;
+    private Date createTime;
+}

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

@@ -0,0 +1,16 @@
+package com.ruoyi.app.flashdelivery.dto;
+
+import lombok.Data;
+
+import java.util.List;
+
+/** 骑手端分页结果及新任务页顶部摘要。 */
+@Data
+public class FlashDeliveryRiderOrderPageView {
+    private List<FlashDeliveryRiderOrderListView> records = List.of();
+    private long total;
+    private long current;
+    private long size;
+    private Long nearbyTaskCount;
+    private Long highestOrderAmount;
+}

+ 27 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryUserOrderListView.java

@@ -0,0 +1,27 @@
+package com.ruoyi.app.flashdelivery.dto;
+
+import lombok.Data;
+
+import java.util.Date;
+
+/** 用户端闪送订单卡片,仅包含列表页面所需字段。 */
+@Data
+public class FlashDeliveryUserOrderListView {
+    private Long id;
+    private String orderNo;
+    private String serviceType;
+    private String status;
+    private String packageType;
+    private String deliveryMode;
+    private Date scheduledPickupStartAt;
+    private Date scheduledPickupEndAt;
+    private String pickupAddress;
+    private String pickupDetailAddress;
+    private String deliveryAddress;
+    private String deliveryDetailAddress;
+    private Integer estimatedDurationSeconds;
+    private Long amount;
+    private String currency;
+    private Date deliveredAt;
+    private Date createTime;
+}

+ 186 - 101
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryApplicationService.java

@@ -8,7 +8,9 @@ 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.core.domain.entity.SysDictData;
 import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.common.utils.DictUtils;
 import com.ruoyi.common.utils.MessageUtils;
 import com.ruoyi.common.utils.StringUtils;
 import com.ruoyi.system.domain.InfoUser;
@@ -168,16 +170,10 @@ public class FlashDeliveryApplicationService {
         return participantDetail(order, true, false);
     }
 
-    public IPage<FlashDeliveryOrderView> userOrders(Long userId, int pageNum, int pageSize,
-                                                    String status, String scene, String serviceType) {
-        if (hasText(status) && hasText(scene)) throw fail("flash.delivery.scene.invalid");
+    public IPage<FlashDeliveryUserOrderListView> userOrders(Long userId, int pageNum, int pageSize) {
         QueryWrapper<FlashDeliveryOrder> query = new QueryWrapper<FlashDeliveryOrder>()
                 .eq("user_id", userId).orderByDesc("create_time");
-        optionalEq(query, "status", status);
-        applyUserScene(query, scene);
-        validateOptionalServiceType(serviceType);
-        optionalEq(query, "service_type", serviceType);
-        return mapPage(orderMapper.selectPage(page(pageNum, pageSize), query), this::orderView);
+        return mapPage(orderMapper.selectPage(page(pageNum, pageSize), query), this::userOrderListView);
     }
 
     public FlashDeliveryOrderDetailView userDetail(Long userId, Long orderId) {
@@ -210,41 +206,43 @@ public class FlashDeliveryApplicationService {
         writeLog(orderId, DELIVERED, COMPLETED, "USER", userId, null, now);
     }
 
-    public IPage<FlashDeliveryAvailableOrderView> available(Long riderId, int pageNum, int pageSize,
-                                                            String serviceType) {
+    public FlashDeliveryRiderOrderPageView riderOrders(Long riderId, int pageNum, int pageSize,
+                                                        String tab, BigDecimal longitude, BigDecimal latitude) {
         requireRider(riderId);
-        validateOptionalServiceType(serviceType);
-        QueryWrapper<FlashDeliveryOrder> query = new QueryWrapper<FlashDeliveryOrder>()
-                .eq("status", WAITING_ACCEPTANCE).isNull("rider_id")
-                .and(item -> item.eq("delivery_mode", "NOW")
-                        .or().le("scheduled_pickup_start_at", new Date()));
-        optionalEq(query, "service_type", serviceType);
-        // 第一版不自动派单:加急单优先,其余订单按发布时间由骑手主动抢单。
-        query.orderByAsc("case when service_type = 'URGENT' then 0 else 1 end", "create_time");
+        if (!hasText(tab)) throw fail("flash.delivery.tab.invalid");
+        String selectedTab = tab.trim();
+        // lambda 无法捕获被重新赋值的参数,统一改用仅 newTask 携带坐标的有效变量。
+        BigDecimal effectiveLongitude = "newTask".equals(selectedTab) ? longitude : null;
+        BigDecimal effectiveLatitude = "newTask".equals(selectedTab) ? latitude : null;
+        if ("newTask".equals(selectedTab)) {
+            validateRiderCoordinates(effectiveLongitude, effectiveLatitude);
+        }
+        QueryWrapper<FlashDeliveryOrder> query = riderOrdersQuery(riderId, selectedTab,
+                effectiveLongitude, effectiveLatitude, true);
         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());
+        FlashDeliveryRiderOrderPageView result = new FlashDeliveryRiderOrderPageView();
+        result.setRecords(orders.getRecords().stream()
+                .map(order -> riderOrderListView(order, effectiveLongitude, effectiveLatitude)).toList());
+        result.setTotal(orders.getTotal());
+        result.setCurrent(orders.getCurrent());
+        result.setSize(orders.getSize());
+        if ("newTask".equals(selectedTab)) {
+            result.setNearbyTaskCount(orders.getTotal());
+            QueryWrapper<FlashDeliveryOrder> maximumQuery = riderOrdersQuery(riderId, selectedTab,
+                    effectiveLongitude, effectiveLatitude, false)
+                    .select("MAX(amount)");
+            result.setHighestOrderAmount(firstLong(orderMapper.selectObjs(maximumQuery)));
+        }
         return result;
     }
 
-    public IPage<FlashDeliveryOrderView> riderOrders(Long riderId, int pageNum, int pageSize,
-                                                     String status, String scene) {
-        requireRider(riderId);
-        if (hasText(status) && hasText(scene)) throw fail("flash.delivery.scene.invalid");
-        QueryWrapper<FlashDeliveryOrder> query = new QueryWrapper<FlashDeliveryOrder>()
-                .eq("rider_id", riderId).orderByDesc("create_time");
-        optionalEq(query, "status", status);
-        applyRiderScene(query, scene);
-        return mapPage(orderMapper.selectPage(page(pageNum, pageSize), query), this::orderView);
-    }
-
-    public Object riderDetail(Long riderId, Long orderId) {
+    public FlashDeliveryOrderDetailView riderDetail(Long riderId, Long orderId) {
         requireRider(riderId);
         FlashDeliveryOrder order = requireOrder(orderId);
         if (WAITING_ACCEPTANCE.equals(order.getStatus()) && order.getRiderId() == null) {
             if (!isAvailableAt(order, new Date())) throw fail("flash.delivery.order.not.found");
-            // 未接单骑手只能看到白名单字段和近似坐标,联系人及精确地址在抢单成功后开放
-            return availableView(order);
+            // 接单前显示取送文字地址,但隐藏联系人、电话、精确坐标、实际 PIN 和履约图片。
+            return participantDetail(order, false, true);
         }
         if (!Objects.equals(riderId, order.getRiderId())) throw fail("flash.delivery.order.not.found");
         return participantDetail(order, false, false);
@@ -566,30 +564,44 @@ public class FlashDeliveryApplicationService {
 
     private FlashDeliveryOrderDetailView participantDetail(FlashDeliveryOrder order,
                                                            boolean userView,
-                                                           boolean hideEvidence) {
-        FlashDeliveryOrderDetailView view = new FlashDeliveryOrderDetailView();
-        view.setOrder(orderView(order));
+                                                           boolean hideSensitiveFields) {
+        FlashDeliveryOrderDetailView view = detailOrderView(order, !hideSensitiveFields);
         if (userView) {
             view.setDeliveryPinCode(Boolean.TRUE.equals(order.getPinRequired()) ? order.getDeliveryPinCode() : null);
             view.setRider(riderSummary(order));
         }
-        if (hideEvidence || order.getId() == null) {
-            view.setImages(List.of());
-            view.setLogs(List.of());
-        } else {
-            view.setImages(imageMapper.selectList(new QueryWrapper<FlashDeliveryOrderImage>()
-                            .eq("order_id", order.getId()).orderByAsc("proof_type", "sort_order"))
-                    .stream().map(this::imageView).toList());
-            view.setLogs(logMapper.selectList(new QueryWrapper<FlashDeliveryOrderLog>()
-                            .eq("order_id", order.getId()).orderByAsc("create_time", "id"))
-                    .stream().map(this::logView).toList());
+        if (!hideSensitiveFields && order.getId() != null) {
+            groupImageUrls(view, imageMapper.selectList(new QueryWrapper<FlashDeliveryOrderImage>()
+                    .eq("order_id", order.getId()).orderByAsc("proof_type", "sort_order")));
         }
         return view;
     }
 
-    private FlashDeliveryAvailableOrderView availableView(FlashDeliveryOrder order) {
-        // 明确逐字段组装白名单 DTO,禁止直接复制订单实体造成未来新增敏感字段意外泄露。
-        FlashDeliveryAvailableOrderView view = new FlashDeliveryAvailableOrderView();
+    private FlashDeliveryUserOrderListView userOrderListView(FlashDeliveryOrder order) {
+        FlashDeliveryUserOrderListView view = new FlashDeliveryUserOrderListView();
+        view.setId(order.getId());
+        view.setOrderNo(order.getOrderNo());
+        view.setServiceType(order.getServiceType());
+        view.setStatus(order.getStatus());
+        view.setPackageType(order.getPackageType());
+        view.setDeliveryMode(order.getDeliveryMode());
+        view.setScheduledPickupStartAt(order.getScheduledPickupStartAt());
+        view.setScheduledPickupEndAt(order.getScheduledPickupEndAt());
+        view.setPickupAddress(order.getPickupAddress());
+        view.setPickupDetailAddress(order.getPickupAddressDetail());
+        view.setDeliveryAddress(order.getDeliveryAddress());
+        view.setDeliveryDetailAddress(order.getDeliveryAddressDetail());
+        view.setEstimatedDurationSeconds(order.getEstimatedDurationSeconds());
+        view.setAmount(order.getAmount());
+        view.setCurrency(order.getCurrency());
+        view.setDeliveredAt(order.getDeliveredAt());
+        view.setCreateTime(order.getCreateTime());
+        return view;
+    }
+
+    private FlashDeliveryRiderOrderListView riderOrderListView(FlashDeliveryOrder order,
+                                                                 BigDecimal longitude, BigDecimal latitude) {
+        FlashDeliveryRiderOrderListView view = new FlashDeliveryRiderOrderListView();
         view.setId(order.getId());
         view.setOrderNo(order.getOrderNo());
         view.setServiceType(order.getServiceType());
@@ -599,14 +611,13 @@ public class FlashDeliveryApplicationService {
         view.setDeliveryMode(order.getDeliveryMode());
         view.setScheduledPickupStartAt(order.getScheduledPickupStartAt());
         view.setScheduledPickupEndAt(order.getScheduledPickupEndAt());
-        view.setPickupCity(order.getPickupCity());
-        view.setPickupArea(order.getPickupArea());
-        view.setDeliveryCity(order.getDeliveryCity());
-        view.setDeliveryArea(order.getDeliveryArea());
-        view.setPickupLatitudeApprox(approximate(order.getPickupLatitude()));
-        view.setPickupLongitudeApprox(approximate(order.getPickupLongitude()));
-        view.setDeliveryLatitudeApprox(approximate(order.getDeliveryLatitude()));
-        view.setDeliveryLongitudeApprox(approximate(order.getDeliveryLongitude()));
+        view.setPinRequired(order.getPinRequired());
+        view.setPickupAddress(order.getPickupAddress());
+        view.setPickupDetailAddress(order.getPickupAddressDetail());
+        view.setDeliveryAddress(order.getDeliveryAddress());
+        view.setDeliveryDetailAddress(order.getDeliveryAddressDetail());
+        view.setPickupDistanceMeters(distanceMeters(longitude, latitude,
+                order.getPickupLongitude(), order.getPickupLatitude()));
         view.setDistanceMeters(order.getDistanceMeters());
         view.setEstimatedDurationSeconds(order.getEstimatedDurationSeconds());
         view.setAmount(order.getAmount());
@@ -615,6 +626,26 @@ public class FlashDeliveryApplicationService {
         return view;
     }
 
+    private FlashDeliveryOrderDetailView detailOrderView(FlashDeliveryOrder order, boolean includeSensitiveFields) {
+        FlashDeliveryOrderDetailView view = new FlashDeliveryOrderDetailView();
+        populateOrderView(view, order, includeSensitiveFields);
+        return view;
+    }
+
+    private void groupImageUrls(FlashDeliveryOrderDetailView view, List<FlashDeliveryOrderImage> images) {
+        List<String> sender = new ArrayList<>();
+        List<String> pickup = new ArrayList<>();
+        List<String> delivery = new ArrayList<>();
+        for (FlashDeliveryOrderImage image : images) {
+            if ("SENDER".equals(image.getProofType())) sender.add(image.getImageUrl());
+            else if ("PICKUP".equals(image.getProofType())) pickup.add(image.getImageUrl());
+            else if ("DELIVERY".equals(image.getProofType())) delivery.add(image.getImageUrl());
+        }
+        view.setSenderImageUrls(sender);
+        view.setPickupImageUrls(pickup);
+        view.setDeliveryImageUrls(delivery);
+    }
+
     private FlashDeliveryServiceView serviceView(FlashDeliveryPricing pricing) {
         FlashDeliveryServiceView view = new FlashDeliveryServiceView();
         view.setServiceType(pricing.getServiceType());
@@ -630,8 +661,8 @@ public class FlashDeliveryApplicationService {
         return view;
     }
 
-    private FlashDeliveryOrderView orderView(FlashDeliveryOrder order) {
-        FlashDeliveryOrderView view = new FlashDeliveryOrderView();
+    private void populateOrderView(FlashDeliveryOrderView view, FlashDeliveryOrder order,
+                                   boolean includeSensitiveFields) {
         view.setId(order.getId());
         view.setOrderNo(order.getOrderNo());
         view.setServiceType(order.getServiceType());
@@ -642,14 +673,14 @@ public class FlashDeliveryApplicationService {
         view.setScheduledPickupStartAt(order.getScheduledPickupStartAt());
         view.setScheduledPickupEndAt(order.getScheduledPickupEndAt());
         view.setPinRequired(order.getPinRequired());
-        view.setPickup(addressView(order, true));
-        view.setDelivery(addressView(order, false));
+        view.setPickup(addressView(order, true, includeSensitiveFields));
+        view.setDelivery(addressView(order, false, includeSensitiveFields));
         view.setDistanceMeters(order.getDistanceMeters());
         view.setDistanceSource(order.getDistanceSource());
         view.setEstimatedDurationSeconds(order.getEstimatedDurationSeconds());
         view.setAmount(order.getAmount());
         view.setCurrency(order.getCurrency());
-        view.setUserNote(order.getUserNote());
+        view.setUserNote(includeSensitiveFields ? order.getUserNote() : null);
         view.setAcceptedAt(order.getAcceptedAt());
         view.setPickedUpAt(order.getPickedUpAt());
         view.setDeliveredAt(order.getDeliveredAt());
@@ -658,42 +689,31 @@ public class FlashDeliveryApplicationService {
         view.setCancelReason(order.getCancelReason());
         view.setCreateTime(order.getCreateTime());
         view.setUpdateTime(order.getUpdateTime());
-        return view;
     }
 
-    private FlashDeliveryAddressView addressView(FlashDeliveryOrder order, boolean pickup) {
+    private FlashDeliveryAddressView addressView(FlashDeliveryOrder order, boolean pickup,
+                                                 boolean includeSensitiveFields) {
         FlashDeliveryAddressView view = new FlashDeliveryAddressView();
         if (pickup) {
-            view.setName(order.getPickupName()); view.setPhone(order.getPickupPhone());
+            view.setName(includeSensitiveFields ? order.getPickupName() : null);
+            view.setPhone(includeSensitiveFields ? order.getPickupPhone() : null);
             view.setAddress(order.getPickupAddress()); view.setAddressDetail(order.getPickupAddressDetail());
             view.setCity(order.getPickupCity()); view.setArea(order.getPickupArea());
             view.setHandoffMethod(order.getPickupHandoffMethod());
-            view.setLongitude(order.getPickupLongitude()); view.setLatitude(order.getPickupLatitude());
+            view.setLongitude(includeSensitiveFields ? order.getPickupLongitude() : null);
+            view.setLatitude(includeSensitiveFields ? order.getPickupLatitude() : null);
         } else {
-            view.setName(order.getDeliveryName()); view.setPhone(order.getDeliveryPhone());
+            view.setName(includeSensitiveFields ? order.getDeliveryName() : null);
+            view.setPhone(includeSensitiveFields ? order.getDeliveryPhone() : null);
             view.setAddress(order.getDeliveryAddress()); view.setAddressDetail(order.getDeliveryAddressDetail());
             view.setCity(order.getDeliveryCity()); view.setArea(order.getDeliveryArea());
             view.setHandoffMethod(order.getDeliveryHandoffMethod());
-            view.setLongitude(order.getDeliveryLongitude()); view.setLatitude(order.getDeliveryLatitude());
+            view.setLongitude(includeSensitiveFields ? order.getDeliveryLongitude() : null);
+            view.setLatitude(includeSensitiveFields ? order.getDeliveryLatitude() : null);
         }
         return view;
     }
 
-    private FlashDeliveryImageView imageView(FlashDeliveryOrderImage image) {
-        FlashDeliveryImageView view = new FlashDeliveryImageView();
-        view.setProofType(image.getProofType()); view.setImageUrl(image.getImageUrl());
-        view.setSortOrder(image.getSortOrder()); view.setCreateTime(image.getCreateTime());
-        return view;
-    }
-
-    private FlashDeliveryLogView logView(FlashDeliveryOrderLog log) {
-        FlashDeliveryLogView view = new FlashDeliveryLogView();
-        view.setFromStatus(log.getFromStatus()); view.setToStatus(log.getToStatus());
-        view.setOperatorType(log.getOperatorType()); view.setReason(log.getReason());
-        view.setCreateTime(log.getCreateTime());
-        return view;
-    }
-
     private FlashDeliveryRiderSummaryView riderSummary(FlashDeliveryOrder order) {
         if (order.getRiderId() == null) return null;
         InfoUser rider = userMapper.selectInfoUserByUserId(order.getRiderId());
@@ -797,28 +817,94 @@ public class FlashDeliveryApplicationService {
         return result;
     }
 
-    private void applyUserScene(QueryWrapper<FlashDeliveryOrder> query, String scene) {
-        if (!hasText(scene) || "all".equals(scene)) return;
-        switch (scene.trim()) {
-            case "matching" -> query.eq("status", WAITING_ACCEPTANCE);
-            case "progress" -> query.in("status", ACCEPTED, PICKED_UP, DELIVERED);
-            case "done" -> query.eq("status", COMPLETED);
-            case "cancelled" -> query.eq("status", CANCELLED);
-            default -> throw fail("flash.delivery.scene.invalid");
+    private QueryWrapper<FlashDeliveryOrder> riderOrdersQuery(Long riderId, String tab,
+                                                                BigDecimal longitude, BigDecimal latitude,
+                                                                boolean orderResults) {
+        QueryWrapper<FlashDeliveryOrder> query = new QueryWrapper<>();
+        Date now = new Date();
+        switch (tab) {
+            case "newTask" -> {
+                query.eq("status", WAITING_ACCEPTANCE).isNull("rider_id")
+                        .and(item -> item.eq("delivery_mode", "NOW")
+                                .or().le("scheduled_pickup_start_at", now));
+                if (longitude != null) {
+                    Integer limitKm = newTaskDistanceLimit();
+                    if (limitKm != null) {
+                        query.apply("ST_Distance_Sphere(point(pickup_longitude, pickup_latitude), point({0}, {1})) <= {2}",
+                                longitude, latitude, limitKm.longValue() * 1000L);
+                    }
+                    if (orderResults) {
+                        query.orderByAsc("ST_Distance_Sphere(point(pickup_longitude, pickup_latitude), point(" + longitude
+                                + ", " + latitude + "))");
+                    }
+                } else if (orderResults) {
+                    query.orderByDesc("create_time");
+                }
+            }
+            case "toPickup" -> {
+                query.eq("rider_id", riderId).eq("status", ACCEPTED);
+                if (orderResults) query.orderByAsc("create_time");
+            }
+            case "delivering" -> {
+                query.eq("rider_id", riderId).eq("status", PICKED_UP);
+                if (orderResults) query.orderByAsc("create_time");
+            }
+            case "completed" -> {
+                query.eq("rider_id", riderId).in("status", DELIVERED, COMPLETED);
+                if (orderResults) query.orderByDesc("create_time");
+            }
+            case "cancelled" -> {
+                query.eq("rider_id", riderId).eq("status", CANCELLED);
+                if (orderResults) query.orderByDesc("create_time");
+            }
+            default -> throw fail("flash.delivery.tab.invalid");
         }
+        return query;
     }
 
-    private void applyRiderScene(QueryWrapper<FlashDeliveryOrder> query, String scene) {
-        if (!hasText(scene) || "all".equals(scene)) return;
-        switch (scene.trim()) {
-            case "pickup" -> query.eq("status", ACCEPTED);
-            case "delivering" -> query.eq("status", PICKED_UP);
-            case "completed" -> query.in("status", DELIVERED, COMPLETED);
-            case "cancelled" -> query.eq("status", CANCELLED);
-            default -> throw fail("flash.delivery.scene.invalid");
+    private void validateRiderCoordinates(BigDecimal longitude, BigDecimal latitude) {
+        if ((longitude == null) != (latitude == null)
+                || longitude != null && (longitude.compareTo(BigDecimal.valueOf(-180)) < 0
+                || longitude.compareTo(BigDecimal.valueOf(180)) > 0
+                || latitude.compareTo(BigDecimal.valueOf(-90)) < 0
+                || latitude.compareTo(BigDecimal.valueOf(90)) > 0)) {
+            throw fail("flash.delivery.coordinates.invalid");
         }
     }
 
+    private Integer newTaskDistanceLimit() {
+        try {
+            List<SysDictData> values = DictUtils.getDictCache("sys_qs_newtask_distance");
+            if (values != null && !values.isEmpty()) {
+                int limit = Integer.parseInt(values.get(0).getDictValue());
+                return limit > 0 ? limit : null;
+            }
+        } catch (Exception ignored) {
+            // Keep the established rider-list behaviour: absent or malformed configuration disables distance filtering.
+        }
+        return null;
+    }
+
+    private Integer distanceMeters(BigDecimal longitude, BigDecimal latitude,
+                                   BigDecimal targetLongitude, BigDecimal targetLatitude) {
+        if (longitude == null || targetLongitude == null || targetLatitude == null) return null;
+        double latitudeRadians = Math.toRadians(latitude.doubleValue());
+        double targetLatitudeRadians = Math.toRadians(targetLatitude.doubleValue());
+        double latitudeDelta = targetLatitudeRadians - latitudeRadians;
+        double longitudeDelta = Math.toRadians(targetLongitude.doubleValue() - longitude.doubleValue());
+        double value = Math.sin(latitudeDelta / 2) * Math.sin(latitudeDelta / 2)
+                + Math.cos(latitudeRadians) * Math.cos(targetLatitudeRadians)
+                * Math.sin(longitudeDelta / 2) * Math.sin(longitudeDelta / 2);
+        return (int) Math.round(6_371_000D * 2D * Math.atan2(Math.sqrt(value), Math.sqrt(1D - value)));
+    }
+
+    private Long firstLong(List<Object> values) {
+        if (values == null || values.isEmpty() || values.get(0) == null) return null;
+        Object value = values.get(0);
+        if (value instanceof Number number) return number.longValue();
+        return Long.valueOf(String.valueOf(value));
+    }
+
     private String normalizeDeliveryMode(String value) {
         String mode = hasText(value) ? value.trim() : "NOW";
         if (!DELIVERY_MODES.contains(mode)) throw fail("flash.delivery.mode.invalid");
@@ -851,7 +937,6 @@ public class FlashDeliveryApplicationService {
     private boolean hasText(String value) { return StringUtils.isNotEmpty(value) && !value.trim().isEmpty(); }
     private String trimToNull(String value) { return hasText(value) ? value.trim() : null; }
     private boolean tooLong(String value, int max) { return value != null && value.trim().length() > max; }
-    private BigDecimal approximate(BigDecimal value) { return value == null ? null : value.setScale(2, RoundingMode.HALF_UP); }
     private boolean sameAddressText(FlashDeliveryAddressRequest left, FlashDeliveryAddressRequest right) {
         return normalized(left.getAddress()).equals(normalized(right.getAddress()))
                 && normalized(left.getAddressDetail()).equals(normalized(right.getAddressDetail()));

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

@@ -316,5 +316,7 @@ flash.delivery.pin.invalid=Invalid delivery PIN
 flash.delivery.scene.invalid=Invalid order status group
 flash.delivery.pricing.not.found=Pricing configuration does not exist
 flash.delivery.pricing.changed=Pricing configuration has changed, please refresh and retry
+flash.delivery.tab.invalid=Invalid flash delivery list tab
+flash.delivery.coordinates.invalid=Latitude and longitude must be provided in pairs and within valid ranges
 address.access.denied=Address does not exist or is not accessible
 address.data.invalid=Contact, address, or coordinates are invalid

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

@@ -319,5 +319,7 @@ flash.delivery.pin.invalid=Invalid delivery PIN
 flash.delivery.scene.invalid=Invalid order status group
 flash.delivery.pricing.not.found=Pricing configuration does not exist
 flash.delivery.pricing.changed=Pricing configuration has changed, please refresh and retry
+flash.delivery.tab.invalid=Invalid flash delivery list tab
+flash.delivery.coordinates.invalid=Latitude and longitude must be provided in pairs and within valid ranges
 address.access.denied=Address does not exist or is not accessible
 address.data.invalid=Contact, address, or coordinates are invalid

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

@@ -319,5 +319,7 @@ flash.delivery.pin.invalid=Mã PIN giao hàng không chính xác
 flash.delivery.scene.invalid=Nhóm trạng thái đơn hàng không hợp lệ
 flash.delivery.pricing.not.found=Không tìm thấy cấu hình giá
 flash.delivery.pricing.changed=Cấu hình giá đã thay đổi, vui lòng tải lại và thử lại
+flash.delivery.tab.invalid=Tab danh sách giao hàng nhanh không hợp lệ
+flash.delivery.coordinates.invalid=Vĩ độ và kinh độ phải được cung cấp theo cặp và nằm trong phạm vi hợp lệ
 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ệ

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

@@ -320,5 +320,7 @@ flash.delivery.pin.invalid=交付PIN不正确
 flash.delivery.scene.invalid=订单状态分组参数无效
 flash.delivery.pricing.not.found=计价配置不存在
 flash.delivery.pricing.changed=计价配置已发生变化,请刷新后重试
+flash.delivery.tab.invalid=闪送列表页签无效
+flash.delivery.coordinates.invalid=经纬度必须成对提供且范围有效
 address.access.denied=地址不存在或无权访问
 address.data.invalid=联系人、地址或经纬度信息无效

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

@@ -320,5 +320,7 @@ flash.delivery.pin.invalid=交付PIN不正確
 flash.delivery.scene.invalid=訂單狀態分組參數無效
 flash.delivery.pricing.not.found=計價設定不存在
 flash.delivery.pricing.changed=計價設定已發生變更,請重新整理後重試
+flash.delivery.tab.invalid=閃送列表頁籤無效
+flash.delivery.coordinates.invalid=經緯度必須成對提供且範圍有效
 address.access.denied=地址不存在或無權存取
 address.data.invalid=聯絡人、地址或經緯度資訊無效

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

@@ -1,21 +1,57 @@
 package com.ruoyi.app.flashdelivery.controller;
 
 import com.ruoyi.app.flashdelivery.dto.FlashDeliveryCreateRequest;
+import com.ruoyi.app.flashdelivery.dto.FlashDeliveryOrderDetailView;
 import com.ruoyi.app.flashdelivery.dto.FlashDeliveryProofRequest;
 import com.ruoyi.app.flashdelivery.dto.FlashDeliveryDeliverRequest;
+import com.ruoyi.app.flashdelivery.service.FlashDeliveryApplicationService;
+import com.ruoyi.common.core.domain.AjaxResult;
+import com.ruoyi.common.utils.spring.SpringUtils;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
 import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.config.ConfigurableListableBeanFactory;
+import org.springframework.beans.factory.support.DefaultListableBeanFactory;
+import org.springframework.context.support.StaticMessageSource;
 import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.test.util.ReflectionTestUtils;
 import org.springframework.web.bind.annotation.RequestBody;
 import org.springframework.web.bind.annotation.RequestHeader;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.GetMapping;
 import com.ruoyi.common.annotation.Log;
 import com.ruoyi.system.utils.Auth;
+import com.ruoyi.system.utils.JwtUtil;
 
 import java.lang.reflect.Method;
+import java.math.BigDecimal;
 
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
 import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
 
 class FlashDeliveryControllerContractTest {
+    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 appControllersReadIdentityFromTokenAndUseRequestBodies() throws Exception {
@@ -34,6 +70,50 @@ class FlashDeliveryControllerContractTest {
         assertNotNull(deliver.getParameters()[2].getAnnotation(RequestBody.class));
     }
 
+    @Test
+    void appOrderListsUseSimplePageAndRiderFoodOrderParameters() throws Exception {
+        Method userOrders = FlashDeliveryUserController.class.getMethod("orders",
+                String.class, int.class, int.class);
+        assertNotNull(userOrders.getParameters()[0].getAnnotation(RequestHeader.class));
+        assertNotNull(userOrders.getParameters()[1].getAnnotation(RequestParam.class));
+        assertNotNull(userOrders.getParameters()[2].getAnnotation(RequestParam.class));
+
+        Method riderOrders = FlashDeliveryRiderController.class.getMethod("orders",
+                String.class, int.class, int.class, String.class, BigDecimal.class, BigDecimal.class);
+        assertArrayEquals(new String[]{"/orders"}, riderOrders.getAnnotation(GetMapping.class).value());
+        for (int index = 1; index < riderOrders.getParameterCount(); index++) {
+            assertNotNull(riderOrders.getParameters()[index].getAnnotation(RequestParam.class));
+        }
+
+        assertThrows(NoSuchMethodException.class,
+                () -> FlashDeliveryRiderController.class.getMethod("available",
+                        String.class, int.class, int.class, String.class));
+        assertThrows(NoSuchMethodException.class,
+                () -> FlashDeliveryRiderController.class.getMethod("mine",
+                        String.class, int.class, int.class, String.class, String.class));
+    }
+
+    @Test
+    void appDetailAndAcceptPutDirectOrderDtoInAjaxData() {
+        FlashDeliveryApplicationService service = mock(FlashDeliveryApplicationService.class);
+        FlashDeliveryOrderDetailView detail = new FlashDeliveryOrderDetailView();
+        detail.setId(10L);
+        when(service.userDetail(7L, 10L)).thenReturn(detail);
+        when(service.riderDetail(8L, 10L)).thenReturn(detail);
+        when(service.accept(8L, 10L)).thenReturn(detail);
+
+        AjaxResult userResult = new FlashDeliveryUserController(service)
+                .detail(JwtUtil.token("7", "user"), 10L);
+        AjaxResult riderResult = new FlashDeliveryRiderController(service)
+                .detail(JwtUtil.token("8", "rider"), 10L);
+        AjaxResult acceptResult = new FlashDeliveryRiderController(service)
+                .accept(JwtUtil.token("8", "rider"), 10L);
+
+        assertSame(detail, userResult.get(AjaxResult.DATA_TAG));
+        assertSame(detail, riderResult.get(AjaxResult.DATA_TAG));
+        assertSame(detail, acceptResult.get(AjaxResult.DATA_TAG));
+    }
+
     @Test
     void everyAdminEndpointDeclaresPlatformPermission() {
         for (Method method : FlashDeliveryAdminController.class.getDeclaredMethods()) {

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

@@ -1,36 +0,0 @@
-package com.ruoyi.app.flashdelivery.dto;
-
-import org.junit.jupiter.api.Test;
-
-import java.util.Set;
-import java.util.stream.Collectors;
-import java.util.stream.Stream;
-
-import static org.junit.jupiter.api.Assertions.assertFalse;
-import static org.junit.jupiter.api.Assertions.assertTrue;
-
-class 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);
-        }
-    }
-
-    @Test
-    void publicRiderViewContainsOnlySafeFieldsNeededBySupplementaryPrototype() {
-        Set<String> fields = Stream.of(FlashDeliveryAvailableOrderView.class.getDeclaredFields())
-                .map(java.lang.reflect.Field::getName).collect(Collectors.toSet());
-        for (String required : Set.of("packageType", "packageSize", "deliveryMode",
-                "scheduledPickupStartAt", "scheduledPickupEndAt", "pickupCity", "pickupArea",
-                "deliveryCity", "deliveryArea", "estimatedDurationSeconds")) {
-            assertTrue(fields.contains(required), required);
-        }
-        assertFalse(fields.contains("deliveryPinCode"));
-    }
-}

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

@@ -23,4 +23,18 @@ class FlashDeliveryOrderViewTest {
             assertTrue(fields.contains(required), required);
         }
     }
+
+    @Test
+    void appDetailIsDirectOrderDataWithExplicitImageArrays() {
+        Set<String> fields = Stream.of(FlashDeliveryOrderDetailView.class.getDeclaredFields())
+                .map(java.lang.reflect.Field::getName).collect(Collectors.toSet());
+        for (String forbidden : Set.of("order", "images", "logs")) {
+            assertFalse(fields.contains(forbidden), forbidden);
+        }
+        for (String required : Set.of("senderImageUrls", "pickupImageUrls",
+                "deliveryImageUrls", "rider", "deliveryPinCode")) {
+            assertTrue(fields.contains(required), required);
+        }
+        assertTrue(FlashDeliveryOrderView.class.isAssignableFrom(FlashDeliveryOrderDetailView.class));
+    }
 }

+ 40 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryRiderOrderListViewTest.java

@@ -0,0 +1,40 @@
+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;
+
+class FlashDeliveryRiderOrderListViewTest {
+    @Test
+    void userListContainsOnlyFieldsRequiredByOrderCards() {
+        Set<String> fields = Stream.of(FlashDeliveryUserOrderListView.class.getDeclaredFields())
+                .map(java.lang.reflect.Field::getName).collect(Collectors.toSet());
+        Set<String> expected = Set.of("id", "orderNo", "serviceType", "status", "packageType",
+                "deliveryMode", "scheduledPickupStartAt", "scheduledPickupEndAt", "pickupAddress",
+                "pickupDetailAddress", "deliveryAddress", "deliveryDetailAddress",
+                "estimatedDurationSeconds", "amount", "currency", "deliveredAt", "createTime");
+        org.junit.jupiter.api.Assertions.assertEquals(expected, fields);
+    }
+
+    @Test
+    void riderListContainsOnlyFieldsRequiredByOrderCards() {
+        Set<String> fields = Stream.of(FlashDeliveryRiderOrderListView.class.getDeclaredFields())
+                .map(java.lang.reflect.Field::getName).collect(Collectors.toSet());
+        Set<String> expected = Set.of("id", "orderNo", "serviceType", "status", "packageType",
+                "packageSize", "deliveryMode", "scheduledPickupStartAt", "scheduledPickupEndAt",
+                "pinRequired", "pickupAddress", "pickupDetailAddress", "deliveryAddress",
+                "deliveryDetailAddress", "pickupDistanceMeters", "distanceMeters",
+                "estimatedDurationSeconds", "amount", "currency", "createTime");
+        org.junit.jupiter.api.Assertions.assertEquals(expected, fields);
+    }
+
+    @Test
+    void riderPageContainsOnlyPagingAndHeaderSummary() {
+        Set<String> fields = Stream.of(FlashDeliveryRiderOrderPageView.class.getDeclaredFields())
+                .map(java.lang.reflect.Field::getName).collect(Collectors.toSet());
+        org.junit.jupiter.api.Assertions.assertEquals(Set.of("records", "total", "current", "size",
+                "nearbyTaskCount", "highestOrderAmount"), fields);
+    }
+}

+ 254 - 7
ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryApplicationServiceTest.java

@@ -8,10 +8,12 @@ import com.ruoyi.app.flashdelivery.dto.FlashDeliveryProofRequest;
 import com.ruoyi.app.flashdelivery.dto.FlashDeliveryPricingRequest;
 import com.ruoyi.app.flashdelivery.dto.FlashDeliveryQuoteRequest;
 import com.ruoyi.app.flashdelivery.dto.FlashDeliveryDeliverRequest;
+import com.ruoyi.app.flashdelivery.dto.FlashDeliveryRiderOrderPageView;
 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.FlashDeliveryOrderImage;
 import com.ruoyi.system.domain.flash.FlashDeliveryPricing;
 import com.ruoyi.system.mapper.InfoUserMapper;
 import com.ruoyi.system.mapper.flash.FlashDeliveryOrderImageMapper;
@@ -23,9 +25,12 @@ import org.junit.jupiter.api.BeforeAll;
 import org.junit.jupiter.api.AfterAll;
 import org.mockito.ArgumentCaptor;
 import org.mockito.InOrder;
+import org.mockito.MockedStatic;
 import org.springframework.beans.factory.config.ConfigurableListableBeanFactory;
 import org.springframework.beans.factory.support.DefaultListableBeanFactory;
 import org.springframework.context.support.StaticMessageSource;
+import com.ruoyi.common.core.domain.entity.SysDictData;
+import com.ruoyi.common.utils.DictUtils;
 import com.ruoyi.common.utils.spring.SpringUtils;
 import org.springframework.test.util.ReflectionTestUtils;
 
@@ -35,6 +40,8 @@ import java.util.Date;
 
 import static com.ruoyi.system.domain.flash.FlashDeliveryStatus.ACCEPTED;
 import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
 import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 import com.ruoyi.common.exception.ServiceException;
@@ -42,6 +49,7 @@ 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.mockStatic;
 import static org.mockito.Mockito.never;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
@@ -182,21 +190,189 @@ class FlashDeliveryApplicationServiceTest {
 
     @Test
     @SuppressWarnings({"rawtypes", "unchecked"})
-    void userProgressSceneFiltersAllThreeInProgressStatusesBeforePaging() {
+    void userListOnlyFiltersByOwnerAndReturnsCardFields() {
         Fixture fixture = new Fixture();
+        FlashDeliveryOrder order = order(10L, "COMPLETED");
+        Page<FlashDeliveryOrder> source = new Page<>(1, 10, 1);
+        source.setRecords(List.of(order));
         when(fixture.orderMapper.selectPage(any(com.baomidou.mybatisplus.core.metadata.IPage.class),
-                any(Wrapper.class))).thenReturn(new Page<FlashDeliveryOrder>());
+                any(Wrapper.class))).thenReturn(source);
 
-        fixture.service.userOrders(7L, 1, 10, null, "progress", null);
+        var result = fixture.service.userOrders(7L, 1, 10);
 
         ArgumentCaptor<com.baomidou.mybatisplus.core.conditions.query.QueryWrapper> query =
                 ArgumentCaptor.forClass(com.baomidou.mybatisplus.core.conditions.query.QueryWrapper.class);
         verify(fixture.orderMapper).selectPage(any(com.baomidou.mybatisplus.core.metadata.IPage.class), query.capture());
-        query.getValue().getSqlSegment();
+        String sql = query.getValue().getSqlSegment().toLowerCase();
         var parameters = query.getValue().getParamNameValuePairs().values();
-        assertTrue(parameters.contains("ACCEPTED"));
-        assertTrue(parameters.contains("PICKED_UP"));
-        assertTrue(parameters.contains("DELIVERED"));
+        assertTrue(sql.contains("user_id"));
+        assertFalse(sql.contains("status"));
+        assertFalse(sql.contains("service_type"));
+        assertTrue(parameters.contains(7L));
+        assertEquals("pickup road", result.getRecords().get(0).getPickupAddress());
+        assertEquals(112L, result.getRecords().get(0).getAmount());
+    }
+
+    @Test
+    @SuppressWarnings({"rawtypes", "unchecked"})
+    void riderNewTaskUsesFoodOrderParametersAndReturnsPrototypeCardSummary() {
+        Fixture fixture = new Fixture();
+        rider(fixture, 8L);
+        FlashDeliveryOrder order = order(10L, "WAITING_ACCEPTANCE");
+        Page<FlashDeliveryOrder> source = new Page<>(1, 10, 1);
+        source.setRecords(List.of(order));
+        when(fixture.orderMapper.selectPage(any(com.baomidou.mybatisplus.core.metadata.IPage.class),
+                any(Wrapper.class))).thenReturn(source);
+        when(fixture.orderMapper.selectObjs(any(Wrapper.class))).thenReturn(List.of(168L));
+
+        SysDictData distanceLimit = new SysDictData();
+        distanceLimit.setDictValue("5");
+        FlashDeliveryRiderOrderPageView result;
+        try (MockedStatic<DictUtils> dictionary = mockStatic(DictUtils.class)) {
+            dictionary.when(() -> DictUtils.getDictCache("sys_qs_newtask_distance"))
+                    .thenReturn(List.of(distanceLimit));
+            result = fixture.service.riderOrders(8L, 1, 10, "newTask",
+                    new BigDecimal("121.5000"), new BigDecimal("25.0000"));
+        }
+
+        assertEquals(1L, result.getNearbyTaskCount());
+        assertEquals(168L, result.getHighestOrderAmount());
+        assertEquals("pickup road", result.getRecords().get(0).getPickupAddress());
+        assertEquals("delivery road", result.getRecords().get(0).getDeliveryAddress());
+        assertTrue(result.getRecords().get(0).getPickupDistanceMeters() > 0);
+        assertTrue(result.getRecords().get(0).getPinRequired());
+        ArgumentCaptor<com.baomidou.mybatisplus.core.conditions.query.QueryWrapper> query =
+                ArgumentCaptor.forClass(com.baomidou.mybatisplus.core.conditions.query.QueryWrapper.class);
+        verify(fixture.orderMapper).selectPage(any(com.baomidou.mybatisplus.core.metadata.IPage.class), query.capture());
+        String sql = query.getValue().getSqlSegment().toLowerCase();
+        var parameters = query.getValue().getParamNameValuePairs().values();
+        assertTrue(sql.contains("status"));
+        assertTrue(sql.contains("rider_id is null"));
+        assertTrue(sql.contains("scheduled_pickup_start_at"));
+        assertTrue(sql.contains("st_distance_sphere"));
+        assertTrue(parameters.contains("WAITING_ACCEPTANCE"));
+        assertTrue(parameters.contains("NOW"));
+        assertTrue(parameters.stream().anyMatch(Date.class::isInstance));
+
+        ArgumentCaptor<com.baomidou.mybatisplus.core.conditions.query.QueryWrapper> summaryQuery =
+                ArgumentCaptor.forClass(com.baomidou.mybatisplus.core.conditions.query.QueryWrapper.class);
+        verify(fixture.orderMapper).selectObjs(summaryQuery.capture());
+        String summarySql = summaryQuery.getValue().getSqlSegment().toLowerCase();
+        var summaryParameters = summaryQuery.getValue().getParamNameValuePairs().values();
+        assertTrue(summarySql.contains("rider_id is null"));
+        assertTrue(summarySql.contains("scheduled_pickup_start_at"));
+        assertTrue(summarySql.contains("st_distance_sphere"));
+        assertTrue(summaryParameters.contains("WAITING_ACCEPTANCE"));
+    }
+
+    @Test
+    @SuppressWarnings({"rawtypes", "unchecked"})
+    void riderNewTaskWithoutPositionFallsBackToNewestFirst() {
+        Fixture fixture = new Fixture();
+        rider(fixture, 8L);
+        when(fixture.orderMapper.selectPage(any(com.baomidou.mybatisplus.core.metadata.IPage.class),
+                any(Wrapper.class))).thenReturn(new Page<FlashDeliveryOrder>());
+        when(fixture.orderMapper.selectObjs(any(Wrapper.class))).thenReturn(List.of());
+
+        fixture.service.riderOrders(8L, 1, 10, "newTask", null, null);
+
+        ArgumentCaptor<com.baomidou.mybatisplus.core.conditions.query.QueryWrapper> query =
+                ArgumentCaptor.forClass(com.baomidou.mybatisplus.core.conditions.query.QueryWrapper.class);
+        verify(fixture.orderMapper).selectPage(any(com.baomidou.mybatisplus.core.metadata.IPage.class), query.capture());
+        String sql = query.getValue().getSqlSegment().toLowerCase();
+        assertTrue(sql.contains("order by create_time desc"));
+        assertFalse(sql.contains("st_distance_sphere"));
+    }
+
+    @Test
+    @SuppressWarnings({"rawtypes", "unchecked"})
+    void riderTabsMapStatusesBeforePagingAndRestrictAssignedTabsToRider() {
+        assertRiderTab("toPickup", List.of("ACCEPTED"));
+        assertRiderTab("delivering", List.of("PICKED_UP"));
+        assertRiderTab("completed", List.of("DELIVERED", "COMPLETED"));
+        assertRiderTab("cancelled", List.of("CANCELLED"));
+    }
+
+    @Test
+    void riderListRejectsUnknownTabAndUnpairedOrInvalidCoordinates() {
+        Fixture fixture = new Fixture();
+        rider(fixture, 8L);
+
+        assertThrows(ServiceException.class,
+                () -> fixture.service.riderOrders(8L, 1, 10, "refund", null, null));
+        assertThrows(ServiceException.class,
+                () -> fixture.service.riderOrders(8L, 1, 10, null, null, null));
+        assertThrows(ServiceException.class,
+                () -> fixture.service.riderOrders(8L, 1, 10, "newTask", BigDecimal.ONE, null));
+        assertThrows(ServiceException.class,
+                () -> fixture.service.riderOrders(8L, 1, 10, "newTask",
+                        new BigDecimal("181"), BigDecimal.ZERO));
+        verify(fixture.orderMapper, never()).selectPage(any(), any());
+    }
+
+    @Test
+    void assignedRiderTabsIgnoreLocationParameters() {
+        Fixture fixture = new Fixture();
+        rider(fixture, 8L);
+        when(fixture.orderMapper.selectPage(any(com.baomidou.mybatisplus.core.metadata.IPage.class),
+                any(Wrapper.class))).thenReturn(new Page<FlashDeliveryOrder>());
+
+        fixture.service.riderOrders(8L, 1, 10, "toPickup",
+                new BigDecimal("181"), new BigDecimal("91"));
+
+        verify(fixture.orderMapper).selectPage(any(), any());
+    }
+
+    @Test
+    void preAcceptRiderDetailIsDirectAndShowsTextAddressWithoutSensitiveFields() {
+        Fixture fixture = new Fixture();
+        rider(fixture, 8L);
+        FlashDeliveryOrder order = order(10L, "WAITING_ACCEPTANCE");
+        when(fixture.orderMapper.selectById(10L)).thenReturn(order);
+
+        var result = fixture.service.riderDetail(8L, 10L);
+
+        assertEquals(10L, result.getId());
+        assertEquals("pickup road", result.getPickup().getAddress());
+        assertEquals("pickup door", result.getPickup().getAddressDetail());
+        assertEquals("delivery road", result.getDelivery().getAddress());
+        assertEquals("delivery door", result.getDelivery().getAddressDetail());
+        assertNull(result.getPickup().getName());
+        assertNull(result.getPickup().getPhone());
+        assertNull(result.getPickup().getLongitude());
+        assertNull(result.getPickup().getLatitude());
+        assertNull(result.getDelivery().getName());
+        assertNull(result.getDelivery().getPhone());
+        assertNull(result.getDelivery().getLongitude());
+        assertNull(result.getDelivery().getLatitude());
+        assertNull(result.getDeliveryPinCode());
+        assertNull(result.getUserNote());
+        assertTrue(result.getSenderImageUrls().isEmpty());
+        assertTrue(result.getPickupImageUrls().isEmpty());
+        assertTrue(result.getDeliveryImageUrls().isEmpty());
+        verify(fixture.imageMapper, never()).selectList(any());
+        verify(fixture.logMapper, never()).selectList(any());
+    }
+
+    @Test
+    void userDetailReturnsDirectOrderAndGroupsImageUrlsWithoutRawLogs() {
+        Fixture fixture = new Fixture();
+        FlashDeliveryOrder order = order(10L, ACCEPTED);
+        order.setRiderId(null);
+        when(fixture.orderMapper.selectById(10L)).thenReturn(order);
+        when(fixture.imageMapper.selectList(any(Wrapper.class))).thenReturn(List.of(
+                image("SENDER", "https://example.com/sender.jpg"),
+                image("PICKUP", "https://example.com/pickup.jpg"),
+                image("DELIVERY", "https://example.com/delivery.jpg")));
+
+        var result = fixture.service.userDetail(7L, 10L);
+
+        assertEquals(10L, result.getId());
+        assertEquals(List.of("https://example.com/sender.jpg"), result.getSenderImageUrls());
+        assertEquals(List.of("https://example.com/pickup.jpg"), result.getPickupImageUrls());
+        assertEquals(List.of("https://example.com/delivery.jpg"), result.getDeliveryImageUrls());
+        assertEquals("4821", result.getDeliveryPinCode());
+        verify(fixture.logMapper, never()).selectList(any());
     }
 
     @Test
@@ -458,6 +634,77 @@ class FlashDeliveryApplicationServiceTest {
         return request;
     }
 
+    @SuppressWarnings({"rawtypes", "unchecked"})
+    private void assertRiderTab(String tab, List<String> statuses) {
+        Fixture fixture = new Fixture();
+        rider(fixture, 8L);
+        when(fixture.orderMapper.selectPage(any(com.baomidou.mybatisplus.core.metadata.IPage.class),
+                any(Wrapper.class))).thenReturn(new Page<FlashDeliveryOrder>());
+
+        fixture.service.riderOrders(8L, 1, 10, tab, null, null);
+
+        ArgumentCaptor<com.baomidou.mybatisplus.core.conditions.query.QueryWrapper> query =
+                ArgumentCaptor.forClass(com.baomidou.mybatisplus.core.conditions.query.QueryWrapper.class);
+        verify(fixture.orderMapper).selectPage(any(com.baomidou.mybatisplus.core.metadata.IPage.class), query.capture());
+        // MyBatis-Plus 的条件参数在渲染 SQL 段时才写入参数表,必须先取 SQL 再断言参数。
+        String sql = query.getValue().getSqlSegment();
+        assertTrue(sql.contains("rider_id"), tab);
+        var parameters = query.getValue().getParamNameValuePairs().values();
+        assertTrue(parameters.contains(8L), tab);
+        for (String status : statuses) assertTrue(parameters.contains(status), tab + ":" + status);
+    }
+
+    private void rider(Fixture fixture, Long riderId) {
+        InfoUser rider = new InfoUser();
+        rider.setUserId(riderId);
+        rider.setUserType("2");
+        when(fixture.userMapper.selectInfoUserByUserId(riderId)).thenReturn(rider);
+    }
+
+    private FlashDeliveryOrder order(Long id, String status) {
+        FlashDeliveryOrder order = new FlashDeliveryOrder();
+        order.setId(id);
+        order.setOrderNo("FL-20260813-021");
+        order.setUserId(7L);
+        order.setServiceType("HELP_SEND");
+        order.setStatus(status);
+        order.setPackageType("DOCUMENT");
+        order.setPackageSize("SMALL");
+        order.setDeliveryMode("NOW");
+        order.setPinRequired(true);
+        order.setDeliveryPinCode("4821");
+        order.setPickupName("pickup contact");
+        order.setPickupPhone("0911111111");
+        order.setPickupAddress("pickup road");
+        order.setPickupAddressDetail("pickup door");
+        order.setPickupCity("台北市");
+        order.setPickupArea("中山區");
+        order.setPickupLongitude(new BigDecimal("121.5100"));
+        order.setPickupLatitude(new BigDecimal("25.0100"));
+        order.setDeliveryName("delivery contact");
+        order.setDeliveryPhone("0922222222");
+        order.setDeliveryAddress("delivery road");
+        order.setDeliveryAddressDetail("delivery door");
+        order.setDeliveryCity("台北市");
+        order.setDeliveryArea("大安區");
+        order.setDeliveryLongitude(new BigDecimal("121.5200"));
+        order.setDeliveryLatitude(new BigDecimal("25.0200"));
+        order.setDistanceMeters(3200);
+        order.setEstimatedDurationSeconds(2100);
+        order.setAmount(112L);
+        order.setCurrency("TWD");
+        order.setUserNote("private note");
+        order.setCreateTime(new Date());
+        return order;
+    }
+
+    private FlashDeliveryOrderImage image(String proofType, String url) {
+        FlashDeliveryOrderImage image = new FlashDeliveryOrderImage();
+        image.setProofType(proofType);
+        image.setImageUrl(url);
+        return image;
+    }
+
     private static class Fixture {
         final FlashDeliveryOrderMapper orderMapper = mock(FlashDeliveryOrderMapper.class);
         final FlashDeliveryPricingMapper pricingMapper = mock(FlashDeliveryPricingMapper.class);

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

@@ -3,6 +3,6 @@
 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`。
+4. 骑手从 `/system/flashDelivery/rider/orders?page=1&size=10&tab=newTask&longitude=121.5&latitude=25.0` 拉取待抢任务并调用 `/accept`;五个页签分别为 `newTask`(待接单)、`toPickup`(已接单)、`delivering`(已取件)、`completed`(已送达/已完成)和 `cancelled`(已取消)。`newTask` 的经纬度仅用于附近范围、取件点距离和排序,闪送没有 `refund` 页签。接单前只展示完整文字地址,隐藏联系人、电话、实际 PIN、精确坐标和履约图片;图片先走现有上传接口,再把 URL 传给 `/pickup` 和 `/deliver`。
 5. 用户 `/confirmReceipt` 完成;送达满 24 小时未操作则自动完成。
 6. 完整字段见 [API 契约](contracts/api.md)。

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

@@ -4,7 +4,7 @@
 
 **创建日期**:2026-08-31
 
-**状态**:平台前端与外卖同规则时段运价调整已验证;App 列表精简设计已确认、待实现
+**状态**:平台前端与外卖同规则时段运价调整已验证;App 订单列表与详情精简已提交,定向测试与模块构建通过
 
 **输入**:基于蓝湖“闪送”分组 7 个设计页面及 2026-08-24 用户端/骑手端补充原型,实现帮送、帮取、加急送的完整非支付业务闭环,包括共享地址簿、路线报价、包裹信息、立即/预约配送、可选 PIN 交付、创建订单、骑手主动抢单、取件、送达、用户签收、平台配置与介入。
 

+ 15 - 7
specs/024-flash-delivery/tasks.md

@@ -79,10 +79,18 @@
 
 ## Phase 11:App 订单列表与详情精简
 
-- [ ] T048 先改 Controller 反射、列表字段白名单和详情直接响应测试,确认旧 `/available`、`/mine`、多筛选参数及 `{order,images,logs}` 契约红灯
-- [ ] T049 新增用户列表、骑手列表、骑手分页 DTO,将 App 详情 DTO 改为直接订单字段和三类明确照片 URL 数组,删除旧待抢/图片/日志 App DTO
-- [ ] T050 先改应用服务测试,覆盖用户轻量列表、五个骑手页签、外卖同规则附近范围/排序、摘要、预约条件、本人边界、接单前地址权限和固定详情结构并确认红灯
-- [ ] T051 将用户列表收敛为 `page,size`,将骑手 `/orders` 收敛为 `page,size,tab,longitude,latitude`,在数据库分页前完成条件与距离排序
-- [ ] T052 重写 App 详情映射,直接返回订单字段并按类型聚合照片 URL,不查询或返回原始状态日志;补齐五语言参数错误
-- [ ] T053 更新 `docs/flash-delivery-app-api.md` 与 quickstart,删除旧列表路由、筛选、近似坐标及 App 包装响应说明
-- [ ] T054 使用 JDK 21 运行闪送定向测试、i18n 契约、模块构建和差异检查,核对暂存范围后使用中文提交并推送 `test`
+- [x] T048 先改 Controller 反射、列表字段白名单和详情直接响应测试,确认旧 `/available`、`/mine`、多筛选参数及 `{order,images,logs}` 契约红灯
+- [x] T049 新增用户列表、骑手列表、骑手分页 DTO,将 App 详情 DTO 改为直接订单字段和三类明确照片 URL 数组,删除旧待抢/图片/日志 App DTO
+- [x] T050 先改应用服务测试,覆盖用户轻量列表、五个骑手页签、外卖同规则附近范围/排序、摘要、预约条件、本人边界、接单前地址权限和固定详情结构并确认红灯
+- [x] T051 将用户列表收敛为 `page,size`,将骑手 `/orders` 收敛为 `page,size,tab,longitude,latitude`,在数据库分页前完成条件与距离排序
+- [x] T052 重写 App 详情映射,直接返回订单字段并按类型聚合照片 URL,不查询或返回原始状态日志;补齐五语言参数错误
+- [x] T053 更新 `docs/flash-delivery-app-api.md` 与 quickstart,删除旧列表路由、筛选、近似坐标及 App 包装响应说明
+- [x] T054 使用 JDK 21 运行闪送定向测试、i18n 契约、模块构建和差异检查,核对暂存范围后使用中文提交并推送 `test`
+
+### 2026-09-01 中断点(已于 2026-09-02 收口)
+
+- T048–T053 的生产代码、测试源码、五语言文案和接口文档已在 2026-09-01 写入工作区;2026-09-02 完成修复、验证并提交。
+- 收口时修复两处阻塞:`FlashDeliveryApplicationService.riderOrders` 改用 `effectiveLongitude/effectiveLatitude` 有效坐标变量解决 lambda 捕获编译错误;`FlashDeliveryApplicationServiceTest.assertRiderTab` 改为先渲染 `getSqlSegment()` 再断言参数表(MyBatis-Plus 条件参数为惰性写入,原断言在渲染前读取恒为空)。
+- 验证结果:JDK 21 定向测试 40 项全部通过(0 失败、0 错误);`mvn -pl ruoyi-admin -am -DskipTests package` 成功;`git diff --check` 清洁。
+- 另按审核结论补记 `sys_job` 闪送自动完成调度 SQL(见 `updatesql/sql.md` 2026-09-02 节),并在 `plan.md` 修订并发描述与实现一致。
+- 已有规格提交:`17888f8`(明确闪送 App 订单列表与详情契约)、`8a3ea64`(制定闪送 App 订单接口精简计划)。