ソースを参照

完善闪送多点取送流程并记录测试库迁移

qmj 5 日 前
親
コミット
cda916c850
36 ファイル変更、1432 行追加、264 行削除
  1. 42 2
      docs/flash-delivery-app-api.md
  2. 137 0
      docs/flash-delivery-v2-frontend-handoff.md
  3. 15 6
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/controller/FlashDeliveryRiderController.java
  4. 20 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/controller/FlashDeliveryUserController.java
  5. 3 3
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryHomeView.java
  6. 3 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryOrderView.java
  7. 3 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryRiderOrderListView.java
  8. 12 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryStopDeliverRequest.java
  9. 3 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryStopView.java
  10. 3 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryUserOrderListView.java
  11. 12 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryV2AddressChangeRequest.java
  12. 15 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryV2AddressConfirmRequest.java
  13. 250 74
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryApplicationService.java
  14. 58 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryNotificationService.java
  15. 2 2
      ruoyi-admin/src/main/resources/i18n/messages.properties
  16. 2 2
      ruoyi-admin/src/main/resources/i18n/messages_en_US.properties
  17. 2 2
      ruoyi-admin/src/main/resources/i18n/messages_th_TH.properties
  18. 2 2
      ruoyi-admin/src/main/resources/i18n/messages_zh_CN.properties
  19. 2 2
      ruoyi-admin/src/main/resources/i18n/messages_zh_TW.properties
  20. 61 0
      ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/controller/FlashDeliveryControllerContractTest.java
  21. 2 2
      ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryRiderOrderListViewTest.java
  22. 3 1
      ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryApplicationServiceTest.java
  23. 7 7
      ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryMultiPickupTest.java
  24. 292 0
      ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryNotificationServiceTest.java
  25. 1 1
      ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryRiderBillingTest.java
  26. 208 7
      ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryV2Test.java
  27. 10 1
      ruoyi-system/src/main/resources/mapper/flash/FlashDeliveryOrderMapper.xml
  28. 30 0
      ruoyi-system/src/test/java/com/ruoyi/system/mapper/flash/FlashDeliveryMapperXmlTest.java
  29. 23 6
      specs/024-flash-delivery/contracts/api.md
  30. 2 2
      specs/024-flash-delivery/data-model.md
  31. 28 7
      specs/024-flash-delivery/plan.md
  32. 2 2
      specs/024-flash-delivery/quickstart.md
  33. 20 14
      specs/024-flash-delivery/spec.md
  34. 34 9
      specs/024-flash-delivery/tasks.md
  35. 7 1
      specs/024-flash-delivery/validation.md
  36. 116 109
      updatesql/sql.md

+ 42 - 2
docs/flash-delivery-app-api.md

@@ -1139,7 +1139,7 @@ POST /system/address/{id}/top
 
 ### 下单与编辑
 
-首页读取 `pickupStopLimit/showMultiPickup/showUrgentOption`。用户可新增取货点、填写每站独立物品,并在报价前排序;最终 `pickups` 数组顺序即报价顺序。每个数组项直接包含地址字段和 `packageType/quantity/weightRange/specification`。数组模式不再传旧 `pickup` 或订单级物品。唯一 `delivery` 保持原格式。
+首页读取 `pickupStopLimit` 控制取货点数量;兼容字段 `showMultiPickup` 由上限是否大于 1 派生,`showUrgentOption` 固定为 true,加急始终展示。用户可新增取货点、填写每站独立物品,并在报价前排序;最终 `pickups` 数组顺序即报价顺序。每个数组项直接包含地址字段和 `packageType/quantity/weightRange/specification`。数组模式不再传旧 `pickup` 或订单级物品。唯一 `delivery` 保持原格式。
 
 ```json
 {
@@ -1173,7 +1173,7 @@ POST /system/address/{id}/top
 
 导航目标由 `currentTargetStopId` 定位,全部取完后为唯一收货点。客户端生成 Google Maps URL,使用 `api=1&travelmode=two-wheeler&dir_action=navigate`,地址参数正确编码,起点取设备当前位置。没有定位或无法启动外部地图时,保留目标地址并允许重试;不得因此自动取件。后端不返回导航 URL。
 
-上线前用户、骑手端必须完成数组、逐站凭证、目标调整及状态按钮适配再开启多点开关。当前工作区未提供对应 App 页面实现,本节为客户端交付契约,不能作为 App 已联调证据。
+上线前用户、骑手端必须完成数组、逐站凭证、目标调整及状态按钮适配,再调高多点数量上限。当前工作区未提供对应 App 页面实现,本节为客户端交付契约,不能作为 App 已联调证据。
 
 ## 2026-09-30:闪送完成后的骑手收入账单
 
@@ -1191,3 +1191,43 @@ POST /system/address/{id}/top
 | `payType` / `paymentStatus` | 4 现金、6 线下转账 / 0 未确认收款、1 已确认 |
 
 App 应根据来源打开对应订单详情,不能将账单收入展示为平台可提现金额,也不能将有账单标记为已收款。已有“未接单报价不得展示为收益”的限制不变;本节只放开已完成订单的收入账单。此次没有修改 App 页面,真实 App 展示需客户端接入验证。部署前执行 `updatesql/sql.md` 中本次 `user_billing.order_source` 增量 SQL。
+
+## 2026-10-14 新多点取送接口的参数位置
+
+本次新增接口带 body 时,业务参数统一放 body,token 保持请求头;GET 简单参数和已有旧接口保持原样。v2 报价/创建继续使用 `/system/flashDelivery/v2/quote` 与 `/system/flashDelivery/v2/orders`。
+
+新逐站送达采用固定路径:
+
+```http
+POST /system/flashDelivery/rider/orders/stops/deliver
+```
+
+```json
+{
+  "orderId": 10,
+  "stopId": 13,
+  "pinCode": "4821",
+  "imageUrls": ["/profile/delivery.jpg"]
+}
+```
+
+订单、站点 ID 必须为正整数;`stopId` 从订单详情 `stops` 中对应 DELIVERY 站的 `id` 取得,单收货站也必须传。照片至少一张;订单启用 PIN 时提交对应收货站的码,未启用时可省略 `pinCode`。服务器核实站点归属和骑手权限;全部收货站送达后才推进整单状态。
+
+此前当日新增的带路径 ID 逐站送达地址不再作为新接口契约。已有 LINE Pay、旧取件/送达/目标调整/改址及平台接口未调整,仍按原文档接入。
+
+## 2026-10-14 多点履约与改址修复
+
+v2 订单即使只有一个收货站,也必须使用上述逐站送达接口并传 `stopId`;旧整单送达接口拒绝 v2 订单。每次送达只接受详情中 `currentTargetStopId` 对应的待送站;需调整送达顺序时,骑手通过原目标调整入口记录原因与发起人已同意,再送新的目标站。末站送达后重复请求可安全返回,不会再次保存凭证或通知。全部收货站完成才进入 `DELIVERED`;确认收货或系统/平台完成进入 `COMPLETED` 后才生成一次骑手收入账单。
+
+`stops[].id` 是站点 ID。启用 PIN 时,v2 详情在对应收货站的 `deliveryPinCode` 返回各站独立收货码;发起人可见全部,系统内匹配的收件人仅能看到自己的站点收货码,骑手看不到。收件人列表卡片展示自己的收货地址,详情隐藏其他收货站的联系人和精确地址。匹配不到系统用户时,仍保留联系人电话和按站收货码,由发起人线下告知;本阶段不实现逐站收件人推送。v2 订单级 `deliveryPinCode` 不再用作送达码。订单列表和详情提供 `deliveryStopCount`、`deliveredStopCount` 供展示进度。
+
+v2 待接单订单修改站点使用两个新接口,token 在请求头,其余业务参数全部在 JSON body:
+
+```http
+POST /system/flashDelivery/v2/orders/address/quote
+POST /system/flashDelivery/v2/orders/address
+```
+
+报价 body 为 `{"orderId":10,"orderVersion":0,"stops":[...]}`,其中 `stops` 必须是完整的取货与收货站数组,结构与 v2 创建相同。确认保存 body 再附上报价返回的 `quotedDistanceMeters`、`quotedBaseDeliveryFee`、`quotedDistanceFee`、`quotedUrgentFee`、`quotedAmount`。服务端沿用该订单的物品和运价快照,并核对版本与报价;保存成功后按返回详情刷新全部 `stops[].id` 和收货码。旧改址入口遇到 v2 订单会拒绝,旧订单的参数和行为保持原样。
+
+每站取货或送达首次成功提交后,发起人收到一次进度推送及消息中心记录。payload 的 `eventType` 为 `FLASH_PICKUP_STOP_COMPLETED` 或 `FLASH_DELIVERY_STOP_COMPLETED`,并含 `ddId`、真实订单 `state`、`stopId`、`stopType`、从 0 开始的 `stopOrder`、`completedStopCount`、`totalStopCount`。同站重试不会重复通知,最后一站不再叠发旧整单阶段通知。App 按 `ddId` 打开订单详情,以最新详情展示站点进度。逐站收件人通知记录为后续需求,本轮暂缓。

+ 137 - 0
docs/flash-delivery-v2-frontend-handoff.md

@@ -0,0 +1,137 @@
+# 闪送 v2 多点取送前端对接说明
+
+更新时间:2026-10-14。本文对应当前仓库中的后端实现,供用户端和骑手端接入;联调前需确认服务端部署版本和 [10 月 14 日数据库执行说明](../updatesql/sql.md)。
+
+## 本次要调整的页面和流程
+
+| 页面/场景 | 前端调整 |
+| --- | --- |
+| 用户填写闪送订单 | 按顺序编辑一个 `stops` 数组:先全部取货站,再全部收货站;物品信息只填写一次。根据 `/home` 的取货点和收货点数量上限控制可添加数量。 |
+| 报价与下单 | 使用 `/v2/quote` 和 `/v2/orders`。创建时把本次报价的配置 ID、版本和费用原样回传;创建请求号在重试时保持一致。 |
+| 用户/收件人订单列表和详情 | 展示取货、收货两个进度;站点使用 `stops[].id`,不可把数组下标当成站点 ID。逐站收货码按权限展示。 |
+| 骑手履约 | 从详情的 `currentTargetStopId` 找当前目标;每站上传凭证并刷新详情。逐站送达改用固定路径的新接口,`orderId`、`stopId`、PIN 和照片全部放 body。 |
+| 待接单改址 | v2 使用新的完整站点报价与保存接口。保存成功后刷新详情,替换本地旧站点 ID 和收货码。 |
+| 发起人消息 | 识别两种逐站进度事件,凭 `ddId` 打开订单详情,再读取最新状态。逐站收件人通知本期不接入。 |
+
+所有下列新 POST 接口使用 `token` 请求头和 JSON body。只有**新接口**采用“业务参数统一放 body”的约定;现有 GET、旧取件、旧目标调整、旧订单接口及 LINE Pay 接口的参数格式保持原样。响应仍为项目通用 `AjaxResult`,业务数据在 `data` 中;订单详情的 `data` 直接是订单对象,不再套一层 `order`。
+
+## 用户端:填写、报价、创建
+
+先调用 `GET /system/flashDelivery/home`,使用 `data.pickupStopLimit`、`data.deliveryStopLimit` 控制添加站点按钮和提交数量。有效范围为 1–10,默认均为 1;对应上限大于 1 即可使用多站点,无需开关。兼容字段 `showMultiPickup`、`showMultiDropoff` 分别表示对应上限是否大于 1,`showUrgentOption` 始终为 true;加急选项直接展示。服务类型、配送等级和可用支付方式也从首页响应读取。
+
+`POST /system/flashDelivery/v2/quote` 的示例 body:
+
+```json
+{
+  "serviceType": "HELP_SEND",
+  "deliveryType": "NORMAL",
+  "vehicleType": 1,
+  "packageType": "DOCUMENT",
+  "quantity": 2,
+  "weightRange": "UP_TO_5_KG",
+  "specification": "两个文件袋",
+  "tipAmount": 0,
+  "deliveryMode": "NOW",
+  "payType": "4",
+  "stops": [
+    {"type":"PICKUP","name":"取件人甲","phone":"0911000001","address":"台北市信义区甲路1号","addressDetail":"1楼","longitude":121.5601,"latitude":25.0301,"remark":"取文件袋甲"},
+    {"type":"PICKUP","name":"取件人乙","phone":"0911000002","address":"台北市信义区乙路2号","longitude":121.5602,"latitude":25.0302,"remark":"取文件袋乙"},
+    {"type":"DELIVERY","name":"收件人丙","phone":"0911000003","address":"台北市大安区丙路3号","longitude":121.5603,"latitude":25.0303,"remark":"交给本人"},
+    {"type":"DELIVERY","name":"收件人丁","phone":"0911000004","address":"台北市大安区丁路4号","longitude":121.5604,"latitude":25.0304,"remark":"交前台"}
+  ]
+}
+```
+
+至少一个 `PICKUP` 和一个 `DELIVERY`,取货站必须排在所有收货站之前,路线顺序就是数组顺序;同一地址或坐标不能重复。每站分别填写姓名、电话、完整地址和经纬度,可选门牌、交接方式、最长 200 字备注。v2 站点不要提交旧结构中的逐站物品字段;`packageType`、正整数 `quantity`、可选 `weightRange` 和 `specification` 只在订单级出现一次。`SCHEDULED` 订单还需提交 `scheduledPickupStartAt`、`scheduledPickupEndAt`,`NOW` 订单无需提交这两个字段。
+
+创建订单使用 `POST /system/flashDelivery/v2/orders`。body 包含报价时的业务字段和完整 `stops`,再增加:
+
+```json
+{
+  "clientRequestId": "客户端生成并持久保存的本次下单请求号",
+  "pricingId": 123,
+  "pricingVersion": 1,
+  "quotedBaseDeliveryFee": 80,
+  "quotedDistanceFee": 10,
+  "quotedUrgentFee": 0,
+  "quotedAmount": 90,
+  "pinRequired": true,
+  "senderImageUrls": [],
+  "userNote": "请注意文件袋"
+}
+```
+
+上述价格数字只展示字段形状,实际值必须取本次报价 `data` 的 `pricingId`、`pricingVersion`、`baseDeliveryFee`、`distanceFee`、`urgentFee`、`amount`,不可使用示例数字。`clientRequestId` 最长 64 字符;同一次创建的网络重试使用相同值,新订单使用新值。`pinRequired` 省略时默认为 `true`,开启后每个收货站有独立四位收货码。报价在创建前变化时,接口会返回错误并在 `data` 附最新报价;展示新金额让用户重新确认后再提交。
+
+## 订单详情、收件人和进度
+
+- 用户列表:`GET /system/flashDelivery/orders?role=sender|receiver&page=1&size=10`;`receiver` 包括每一个已匹配到系统普通用户的收货站。收件人列表卡片展示自己的收货地址。
+- 用户详情:`GET /system/flashDelivery/orders/{id}`;按订单号打开消息:`GET /system/flashDelivery/orders/byOrderNo?orderNo=...`。
+- 骑手列表与详情:`GET /system/flashDelivery/rider/orders`、`GET /system/flashDelivery/rider/orders/{id}`,沿用原有 `tab`、分页和可选坐标参数。
+
+详情 `data.stops` 按路线顺序返回。请求站点字段叫 `type`,响应字段叫 `stopType`;响应的 `id` 才是后续履约所需的 `stopId`,`stopOrder` 从 0 开始。每站有 `status`、`remark`、`pickedUpAt`/`deliveredAt` 和 `imageUrls`。列表与详情提供 `pickupStopCount`、`pickedUpStopCount`、`deliveryStopCount`、`deliveredStopCount`。部分取货时整单仍为 `ACCEPTED`;全部取完为 `PICKED_UP`;部分送达仍为 `PICKED_UP`;全部送达为 `DELIVERED`;确认收货或平台/系统完成后为 `COMPLETED`。页面不要只靠整单 `status` 推算逐站进度。
+
+v2 的订单级 `deliveryPinCode` 为空,收货码在各个 DELIVERY 站的 `stops[].deliveryPinCode`。发起人可查看全部收货码;已在系统内匹配的收件人只能查看自己站的收货码及联系人、精确地址和凭证;骑手看不到任何实际收货码。未匹配系统用户的收货站仍可下单,但此人不会进入“我收的”列表,也不会收到 App 推送;发起人需自行告知该站收货码。**逐站收件人通知本期暂缓。**
+
+用户端取消按钮以 `canUserCancel` 为准;任一取货站已取货后不可自助取消。确认收货入口只在整单 `DELIVERED` 后提供;系统仍会检查全部收货站是否真的已送达。骑手收入账单只在订单进入 `COMPLETED` 时生成一次。
+
+## 骑手端:目标、取货、送达
+
+骑手从详情 `currentTargetStopId` 定位当前站点;默认依次指向最早未完成的取货站,全部取完后依次指向最早未送达的收货站。操作成功后重新请求详情,不继续使用旧的目标 ID、状态或版本。
+
+| 操作 | 当前接口 | 提交内容 |
+| --- | --- | --- |
+| 逐站取货 | `POST /system/flashDelivery/rider/orders/{id}/stops/{stopId}/pickup` | 现有接口,body 为 `{"imageUrls":["/profile/pickup.jpg"]}`;如同时传 `stopId`,必须与路径一致。 |
+| 选择目标站 | `POST /system/flashDelivery/rider/orders/{id}/stops/{stopId}/target` | 现有接口,body 含 `orderVersion`、`clientRequestId`,改变默认目标时还需 `reason` 和 `consentDeclared:true`。 |
+| 逐站送达 | `POST /system/flashDelivery/rider/orders/stops/deliver` | **新接口**,body 含 `orderId`、`stopId`、`imageUrls`,启用 PIN 时再传该站 `pinCode`。 |
+
+取货和送达凭证每站至少 1 张、最多 9 张。新送达接口中 `orderId`、`stopId` 均为正整数;**只有一个收货站的 v2 订单也必须传 `stopId`**。骑手在送达现场向收件人核对其本站收货码,不能使用别站或订单级收货码。新骑手端可统一根据详情中的站点 ID 使用逐站送达接口;旧 `/rider/orders/{id}/deliver` 只供旧客户端处理旧订单,v2 订单调用它会被拒绝。
+
+送达非当前目标站会被拒绝。改变顺序时,先取得发起人同意,再使用目标调整接口登记原因和 `consentDeclared:true`;该字段是骑手声明,当前没有发起人 App 内审批流程。调整请求的 `clientRequestId` 在同一次操作重试时保持不变。某站已成功取货或送达的重试不会重复保存照片和进度;末站送达后主状态已变化时也是如此。
+
+## 用户端:v2 待接单改址
+
+仅待接单、未分配骑手的 v2 订单可编辑。先从详情读取 `orderVersion` 和完整站点,编辑后提交所有取货与收货站;只提交被修改的一站会丢失其余站点,因此服务端要求完整数组。
+
+```http
+POST /system/flashDelivery/v2/orders/address/quote
+token: <登录 token>
+Content-Type: application/json
+```
+
+```json
+{"orderId":10,"orderVersion":0,"stops":[{"type":"PICKUP","name":"...","phone":"...","address":"...","longitude":121.56,"latitude":25.03},{"type":"DELIVERY","name":"...","phone":"...","address":"...","longitude":121.57,"latitude":25.04}]}
+```
+
+确认保存调用 `POST /system/flashDelivery/v2/orders/address`,body 保留相同的 `orderId`、`orderVersion`、**完整** `stops`,再回传报价 `data` 中的 `distanceMeters`→`quotedDistanceMeters`、`baseDeliveryFee`→`quotedBaseDeliveryFee`、`distanceFee`→`quotedDistanceFee`、`urgentFee`→`quotedUrgentFee`、`amount`→`quotedAmount`。只改站点,物品、配送方式、小费和原运价快照继续沿用订单保存值。报价或版本变化时重新获取并让用户确认。保存成功后以返回详情更新全部 `stops[].id` 和各站收货码;旧站点 ID 已失效。旧改址入口会拒绝 v2 订单,旧订单仍沿用原入口。
+
+## 发起人逐站通知
+
+每个取货/收货站首次成功且事务提交后,服务端向**订单发起人**推送并写入消息中心。两种 `eventType`:`FLASH_PICKUP_STOP_COMPLETED`、`FLASH_DELIVERY_STOP_COMPLETED`。消息 payload 示例:
+
+```json
+{
+  "ddId": "订单号",
+  "state": "PICKED_UP",
+  "type": -1,
+  "pushType": 3,
+  "eventType": "FLASH_PICKUP_STOP_COMPLETED",
+  "stopId": 13,
+  "stopType": "PICKUP",
+  "stopOrder": 0,
+  "completedStopCount": 2,
+  "totalStopCount": 2
+}
+```
+
+`state` 是通知发生时真实的整单状态,不应写死为示例值;`stopOrder` 为路线数组的 0 基下标,通知文案的站号为 1 基。收到事件后按 `ddId` 请求 `/orders/byOrderNo` 并刷新详情;不要从消息体推断联系人、地址或 PIN。相同站点的重试不重复通知,最后一站也不叠发旧整单阶段通知。逐站收件人通知留待后续需求。
+
+## 联调验收重点
+
+1. 单取单送和多取多送都走 v2 报价/创建;收货站只有一站时,送达仍提供 `stopId`。
+2. 每站首次取货/送达后进度计数与当前目标正确;部分送达时整单仍为 `PICKED_UP`,不能提前确认完成或记账。
+3. 修改第二个收货站后,其他站、订单级物品和原运价仍在;保存后旧站点 ID/PIN 不再使用。
+4. 发起人看全部收货码,匹配的第二站收件人只看自己的地址、联系人、凭证和 PIN;骑手看不到 PIN;未匹配用户不出现 App 收件人入口。
+5. 发起人收到每站一次进度消息并能按订单号打开详情;收件人没有逐站推送。真实设备推送和 App 页面尚需联调。
+
+现有单点、支付及其他旧接口以 [原闪送 App API 文档](flash-delivery-app-api.md) 为准。数据库结构和不同环境的执行方式见 [10 月 14 日 SQL 说明](../updatesql/sql.md);本次问题修复没有额外字段变更;2026-10-14 测试库已按旧版增量路径完成结构与字典更新,其他环境仍需按 SQL 说明执行。

+ 15 - 6
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/controller/FlashDeliveryRiderController.java

@@ -3,10 +3,13 @@ package com.ruoyi.app.flashdelivery.controller;
 import com.ruoyi.app.flashdelivery.dto.FlashDeliveryTargetRequest;
 import com.ruoyi.app.flashdelivery.dto.FlashDeliveryProofRequest;
 import com.ruoyi.app.flashdelivery.dto.FlashDeliveryDeliverRequest;
+import com.ruoyi.app.flashdelivery.dto.FlashDeliveryStopDeliverRequest;
 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.common.exception.ServiceException;
+import com.ruoyi.common.utils.MessageUtils;
 import com.ruoyi.system.utils.Auth;
 import com.ruoyi.system.utils.JwtUtil;
 import org.springframework.web.bind.annotation.*;
@@ -106,13 +109,19 @@ public class FlashDeliveryRiderController extends BaseController {
         return success();
     }
 
-    /** v2 逐站送达:校验按站收货码并上传凭证,全部收货站完成订单才进入已送达。 */
-    @PostMapping("/orders/{id}/stops/{stopId}/deliver")
+    /** v2 逐站送达:业务参数统一放 body,全部收货站完成订单才进入已送达。 */
+    @PostMapping("/orders/stops/deliver")
     @Auth
-    public AjaxResult deliverAtStop(@RequestHeader String token, @PathVariable Long id,
-                                    @PathVariable Long stopId,
-                                    @RequestBody FlashDeliveryDeliverRequest request) {
-        service.deliverAtStop(userId(token), id, stopId, request);
+    public AjaxResult deliverAtStop(@RequestHeader String token,
+                                    @RequestBody FlashDeliveryStopDeliverRequest request) {
+        if (request == null) throw new ServiceException(MessageUtils.message("flash.delivery.request.required"));
+        if (request.getOrderId() == null || request.getOrderId() <= 0) {
+            throw new ServiceException(MessageUtils.message("flash.delivery.order.not.found"));
+        }
+        if (request.getStopId() == null || request.getStopId() <= 0) {
+            throw new ServiceException(MessageUtils.message("flash.delivery.stop.id.required"));
+        }
+        service.deliverAtStop(userId(token), request.getOrderId(), request.getStopId(), request);
         return success();
     }
 

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

@@ -121,6 +121,26 @@ public class FlashDeliveryUserController extends BaseController {
         }
     }
 
+    /** v2 修改完整站点的预报价,沿用订单原有物品与运价快照。 */
+    @PostMapping("/v2/orders/address/quote")
+    @Auth
+    public AjaxResult quoteAddressV2(@RequestHeader String token,
+                                     @RequestBody FlashDeliveryV2AddressChangeRequest request) {
+        return success(service.quoteAddressV2(userId(token), request));
+    }
+
+    /** v2 确认完整站点改址,订单和站点 ID 均通过 body 接收。 */
+    @PostMapping("/v2/orders/address")
+    @Auth
+    public AjaxResult updateAddressV2(@RequestHeader String token,
+                                      @RequestBody FlashDeliveryV2AddressConfirmRequest request) {
+        try {
+            return success(service.updateAddressV2(userId(token), request));
+        } catch (FlashDeliveryQuoteChangedException exception) {
+            return AjaxResult.error(MessageUtils.message("flash.delivery.quote.changed"), exception.getLatestQuote());
+        }
+    }
+
     /** 待接单时追加正整数小费,同步增加订单总金额。 */
     @PostMapping("/orders/tip")
     @Auth

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

@@ -17,12 +17,12 @@ public class FlashDeliveryHomeView {
     private List<com.ruoyi.system.service.PaymentMethodGateService.PayMethodOption> payMethods;
     /** 取货点有效上限,合法范围1至10,配置非法时为1。 */
     private Integer pickupStopLimit;
-    /** 是否开放多取货点提交,不影响已有订单履约。 */
+    /** Compatibility field: true when pickupStopLimit is greater than 1. */
     private Boolean showMultiPickup;
     /** v2 收货点有效上限,合法范围1至10,配置非法时为1。 */
     private Integer deliveryStopLimit;
-    /** v2 是否开放多收货点提交,不影响已有订单履约。 */
+    /** Compatibility field: true when deliveryStopLimit is greater than 1. */
     private Boolean showMultiDropoff;
-    /** 仅控制加急选项展示,后端仍按deliveryType校验。 */
+    /** Compatibility field: urgent service is always available. */
     private Boolean showUrgentOption;
 }

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

@@ -82,6 +82,9 @@ public class FlashDeliveryOrderView {
     /** 取货站总数及已实际确认的取货站数。 */
     private Integer pickupStopCount;
     private Integer pickedUpStopCount;
+    /** 收货站总数与已送达数;历史单按单收货站兼容。 */
+    private Integer deliveryStopCount;
+    private Integer deliveredStopCount;
     /** 任一站已取货即禁止用户自助取消,不能仅按订单主状态判断。 */
     private Boolean canUserCancel;
 }

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

@@ -70,6 +70,9 @@ public class FlashDeliveryRiderOrderListView {
     /** 取货站总数及已实际确认的取货站数。 */
     private Integer pickupStopCount;
     private Integer pickedUpStopCount;
+    /** 收货站总数与已送达数;历史单按单收货站兼容。 */
+    private Integer deliveryStopCount;
+    private Integer deliveredStopCount;
     /** 任一站已取货即禁止用户自助取消,不能仅按订单主状态判断。 */
     private Boolean canUserCancel;
     /** 用户备注;仅骑手本人任务返回,待抢单卡片为空。 */

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

@@ -0,0 +1,12 @@
+package com.ruoyi.app.flashdelivery.dto;
+
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+/** 新逐站送达请求;orderId 与继承的 stopId 均必填,业务参数统一从请求体读取。 */
+@Data
+@EqualsAndHashCode(callSuper = true)
+public class FlashDeliveryStopDeliverRequest extends FlashDeliveryDeliverRequest {
+    /** 闪送订单 ID,必填正整数;stopId 指定本单的收货站。 */
+    private Long orderId;
+}

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

@@ -20,6 +20,9 @@ public class FlashDeliveryStopView {
     private BigDecimal latitude;
     /** 选填备注,告知骑手该站取什么或送什么。 */
     private String remark;
+    /** 按站收货码,仅发起人或本站绑定收件人可见,骑手不返回。 */
+    @com.fasterxml.jackson.annotation.JsonInclude(com.fasterxml.jackson.annotation.JsonInclude.Include.NON_NULL)
+    private String deliveryPinCode;
     private String status;
     private Date pickedUpAt;
     private Date deliveredAt;

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

@@ -62,6 +62,9 @@ public class FlashDeliveryUserOrderListView {
     /** 取货站总数及已实际确认的取货站数。 */
     private Integer pickupStopCount;
     private Integer pickedUpStopCount;
+    /** 收货站总数与已送达数;历史单按单收货站兼容。 */
+    private Integer deliveryStopCount;
+    private Integer deliveredStopCount;
     /** 任一站已取货即禁止用户自助取消,不能仅按订单主状态判断。 */
     private Boolean canUserCancel;
 }

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

@@ -0,0 +1,12 @@
+package com.ruoyi.app.flashdelivery.dto;
+
+import java.util.List;
+import lombok.Data;
+
+/** v2 待接单改址:只提交完整站点,订单级物品与运价沿用原订单。 */
+@Data
+public class FlashDeliveryV2AddressChangeRequest {
+    private Long orderId;
+    private Integer orderVersion;
+    private List<FlashDeliveryV2StopRequest> stops;
+}

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

@@ -0,0 +1,15 @@
+package com.ruoyi.app.flashdelivery.dto;
+
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+/** v2 确认改址时回传最新报价,订单/版本/完整站点均在 body。 */
+@Data
+@EqualsAndHashCode(callSuper = true)
+public class FlashDeliveryV2AddressConfirmRequest extends FlashDeliveryV2AddressChangeRequest {
+    private Integer quotedDistanceMeters;
+    private Long quotedBaseDeliveryFee;
+    private Long quotedDistanceFee;
+    private Long quotedUrgentFee;
+    private Long quotedAmount;
+}

+ 250 - 74
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryApplicationService.java

@@ -129,11 +129,13 @@ public class FlashDeliveryApplicationService {
     /** 闪送首页:返回开放的业务场景、配送等级,以及当前时段各服务的公开运价摘要。 */
     public FlashDeliveryHomeView home() {
         FlashDeliveryHomeView view = new FlashDeliveryHomeView();
-        view.setPickupStopLimit(pickupStopLimit());
-        view.setShowMultiPickup(dictionaryEnabled("flash_show_multi_pickup"));
-        view.setDeliveryStopLimit(deliveryStopLimit());
-        view.setShowMultiDropoff(dictionaryEnabled("flash_show_multi_dropoff"));
-        view.setShowUrgentOption(dictionaryEnabled("flash_show_urgent_option"));
+        int pickupLimit = pickupStopLimit();
+        int deliveryLimit = deliveryStopLimit();
+        view.setPickupStopLimit(pickupLimit);
+        view.setShowMultiPickup(pickupLimit > 1);
+        view.setDeliveryStopLimit(deliveryLimit);
+        view.setShowMultiDropoff(deliveryLimit > 1);
+        view.setShowUrgentOption(true);
         view.setServiceTypes(SERVICE_TYPES);
         view.setDeliveryTypes(List.of("NORMAL", "URGENT"));
         FlashDeliveryPricing pricing = findPricingAtOrNull(pricingTargetTime(new Date()), FlashDeliveryVehicleType.MOTORCYCLE);
@@ -303,11 +305,25 @@ public class FlashDeliveryApplicationService {
         if (!"sender".equals(selectedRole) && !"receiver".equals(selectedRole)) {
             throw fail("flash.delivery.tab.invalid");
         }
-        String participantColumn = "receiver".equals(selectedRole) ? "receiver_user_id" : "user_id";
-        QueryWrapper<FlashDeliveryOrder> query = new QueryWrapper<FlashDeliveryOrder>()
-                .eq(participantColumn, userId).orderByDesc("create_time");
+        QueryWrapper<FlashDeliveryOrder> query = new QueryWrapper<>();
+        if ("receiver".equals(selectedRole)) {
+            query.and(participant -> participant.eq("receiver_user_id", userId)
+                    .or().apply("exists (select 1 from flash_delivery_order_stop s where s.order_id = flash_delivery_order.id "
+                            + "and s.stop_type = 'DELIVERY' and s.receiver_user_id = {0})", userId));
+        } else {
+            query.eq("user_id", userId);
+        }
+        query.orderByDesc("create_time");
         return mapPage(orderMapper.selectPage(page(pageNum, pageSize), query), order -> {
             FlashDeliveryUserOrderListView view = userOrderListView(order);
+            if ("receiver".equals(selectedRole) && order.getDeliveryStopCount() != null
+                    && !Objects.equals(userId, order.getUserId())) {
+                stops(order.getId()).stream().filter(s -> "DELIVERY".equals(s.getStopType())
+                        && Objects.equals(userId, s.getReceiverUserId())).findFirst().ifPresent(own -> {
+                    view.setDeliveryAddress(own.getAddress());
+                    view.setDeliveryDetailAddress(own.getAddressDetail());
+                });
+            }
             view.setCanUserCancel(Objects.equals(userId, order.getUserId()) && canUserCancel(order));
             return view;
         });
@@ -325,7 +341,7 @@ public class FlashDeliveryApplicationService {
 
     private FlashDeliveryOrderDetailView userDetail(Long userId, FlashDeliveryOrder order) {
         if (!isParticipant(userId, order)) throw fail("flash.delivery.order.not.found");
-        FlashDeliveryOrderDetailView view = participantDetail(order, true, false);
+        FlashDeliveryOrderDetailView view = participantDetail(order, true, false, userId);
         view.setCanUserCancel(Objects.equals(userId, order.getUserId()) && Boolean.TRUE.equals(view.getCanUserCancel()));
         return view;
     }
@@ -356,24 +372,7 @@ public class FlashDeliveryApplicationService {
         copyPickupSummary(order, pickups);
         copyAddress(request.getDelivery(), order, false);
         order.setReceiverUserId(resolveReceiverUserId(order.getDeliveryPhone()));
-        order.setDistanceMeters(quote.getDistanceMeters());
-        order.setDistanceSource(quote.getDistanceSource());
-        order.setEstimatedDurationSeconds(quote.getEstimatedDurationSeconds());
-        // 改址报价携带原运价快照,金额与完整站点同事务保存,不引入最新运价。
-        order.setPricingId(quote.getPricingId());
-        order.setPricingVersion(quote.getPricingVersion());
-        order.setPricingStartTime(quote.getStartTime());
-        order.setPricingEndTime(quote.getEndTime());
-        order.setStartingDistance(quote.getStartingDistance());
-        order.setStartingFare(quote.getStartingFare());
-        order.setDistance(quote.getDistance());
-        order.setFreight(quote.getFreight());
-        order.setUrgentRate(quote.getUrgentRate());
-        order.setMinimumUrgentFee(quote.getMinimumUrgentFee());
-        order.setDistanceFee(quote.getDistanceFee());
-        order.setBaseDeliveryFee(quote.getBaseDeliveryFee());
-        order.setUrgentFee(quote.getUrgentFee());
-        order.setAmount(quote.getAmount());
+        applyAddressQuote(order, quote);
         Date now = new Date();
         order.setUpdateTime(now);
         // 即使上面的查询仍显示待接单,骑手也可能已接走,最终以条件 UPDATE 为准。
@@ -437,16 +436,12 @@ public class FlashDeliveryApplicationService {
     /** 地址修改报价:使用订单保存的运价快照重算,不采用当前运价,避免改地址顺带变价。 */
     private FlashDeliveryQuoteView calculateAddressQuote(FlashDeliveryOrder order,
                                                          FlashDeliveryAddressChangeRequest request) {
+        if (order.getDeliveryStopCount() != null) throw fail("flash.delivery.edit.not.allowed");
         List<FlashDeliveryPickupRequest> pickups = editablePickups(order, request);
         validatePickups(pickups, request.getDelivery());
         RouteDistance route = pickupRoute(pickups, request.getDelivery(), vehicleTypeOf(order));
         // FR-074/093:改址沿用下单快照,只重算整条路线,平台后续调价不影响此单。
-        FlashDeliveryPricing pricing = new FlashDeliveryPricing();
-        pricing.setId(order.getPricingId()); pricing.setConfigVersion(order.getPricingVersion());
-        pricing.setStartTime(order.getPricingStartTime()); pricing.setEndTime(order.getPricingEndTime());
-        pricing.setStartingDistance(order.getStartingDistance()); pricing.setStartingFare(order.getStartingFare());
-        pricing.setDistance(order.getDistance()); pricing.setFreight(order.getFreight());
-        pricing.setUrgentRate(order.getUrgentRate()); pricing.setMinimumUrgentFee(order.getMinimumUrgentFee());
+        FlashDeliveryPricing pricing = pricingSnapshot(order);
         FlashDeliveryPriceBreakdown breakdown;
         try {
             breakdown = pricingCalculator.calculateBreakdown(pricing, route.distanceMeters(),
@@ -466,6 +461,111 @@ public class FlashDeliveryApplicationService {
         return view;
     }
 
+    /** v2 待接单改址报价;订单级物品、配送方式与原运价快照保持不变。 */
+    public FlashDeliveryQuoteView quoteAddressV2(Long userId, FlashDeliveryV2AddressChangeRequest request) {
+        if (request == null) throw fail("flash.delivery.request.required");
+        FlashDeliveryOrder order = requireEditableOrder(userId, request.getOrderId(), request.getOrderVersion());
+        return calculateAddressQuoteV2(order, request);
+    }
+
+    /** v2 改址必须提交全部站点,条件更新成功后再替换全部站点。 */
+    @Transactional
+    public FlashDeliveryOrderDetailView updateAddressV2(Long userId, FlashDeliveryV2AddressConfirmRequest request) {
+        if (request == null) throw fail("flash.delivery.request.required");
+        FlashDeliveryOrder order = requireEditableOrder(userId, request.getOrderId(), request.getOrderVersion());
+        FlashDeliveryQuoteView quote = calculateAddressQuoteV2(order, request);
+        if (!Objects.equals(request.getQuotedDistanceMeters(), quote.getDistanceMeters())
+                || !Objects.equals(request.getQuotedBaseDeliveryFee(), quote.getBaseDeliveryFee())
+                || !Objects.equals(request.getQuotedDistanceFee(), quote.getDistanceFee())
+                || !Objects.equals(request.getQuotedUrgentFee(), quote.getUrgentFee())
+                || !Objects.equals(request.getQuotedAmount(), quote.getAmount())) {
+            throw new FlashDeliveryQuoteChangedException(quote);
+        }
+        List<FlashDeliveryV2StopRequest> stops = normalizeV2Stops(request.getStops());
+        FlashDeliveryV2QuoteRequest orderItems = v2OrderSnapshot(order, stops);
+        copyPickupSummary(order, pickupAdapters(stops.stream().filter(s -> "PICKUP".equals(s.getType())).toList(), orderItems));
+        FlashDeliveryV2StopRequest firstDelivery = stops.stream().filter(s -> "DELIVERY".equals(s.getType())).findFirst().orElseThrow();
+        copyAddress(firstDelivery, order, false);
+        order.setReceiverUserId(resolveReceiverUserId(firstDelivery.getPhone()));
+        order.setPickupStopCount((int) stops.stream().filter(s -> "PICKUP".equals(s.getType())).count());
+        order.setPickedUpStopCount(0);
+        order.setDeliveryStopCount((int) stops.stream().filter(s -> "DELIVERY".equals(s.getType())).count());
+        order.setDeliveredStopCount(0);
+        order.setCurrentTargetStopId(null);
+        order.setDeliveryPinCode(null);
+        applyAddressQuote(order, quote);
+        Date now = new Date();
+        order.setUpdateTime(now);
+        if (orderMapper.updateWaitingAddress(order, request.getOrderVersion()) != 1) {
+            throw fail("flash.delivery.state.changed");
+        }
+        stopMapper.delete(new QueryWrapper<FlashDeliveryOrderStop>().eq("order_id", order.getId()));
+        saveStopsV2(order, stops, Boolean.TRUE.equals(order.getPinRequired()), now);
+        order.setVersion(order.getVersion() + 1);
+        writeLog(order.getId(), WAITING_ACCEPTANCE, WAITING_ACCEPTANCE, "USER", userId, "ADDRESS_UPDATED", now);
+        return participantDetail(order, true, false);
+    }
+
+    private FlashDeliveryQuoteView calculateAddressQuoteV2(FlashDeliveryOrder order,
+                                                            FlashDeliveryV2AddressChangeRequest request) {
+        if (order.getDeliveryStopCount() == null) throw fail("flash.delivery.edit.not.allowed");
+        List<FlashDeliveryV2StopRequest> stops = normalizeV2Stops(request.getStops());
+        FlashDeliveryV2QuoteRequest orderItems = v2OrderSnapshot(order, stops);
+        validateOrderLevelItem(orderItems);
+        FlashDeliveryPricing pricing = pricingSnapshot(order);
+        RouteDistance route = v2Route(stops, vehicleTypeOf(order));
+        FlashDeliveryPriceBreakdown breakdown;
+        try {
+            breakdown = pricingCalculator.calculateBreakdown(pricing, route.distanceMeters(),
+                    order.getDeliveryType(), order.getTipAmount());
+        } catch (ArithmeticException exception) {
+            throw fail("flash.delivery.tip.invalid");
+        }
+        List<FlashDeliveryPickupRequest> pickups = pickupAdapters(stops.stream()
+                .filter(s -> "PICKUP".equals(s.getType())).toList(), orderItems);
+        FlashDeliveryQuoteView view = quoteView(quoteAdapter(orderItems),
+                new QuoteContext(pricing, route, breakdown, pickups));
+        view.setOrderVersion(order.getVersion());
+        return view;
+    }
+
+    private FlashDeliveryV2QuoteRequest v2OrderSnapshot(FlashDeliveryOrder order, List<FlashDeliveryV2StopRequest> stops) {
+        FlashDeliveryV2QuoteRequest snapshot = new FlashDeliveryV2QuoteRequest();
+        BeanUtils.copyProperties(order, snapshot);
+        snapshot.setStops(stops);
+        return snapshot;
+    }
+
+    private FlashDeliveryPricing pricingSnapshot(FlashDeliveryOrder order) {
+        FlashDeliveryPricing pricing = new FlashDeliveryPricing();
+        pricing.setId(order.getPricingId()); pricing.setConfigVersion(order.getPricingVersion());
+        pricing.setStartTime(order.getPricingStartTime()); pricing.setEndTime(order.getPricingEndTime());
+        pricing.setStartingDistance(order.getStartingDistance()); pricing.setStartingFare(order.getStartingFare());
+        pricing.setDistance(order.getDistance()); pricing.setFreight(order.getFreight());
+        pricing.setUrgentRate(order.getUrgentRate()); pricing.setMinimumUrgentFee(order.getMinimumUrgentFee());
+        return pricing;
+    }
+
+    private void applyAddressQuote(FlashDeliveryOrder order, FlashDeliveryQuoteView quote) {
+        order.setDistanceMeters(quote.getDistanceMeters());
+        order.setDistanceSource(quote.getDistanceSource());
+        order.setEstimatedDurationSeconds(quote.getEstimatedDurationSeconds());
+        order.setPricingId(quote.getPricingId());
+        order.setPricingVersion(quote.getPricingVersion());
+        order.setPricingStartTime(quote.getStartTime());
+        order.setPricingEndTime(quote.getEndTime());
+        order.setStartingDistance(quote.getStartingDistance());
+        order.setStartingFare(quote.getStartingFare());
+        order.setDistance(quote.getDistance());
+        order.setFreight(quote.getFreight());
+        order.setUrgentRate(quote.getUrgentRate());
+        order.setMinimumUrgentFee(quote.getMinimumUrgentFee());
+        order.setDistanceFee(quote.getDistanceFee());
+        order.setBaseDeliveryFee(quote.getBaseDeliveryFee());
+        order.setUrgentFee(quote.getUrgentFee());
+        order.setAmount(quote.getAmount());
+    }
+
     /** 用户取消:仅待接单或已接单(实际取件前)允许。 */
     @Transactional
     public void userCancel(Long userId, Long orderId, FlashDeliveryReasonRequest request) {
@@ -489,7 +589,8 @@ public class FlashDeliveryApplicationService {
     public void confirmReceipt(Long userId, Long orderId) {
         FlashDeliveryOrder order = lockOrder(orderId);
         if (!isParticipant(userId, order)) throw fail("flash.delivery.order.not.found");
-        if (!allPicked(requireStops(order))) throw fail("flash.delivery.complete.not.allowed");
+        List<FlashDeliveryOrderStop> rows = requireStops(order);
+        if (!allPicked(rows) || !allDelivered(rows)) throw fail("flash.delivery.complete.not.allowed");
         Date now = new Date();
         if (orderMapper.transitionByParticipant(orderId, userId, DELIVERED, COMPLETED, now) != 1) {
             throw fail("flash.delivery.complete.not.allowed");
@@ -642,6 +743,7 @@ public class FlashDeliveryApplicationService {
     public void deliver(Long riderId, Long orderId, FlashDeliveryDeliverRequest request) {
         requireRider(riderId);
         FlashDeliveryOrder order = lockOrder(orderId);
+        if (order.getDeliveryStopCount() != null) throw fail("flash.delivery.transition.not.allowed");
         if (!Objects.equals(riderId, order.getRiderId()) || !PICKED_UP.equals(order.getStatus())) {
             throw fail("flash.delivery.transition.not.allowed");
         }
@@ -671,19 +773,21 @@ public class FlashDeliveryApplicationService {
     public void deliverAtStop(Long riderId, Long orderId, Long stopId, FlashDeliveryDeliverRequest request) {
         requireRider(riderId);
         FlashDeliveryOrder order = lockOrder(orderId);
-        if (!Objects.equals(riderId, order.getRiderId()) || !PICKED_UP.equals(order.getStatus())) {
-            throw fail("flash.delivery.transition.not.allowed");
-        }
+        if (!Objects.equals(riderId, order.getRiderId())) throw fail("flash.delivery.order.not.found");
         List<FlashDeliveryOrderStop> rows = requireStops(order);
-        if (!allPicked(rows)) throw fail("flash.delivery.transition.not.allowed");
         FlashDeliveryOrderStop stop = requireStop(rows, stopId);
         if (!"DELIVERY".equals(stop.getStopType())) throw fail("flash.delivery.stop.invalid");
         // 已确认站点的重试直接成功,不重复写凭证或推进计数。
         if ("DELIVERED".equals(stop.getStatus())) return;
-        // 优先使用按站收货码;订单级摘要字段作为回退。
-        String expectedPin = stop.getDeliveryPinCode() != null ? stop.getDeliveryPinCode() : order.getDeliveryPinCode();
+        if (!PICKED_UP.equals(order.getStatus()) || !allPicked(rows)) {
+            throw fail("flash.delivery.transition.not.allowed");
+        }
+        if (!Objects.equals(stopId, targetStopId(order, rows))) throw fail("flash.delivery.target.mismatch");
+        String expectedPin = order.getDeliveryStopCount() == null
+                ? order.getDeliveryPinCode() : stop.getDeliveryPinCode();
         if (Boolean.TRUE.equals(order.getPinRequired())
-                && (request == null || !Objects.equals(expectedPin, trimToNull(request.getPinCode())))) {
+                && (!hasText(expectedPin) || request == null
+                || !Objects.equals(expectedPin, trimToNull(request.getPinCode())))) {
             throw fail("flash.delivery.pin.invalid");
         }
         List<String> urls = validateProofUrls(request == null ? null : request.getImageUrls(), true);
@@ -693,16 +797,18 @@ public class FlashDeliveryApplicationService {
         saveStationImages(orderId, stopId, "DELIVERY", "RIDER", riderId, urls, now);
         order.setDeliveredStopCount((int) rows.stream().filter(s -> "DELIVERY".equals(s.getStopType())
                 && "DELIVERED".equals(s.getStatus())).count());
-        order.setUpdateTime(now);
-        boolean allDelivered = rows.stream().filter(s -> "DELIVERY".equals(s.getStopType()))
-                .allMatch(s -> "DELIVERED".equals(s.getStatus()));
-        if (allDelivered) {
+        order.setCurrentTargetStopId(null); order.setUpdateTime(now);
+        if (allDelivered(rows)) {
             // 末站完成才整单推进,与取件侧同一行锁与条件更新路径。
             order.setStatus(DELIVERED); order.setDeliveredAt(now);
         }
         if (orderMapper.updateDeliveryFulfilment(order) != 1) throw fail("flash.delivery.state.changed");
         writeLog(orderId, PICKED_UP, order.getStatus(), "RIDER", riderId, "DELIVERY_STOP:" + stopId, now);
-        if (DELIVERED.equals(order.getStatus())) notificationService.notifyDelivered(requireOrder(orderId));
+        if (order.getDeliveryStopCount() != null) {
+            notificationService.notifyDeliveryStop(order, stop, order.getDeliveredStopCount(), expectedDeliveryStopCount(order));
+        } else if (DELIVERED.equals(order.getStatus())) {
+            notificationService.notifyDelivered(requireOrder(orderId));
+        }
     }
 
     // ==== v2 多点取送统一契约(2026-10-14):统一 stops 数组、订单级物品、独立计价 ====
@@ -758,8 +864,8 @@ public class FlashDeliveryApplicationService {
         order.setScheduledPickupStartAt("SCHEDULED".equals(deliveryMode) ? request.getScheduledPickupStartAt() : null);
         order.setScheduledPickupEndAt("SCHEDULED".equals(deliveryMode) ? request.getScheduledPickupEndAt() : null);
         order.setPinRequired(pinRequired);
-        // 订单级收货码作为按站收货码的回退摘要;v2 各收货站独立生成。
-        order.setDeliveryPinCode(pinRequired ? String.format(Locale.ROOT, "%04d", PIN_RANDOM.nextInt(10_000)) : null);
+        // v2 只使用各收货站独立的收货码,订单级字段不生成通用码。
+        order.setDeliveryPinCode(null);
         FlashDeliveryV2StopRequest firstDelivery = deliveries.get(0);
         copyAddress(firstDelivery, order, false);
         // 订单级收件人沿用首收货站匹配,逐站收件人保存在站点表。
@@ -875,12 +981,6 @@ public class FlashDeliveryApplicationService {
                 || pickups.size() > pickupStopLimit() || deliveries.size() > deliveryStopLimit()) {
             throw fail("flash.delivery.stops.limit.exceeded");
         }
-        if (pickups.size() > 1 && !dictionaryEnabled("flash_show_multi_pickup")) {
-            throw fail("flash.delivery.multi.pickup.disabled");
-        }
-        if (deliveries.size() > 1 && !dictionaryEnabled("flash_show_multi_dropoff")) {
-            throw fail("flash.delivery.multi.dropoff.disabled");
-        }
         // 顺序即路线:先取完再送;交错取送属后续增量。
         boolean deliverySeen = false;
         for (FlashDeliveryV2StopRequest stop : stops) {
@@ -942,6 +1042,7 @@ public class FlashDeliveryApplicationService {
     /** 保存 v2 全部站点:按用户顺序落 stop_order,逐站写入备注、收件人与收货码。 */
     private void saveStopsV2(FlashDeliveryOrder order, List<FlashDeliveryV2StopRequest> stops,
                              boolean pinRequired, Date now) {
+        Set<String> deliveryPins = new HashSet<>();
         for (int i = 0; i < stops.size(); i++) {
             FlashDeliveryV2StopRequest stop = stops.get(i);
             FlashDeliveryOrderStop row = new FlashDeliveryOrderStop();
@@ -955,7 +1056,11 @@ public class FlashDeliveryApplicationService {
             if ("DELIVERY".equals(stop.getType())) {
                 row.setReceiverUserId(resolveReceiverUserId(stop.getPhone()));
                 if (pinRequired) {
-                    row.setDeliveryPinCode(String.format(Locale.ROOT, "%04d", PIN_RANDOM.nextInt(10_000)));
+                    String pin;
+                    do {
+                        pin = String.format(Locale.ROOT, "%04d", PIN_RANDOM.nextInt(10_000));
+                    } while (!deliveryPins.add(pin));
+                    row.setDeliveryPinCode(pin);
                 }
             }
             if (stopMapper.insert(row) != 1) throw fail("flash.delivery.state.changed");
@@ -1110,7 +1215,9 @@ public class FlashDeliveryApplicationService {
     @Transactional
     public boolean complete(Long operatorId, String operatorType, Long orderId) {
         FlashDeliveryOrder order = lockOrder(orderId);
-        if (!DELIVERED.equals(order.getStatus()) || !allPicked(requireStops(order))) return false;
+        if (!DELIVERED.equals(order.getStatus())) return false;
+        List<FlashDeliveryOrderStop> rows = requireStops(order);
+        if (!allPicked(rows) || !allDelivered(rows)) return false;
         Date now = new Date();
         if (orderMapper.transitionByStatus(orderId, DELIVERED, COMPLETED, "completed_at", now) != 1) return false;
         writeLog(orderId, DELIVERED, COMPLETED, operatorType, operatorId, null, now);
@@ -1365,20 +1472,62 @@ public class FlashDeliveryApplicationService {
     private FlashDeliveryOrderDetailView participantDetail(FlashDeliveryOrder order,
                                                            boolean userView,
                                                            boolean hideSensitiveFields) {
+        return participantDetail(order, userView, hideSensitiveFields, userView ? order.getUserId() : null);
+    }
+
+    private FlashDeliveryOrderDetailView participantDetail(FlashDeliveryOrder order,
+                                                            boolean userView,
+                                                            boolean hideSensitiveFields, Long viewerId) {
         FlashDeliveryOrderDetailView view = detailOrderView(order, !hideSensitiveFields);
         if (!hideSensitiveFields) view.setOrderVersion(order.getVersion());
         List<FlashDeliveryOrderStop> stationRows = stops(order.getId());
         view.setCanUserCancel(canCancelBeforePickup(order, stationRows));
         List<FlashDeliveryOrderImage> stationImages = hideSensitiveFields ? List.of() : imageMapper.selectList(currentRead(
                 new QueryWrapper<FlashDeliveryOrderImage>().eq("order_id", order.getId()).orderByAsc("proof_type", "sort_order")));
-        view.setStops(stopViews(stationRows, !hideSensitiveFields, stationImages));
+        boolean sender = userView && Objects.equals(viewerId, order.getUserId());
+        boolean v2Receiver = userView && order.getDeliveryStopCount() != null && !sender;
+        if (v2Receiver) {
+            Set<Long> ownStopIds = stationRows.stream().filter(s -> "DELIVERY".equals(s.getStopType())
+                    && Objects.equals(viewerId, s.getReceiverUserId())).map(FlashDeliveryOrderStop::getId)
+                    .collect(java.util.stream.Collectors.toSet());
+            stationImages = stationImages.stream().filter(image -> !"DELIVERY".equals(image.getProofType())
+                    || ownStopIds.contains(image.getStopId())).toList();
+        }
+        List<FlashDeliveryStopView> stationViews = stopViews(stationRows, !hideSensitiveFields, stationImages);
+        if (userView && order.getDeliveryStopCount() != null) {
+            for (int index = 0; index < stationRows.size(); index++) {
+                FlashDeliveryOrderStop stop = stationRows.get(index);
+                FlashDeliveryStopView stopView = stationViews.get(index);
+                if (!"DELIVERY".equals(stop.getStopType())) continue;
+                if (sender || Objects.equals(viewerId, stop.getReceiverUserId())) {
+                    stopView.setDeliveryPinCode(Boolean.TRUE.equals(order.getPinRequired()) ? stop.getDeliveryPinCode() : null);
+                } else if (v2Receiver) {
+                    stopView.setName(null); stopView.setPhone(null); stopView.setAddress(null); stopView.setAddressDetail(null);
+                    stopView.setHandoffMethod(null); stopView.setLongitude(null); stopView.setLatitude(null);
+                    stopView.setRemark(null); stopView.setImageUrls(List.of());
+                }
+            }
+            if (v2Receiver) {
+                stationRows.stream().filter(s -> "DELIVERY".equals(s.getStopType())
+                        && Objects.equals(viewerId, s.getReceiverUserId())).findFirst().ifPresent(own -> {
+                    FlashDeliveryAddressView delivery = new FlashDeliveryAddressView();
+                    delivery.setName(own.getName()); delivery.setPhone(own.getPhone());
+                    delivery.setAddress(own.getAddress()); delivery.setAddressDetail(own.getAddressDetail());
+                    delivery.setHandoffMethod(own.getHandoffMethod());
+                    delivery.setLongitude(own.getLongitude()); delivery.setLatitude(own.getLatitude());
+                    view.setDelivery(delivery);
+                });
+            }
+        }
+        view.setStops(stationViews);
         view.setCurrentTargetStopId(targetStopId(order, stationRows));
         if (!hideSensitiveFields) {
             view.setTargetAdjustments(adjustmentMapper.selectList(currentRead(new QueryWrapper<FlashDeliveryTargetAdjustment>()
                     .eq("order_id", order.getId()).orderByAsc("create_time", "id"))));
         }
         if (userView) {
-            view.setDeliveryPinCode(Boolean.TRUE.equals(order.getPinRequired()) ? order.getDeliveryPinCode() : null);
+            view.setDeliveryPinCode(sender && order.getDeliveryStopCount() == null
+                    && Boolean.TRUE.equals(order.getPinRequired()) ? order.getDeliveryPinCode() : null);
             // 接单后直接返回骑手完整用户资料;star 填充该骑手的闪送平均分供前端展示。
             if (order.getRiderId() != null) {
                 InfoUser rider = userMapper.selectInfoUserByUserId(order.getRiderId());
@@ -1388,7 +1537,7 @@ public class FlashDeliveryApplicationService {
                 view.setRider(rider);
             }
             // 线下转账单送达/完成后返回骑手启用卡,供用户线下转账结清(仅寄件人视角)
-            if (PAY_TYPE_OFFLINE_TRANSFER.equals(order.getPayType())
+            if (sender && PAY_TYPE_OFFLINE_TRANSFER.equals(order.getPayType())
                     && (DELIVERED.equals(order.getStatus()) || COMPLETED.equals(order.getStatus()))) {
                 view.setRiderBankCard(riderActiveCardView(order.getRiderId()));
             }
@@ -1415,6 +1564,8 @@ public class FlashDeliveryApplicationService {
         view.setStatus(order.getStatus());
         view.setPickupStopCount(order.getPickupStopCount());
         view.setPickedUpStopCount(order.getPickedUpStopCount());
+        view.setDeliveryStopCount(expectedDeliveryStopCount(order));
+        view.setDeliveredStopCount(deliveredStopCount(order));
         view.setCanUserCancel(canUserCancel(order));
         view.setOrderVersion(order.getVersion());
         view.setPackageType(order.getPackageType());
@@ -1451,6 +1602,8 @@ public class FlashDeliveryApplicationService {
         view.setStatus(order.getStatus());
         view.setPickupStopCount(order.getPickupStopCount());
         view.setPickedUpStopCount(order.getPickedUpStopCount());
+        view.setDeliveryStopCount(expectedDeliveryStopCount(order));
+        view.setDeliveredStopCount(deliveredStopCount(order));
         view.setCanUserCancel(canUserCancel(order));
         view.setPackageType(order.getPackageType());
         view.setQuantity(order.getQuantity());
@@ -1534,6 +1687,8 @@ public class FlashDeliveryApplicationService {
         view.setStatus(order.getStatus());
         view.setPickupStopCount(order.getPickupStopCount());
         view.setPickedUpStopCount(order.getPickedUpStopCount());
+        view.setDeliveryStopCount(expectedDeliveryStopCount(order));
+        view.setDeliveredStopCount(deliveredStopCount(order));
         view.setCanUserCancel(canUserCancel(order));
         view.setPackageType(order.getPackageType());
         view.setQuantity(order.getQuantity());
@@ -1655,8 +1810,10 @@ public class FlashDeliveryApplicationService {
 
     /** 判断用户是否为订单寄件人或匹配收件人。 */
     private boolean isParticipant(Long userId, FlashDeliveryOrder order) {
-        return Objects.equals(userId, order.getUserId())
-                || Objects.equals(userId, order.getReceiverUserId());
+        if (userId == null) return false;
+        if (Objects.equals(userId, order.getUserId()) || Objects.equals(userId, order.getReceiverUserId())) return true;
+        return order.getDeliveryStopCount() != null && stops(order.getId()).stream().anyMatch(s ->
+                "DELIVERY".equals(s.getStopType()) && Objects.equals(userId, s.getReceiverUserId()));
     }
 
     /** 校验 token 用户为骑手(userType=2)并返回其资料。 */
@@ -1951,9 +2108,6 @@ public class FlashDeliveryApplicationService {
         if (pickups == null || pickups.isEmpty() || pickups.size() > pickupStopLimit()) {
             throw fail("flash.delivery.pickup.limit.exceeded");
         }
-        if (pickups.size() > 1 && !dictionaryEnabled("flash_show_multi_pickup")) {
-            throw fail("flash.delivery.multi.pickup.disabled");
-        }
         validateAddress(delivery);
         for (FlashDeliveryPickupRequest pickup : pickups) {
             validateAddress(pickup);
@@ -2030,8 +2184,6 @@ public class FlashDeliveryApplicationService {
         } catch (NumberFormatException ignored) { return 1; }
     }
 
-    private boolean dictionaryEnabled(String key) { return "1".equals(dictionaryValue(key)); }
-
     private String dictionaryValue(String key) {
         try {
             List<SysDictData> values = DictUtils.getDictCache(key);
@@ -2095,6 +2247,11 @@ public class FlashDeliveryApplicationService {
         return order.getDeliveryStopCount() == null ? 1 : order.getDeliveryStopCount();
     }
 
+    private int deliveredStopCount(FlashDeliveryOrder order) {
+        if (order.getDeliveryStopCount() != null) return Objects.requireNonNullElse(order.getDeliveredStopCount(), 0);
+        return DELIVERED.equals(order.getStatus()) || COMPLETED.equals(order.getStatus()) ? 1 : 0;
+    }
+
     private List<FlashDeliveryOrderStop> pickupStops(List<FlashDeliveryOrderStop> rows) {
         return rows.stream().filter(s -> "PICKUP".equals(s.getStopType())).toList();
     }
@@ -2108,16 +2265,24 @@ public class FlashDeliveryApplicationService {
         return !pickups.isEmpty() && pickups.stream().allMatch(s -> "PICKED_UP".equals(s.getStatus()));
     }
 
-    /** 选择的目标在刷新后保留;该站取货后回到原顺序最早的待取站点。 */
+    private boolean allDelivered(List<FlashDeliveryOrderStop> rows) {
+        List<FlashDeliveryOrderStop> deliveries = rows.stream().filter(s -> "DELIVERY".equals(s.getStopType())).toList();
+        return !deliveries.isEmpty() && deliveries.stream().allMatch(s -> "DELIVERED".equals(s.getStatus()));
+    }
+
+    /** 选择的目标在刷新后保留;本站完成后回到原顺序最早的未完成站点。 */
     private Long targetStopId(FlashDeliveryOrder order, List<FlashDeliveryOrderStop> rows) {
         if (CANCELLED.equals(order.getStatus()) || COMPLETED.equals(order.getStatus()) || DELIVERED.equals(order.getStatus())) return null;
+        String targetType = PICKED_UP.equals(order.getStatus()) ? "DELIVERY" : "PICKUP";
         if (order.getCurrentTargetStopId() != null && rows.stream().anyMatch(s ->
-                Objects.equals(s.getId(), order.getCurrentTargetStopId()) && "PICKUP".equals(s.getStopType()) && "PENDING".equals(s.getStatus()))) {
+                Objects.equals(s.getId(), order.getCurrentTargetStopId())
+                        && targetType.equals(s.getStopType()) && "PENDING".equals(s.getStatus()))) {
             return order.getCurrentTargetStopId();
         }
         return rows.stream().filter(s -> "PICKUP".equals(s.getStopType()) && "PENDING".equals(s.getStatus()))
                 .map(FlashDeliveryOrderStop::getId).findFirst().orElseGet(() -> rows.stream()
-                        .filter(s -> "DELIVERY".equals(s.getStopType())).map(FlashDeliveryOrderStop::getId).findFirst().orElse(null));
+                        .filter(s -> "DELIVERY".equals(s.getStopType()) && "PENDING".equals(s.getStatus()))
+                        .map(FlashDeliveryOrderStop::getId).findFirst().orElse(null));
     }
 
     /** 事务内先锁订单并读取最新状态,再查询站点,统一取货、调整与取消的并发边界。 */
@@ -2155,13 +2320,18 @@ public class FlashDeliveryApplicationService {
             // 重试返回当前进度,不把已过期的历史目标重新写回订单。
             return participantDetail(order, false, false);
         }
-        if (!ACCEPTED.equals(order.getStatus())) throw fail("flash.delivery.transition.not.allowed");
+        if (!ACCEPTED.equals(order.getStatus()) && !PICKED_UP.equals(order.getStatus())) {
+            throw fail("flash.delivery.transition.not.allowed");
+        }
         if (request.getOrderVersion() == null || !Objects.equals(request.getOrderVersion(), order.getVersion())) {
             throw fail("flash.delivery.state.changed");
         }
         List<FlashDeliveryOrderStop> rows = requireStops(order);
         FlashDeliveryOrderStop target = requireStop(rows, stopId);
-        if (!"PICKUP".equals(target.getStopType()) || !"PENDING".equals(target.getStatus())) throw fail("flash.delivery.stop.invalid");
+        String targetType = PICKED_UP.equals(order.getStatus()) ? "DELIVERY" : "PICKUP";
+        if (!targetType.equals(target.getStopType()) || !"PENDING".equals(target.getStatus())) {
+            throw fail("flash.delivery.stop.invalid");
+        }
         Long before = targetStopId(order, rows);
         if (!Objects.equals(before, stopId) && (!hasText(request.getReason()) || tooLong(request.getReason(), 500)
                 || !Boolean.TRUE.equals(request.getConsentDeclared()))) throw fail("flash.delivery.target.consent.required");
@@ -2173,7 +2343,9 @@ public class FlashDeliveryApplicationService {
         adjustment.setReason(trimToNull(request.getReason())); adjustment.setConsentDeclared(Boolean.TRUE.equals(request.getConsentDeclared())); adjustment.setCreateTime(now);
         if (adjustmentMapper.insert(adjustment) != 1) throw fail("flash.delivery.state.changed");
         order.setCurrentTargetStopId(stopId); order.setUpdateTime(now);
-        if (orderMapper.updateFulfilment(order) != 1) throw fail("flash.delivery.state.changed");
+        int updated = PICKED_UP.equals(order.getStatus())
+                ? orderMapper.updateDeliveryFulfilment(order) : orderMapper.updateFulfilment(order);
+        if (updated != 1) throw fail("flash.delivery.state.changed");
         order.setVersion(order.getVersion() + 1);
         return participantDetail(order, false, false);
     }
@@ -2215,8 +2387,11 @@ public class FlashDeliveryApplicationService {
         if (allPicked(rows)) { order.setStatus(PICKED_UP); order.setPickedUpAt(now); }
         if (orderMapper.updateFulfilment(order) != 1) throw fail("flash.delivery.state.changed");
         writeLog(orderId, ACCEPTED, order.getStatus(), "RIDER", riderId, "PICKUP_STOP:" + stopId, now);
-        // 全部取货站完成、订单推进为已取件时通知寄件用户。
-        if (PICKED_UP.equals(order.getStatus())) notificationService.notifyPickedUp(order);
+        if (order.getDeliveryStopCount() != null) {
+            notificationService.notifyPickupStop(order, stop, order.getPickedUpStopCount(), pickups.size());
+        } else if (PICKED_UP.equals(order.getStatus())) {
+            notificationService.notifyPickedUp(order);
+        }
     }
 
     private void saveStationImages(Long orderId, Long stopId, String proofType, String operatorType,
@@ -2235,6 +2410,7 @@ public class FlashDeliveryApplicationService {
         return rows.stream().map(stop -> {
             FlashDeliveryStopView view = new FlashDeliveryStopView();
             BeanUtils.copyProperties(stop, view);
+            view.setDeliveryPinCode(null);
             if (sensitive) view.setImageUrls(images.stream().filter(i -> Objects.equals(i.getStopId(), stop.getId())
                             && Objects.equals(i.getProofType(), stop.getStopType()))
                     .map(FlashDeliveryOrderImage::getImageUrl).toList());

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

@@ -1,5 +1,6 @@
 package com.ruoyi.app.flashdelivery.service;
 
+import com.alibaba.fastjson.JSONObject;
 import com.ruoyi.app.order.dto.OrderPushBodyDto;
 import com.ruoyi.app.push.BusinessPushProperties;
 import com.ruoyi.app.push.NativeRiderBatchPushService;
@@ -8,10 +9,13 @@ import com.ruoyi.app.utils.PayPush;
 import com.ruoyi.app.utils.event.PushEventService;
 import com.ruoyi.common.core.domain.entity.SysDictData;
 import com.ruoyi.common.utils.DictUtils;
+import com.ruoyi.common.utils.LocaleUtils;
+import com.ruoyi.common.utils.MessageUtils;
 import com.ruoyi.framework.manager.AsyncManager;
 import com.ruoyi.system.domain.InfoUser;
 import com.ruoyi.system.domain.RiderPosition;
 import com.ruoyi.system.domain.flash.FlashDeliveryOrder;
+import com.ruoyi.system.domain.flash.FlashDeliveryOrderStop;
 import com.ruoyi.system.mapper.InfoUserMapper;
 import com.ruoyi.system.mapper.RiderPositionMapper;
 import org.slf4j.Logger;
@@ -22,6 +26,7 @@ import org.springframework.transaction.support.TransactionSynchronization;
 import org.springframework.transaction.support.TransactionSynchronizationManager;
 
 import java.util.List;
+import java.util.Locale;
 import java.util.TimerTask;
 
 import static com.ruoyi.system.domain.flash.FlashDeliveryStatus.ACCEPTED;
@@ -42,6 +47,8 @@ public class FlashDeliveryNotificationService {
     private static final String CONTENT_ACCEPTED = "no.message.push.delivery.personnel.receiving.order";
     private static final String CONTENT_PICKED_UP = "no.message.push.delivery.personnel.qspsz.order";
     private static final String CONTENT_DELIVERED = "no.message.push.delivery.personnel.qsysd.order";
+    private static final String CONTENT_PICKUP_STOP = "no.message.push.flash.pickup.stop.completed";
+    private static final String CONTENT_DELIVERY_STOP = "no.message.push.flash.delivery.stop.completed";
     private static final String CONTENT_CANCELLED = "no.message.push.order.cancelled";
     private static final String CONTENT_AVAILABLE = "no.message.push.new.flash.order";
     private static final int RIDER_NOTIFICATION_LIMIT = 20;
@@ -191,6 +198,57 @@ public class FlashDeliveryNotificationService {
         });
     }
 
+    /** 每个取货站首次确认成功后,通知订单发起人本站及取货进度。 */
+    public void notifyPickupStop(FlashDeliveryOrder order, FlashDeliveryOrderStop stop, int completed, int total) {
+        notifyStopProgress(order, stop, completed, total, "FLASH_PICKUP_STOP_COMPLETED", CONTENT_PICKUP_STOP);
+    }
+
+    /** 每个收货站首次确认送达后,通知订单发起人本站及送达进度。 */
+    public void notifyDeliveryStop(FlashDeliveryOrder order, FlashDeliveryOrderStop stop, int completed, int total) {
+        notifyStopProgress(order, stop, completed, total, "FLASH_DELIVERY_STOP_COMPLETED", CONTENT_DELIVERY_STOP);
+    }
+
+    private void notifyStopProgress(FlashDeliveryOrder order, FlashDeliveryOrderStop stop, int completed,
+                                    int total, String eventType, String contentKey) {
+        if (order == null || stop == null || order.getUserId() == null || stop.getStopOrder() == null) return;
+        Long senderId = order.getUserId();
+        String orderNo = order.getOrderNo();
+        int routeStopNumber = stop.getStopOrder() + 1;
+        // 在本次成功动作处固定状态和进度;不序列化联系人、地址或收货码。
+        JSONObject payload = new JSONObject();
+        payload.put("ddId", orderNo);
+        payload.put("state", order.getStatus());
+        payload.put("type", PUSH_PAYLOAD_TYPE_NONE);
+        payload.put("pushType", PUSH_TYPE_FLASH);
+        payload.put("eventType", eventType);
+        payload.put("stopId", stop.getId());
+        payload.put("stopType", stop.getStopType());
+        payload.put("stopOrder", stop.getStopOrder());
+        payload.put("completedStopCount", completed);
+        payload.put("totalStopCount", total);
+        String body = payload.toJSONString();
+        runAfterCommit(() -> {
+            InfoUser sender = userMapper.selectById(senderId);
+            if (sender == null) return;
+            String cid = sender.getCid();
+            AsyncManager.me().execute(new TimerTask() {
+                @Override
+                public void run() {
+                    try {
+                        Locale locale = LocaleUtils.getUserLocale(senderId);
+                        String title = MessageUtils.message(TITLE_KEY, locale);
+                        String content = MessageUtils.message(contentKey, locale, routeStopNumber, completed, total)
+                                + ",NO:" + orderNo;
+                        PayPush.userPush(new PayPush(), pushEventService, senderId, cid, title, content, body, "");
+                    } catch (Exception exception) {
+                        log.error("闪送逐站用户推送失败,userId:{}, orderNo:{}, eventType:{}",
+                                senderId, orderNo, eventType, exception);
+                    }
+                }
+            });
+        });
+    }
+
     /** 订单取消后通知接单骑手;未接单(无骑手归属)或骑手缺失时跳过,寄件用户不推。 */
     public void notifyCancelled(FlashDeliveryOrder order) {
         runAfterCommit(() -> {

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

@@ -181,6 +181,8 @@ no.points.use.fail=積分抵扣未開啟
 no.points.not.exist=您的積分錢包不存在
 no.message.push.delivery.personnel.qspsz.order=騎手配送中
 no.message.push.delivery.personnel.qsysd.order=騎手已送達
+no.message.push.flash.pickup.stop.completed=第 {0} 站已完成取貨,取貨進度 {1}/{2}
+no.message.push.flash.delivery.stop.completed=第 {0} 站已送達,送達進度 {1}/{2}
 no.wallet.noexist.userinfo=用戶資訊不存在
 no.user.state.no.audit=帳號狀態未審核
 no.system.busy.try.again=系統繁忙,請稍後重試
@@ -400,7 +402,6 @@ rider.accept.total.limit.reached=進行中的配送任務總數已達接單上
 flash.delivery.pickup.input.conflict=取貨點格式或舊物品欄位不可同時提交。
 flash.delivery.pickups.required=請提交全部取貨點及各站物品資訊。
 flash.delivery.pickup.limit.exceeded=取貨點數量超過有效上限。
-flash.delivery.multi.pickup.disabled=暫未開放多個取貨點。
 flash.delivery.stops.invalid=訂單站點資料不完整。
 flash.delivery.stop.invalid=該站點不屬於本訂單或不適用於此操作。
 flash.delivery.target.request.conflict=該請求編號已用於不同的目標調整。
@@ -440,7 +441,6 @@ flash.delivery.stops.required=Vui lòng gửi ít nhất một điểm nhận v
 flash.delivery.stop.type.invalid=Loại điểm dừng không hợp lệ
 flash.delivery.stop.remark.too.long=Ghi chú của điểm dừng không được vượt quá 200 ký tự
 flash.delivery.stops.limit.exceeded=Số điểm nhận hoặc điểm giao vượt quá giới hạn
-flash.delivery.multi.dropoff.disabled=Tạm thời chưa hỗ trợ nhiều điểm giao
 flash.delivery.stops.order.invalid=Điểm giao phải đứng sau tất cả các điểm nhận
 
 # 034 Stall qualification: common material rules and private images

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

@@ -183,6 +183,8 @@ no.points.use.fail=Points deduction not activated
 no.points.not.exist=Your points wallet does not exist
 no.message.push.delivery.personnel.qspsz.order=Rider delivering
 no.message.push.delivery.personnel.qsysd.order=Rider has delivered
+no.message.push.flash.pickup.stop.completed=Pickup completed at stop {0}. Pickup progress: {1}/{2}
+no.message.push.flash.delivery.stop.completed=Delivery completed at stop {0}. Delivery progress: {1}/{2}
 no.wallet.noexist.userinfo=User information does not exist
 no.user.state.no.audit=Account status is not audited
 no.system.busy.try.again=System is busy, please try again later
@@ -397,7 +399,6 @@ rider.accept.total.limit.reached=The total active delivery task limit has been r
 flash.delivery.pickup.input.conflict=Do not combine pickup formats or legacy item fields.
 flash.delivery.pickups.required=Submit every pickup with its item details.
 flash.delivery.pickup.limit.exceeded=Pickup count exceeds the configured limit.
-flash.delivery.multi.pickup.disabled=Multiple pickups are not enabled.
 flash.delivery.stops.invalid=Order station data is incomplete.
 flash.delivery.stop.invalid=This station is not a valid target for this order.
 flash.delivery.target.request.conflict=This request ID was already used with different target details.
@@ -426,7 +427,6 @@ flash.delivery.stops.required=Submit at least one pickup stop and one delivery s
 flash.delivery.stop.type.invalid=Invalid stop type
 flash.delivery.stop.remark.too.long=Stop remark cannot exceed 200 characters
 flash.delivery.stops.limit.exceeded=Too many pickup or delivery stops
-flash.delivery.multi.dropoff.disabled=Multiple delivery stops are not available yet
 flash.delivery.stops.order.invalid=Delivery stops must be placed after all pickup stops
 
 # 034 Stall qualification: common material rules and private images

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

@@ -182,6 +182,8 @@ no.points.use.fail=ยังไม่ได้เปิดใช้ส่วน
 no.points.not.exist=ไม่มีกระเป๋าคะแนนของคุณ
 no.message.push.delivery.personnel.qspsz.order=ไรเดอร์กำลังจัดส่ง
 no.message.push.delivery.personnel.qsysd.order=ไรเดอร์จัดส่งถึงแล้ว
+no.message.push.flash.pickup.stop.completed=รับพัสดุที่จุด {0} แล้ว ความคืบหน้าการรับ: {1}/{2}
+no.message.push.flash.delivery.stop.completed=ส่งพัสดุถึงจุด {0} แล้ว ความคืบหน้าการส่ง: {1}/{2}
 no.wallet.noexist.userinfo=ไม่มีข้อมูลผู้ใช้
 no.user.state.no.audit=สถานะบัญชียังไม่ได้รับการตรวจสอบ
 no.system.busy.try.again=ระบบไม่ว่าง โปรดลองอีกครั้งภายหลัง
@@ -398,7 +400,6 @@ rider.accept.total.limit.reached=จำนวนงานจัดส่งท
 flash.delivery.pickup.input.conflict=ห้ามส่งรูปแบบจุดรับหรือข้อมูลสิ่งของแบบเดิมพร้อมกัน
 flash.delivery.pickups.required=กรุณาส่งจุดรับทั้งหมดพร้อมข้อมูลสิ่งของแต่ละจุด
 flash.delivery.pickup.limit.exceeded=จำนวนจุดรับเกินขีดจำกัดที่กำหนด
-flash.delivery.multi.pickup.disabled=ยังไม่เปิดใช้การรับสินค้าหลายจุด
 flash.delivery.stops.invalid=ข้อมูลจุดแวะของคำสั่งซื้อไม่ครบถ้วน
 flash.delivery.stop.invalid=จุดนี้ไม่อยู่ในคำสั่งซื้อหรือใช้กับการดำเนินการนี้ไม่ได้
 flash.delivery.target.request.conflict=รหัสคำขอนี้ถูกใช้กับรายละเอียดการเปลี่ยนเป้าหมายอื่นแล้ว
@@ -427,7 +428,6 @@ flash.delivery.stops.required=โปรดระบุจุดรับสิ
 flash.delivery.stop.type.invalid=ประเภทจุดแวะไม่ถูกต้อง
 flash.delivery.stop.remark.too.long=หมายเหตุของจุดแวะต้องไม่เกิน 200 ตัวอักษร
 flash.delivery.stops.limit.exceeded=จำนวนจุดรับหรือจุดส่งเกินขีดจำกัด
-flash.delivery.multi.dropoff.disabled=ยังไม่รองรับหลายจุดส่ง
 flash.delivery.stops.order.invalid=จุดส่งต้องอยู่หลังจุดรับทั้งหมด
 
 # 034 Stall qualification: common material rules and public images

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

@@ -183,6 +183,8 @@ no.points.use.fail=积分抵扣未开启
 no.points.not.exist=您的积分钱包不存在
 no.message.push.delivery.personnel.qspsz.order=骑手配送中
 no.message.push.delivery.personnel.qsysd.order=骑手已送达
+no.message.push.flash.pickup.stop.completed=第 {0} 站已完成取货,取货进度 {1}/{2}
+no.message.push.flash.delivery.stop.completed=第 {0} 站已送达,送达进度 {1}/{2}
 no.wallet.noexist.userinfo=用户信息不存在
 no.user.state.no.audit=账号状态未审核
 no.system.busy.try.again=系统繁忙,请稍后重试
@@ -399,7 +401,6 @@ rider.accept.total.limit.reached=进行中的配送任务总数已达到接单
 flash.delivery.pickup.input.conflict=取货点格式或旧物品字段不可同时提交。
 flash.delivery.pickups.required=请提交全部取货点及各站物品信息。
 flash.delivery.pickup.limit.exceeded=取货点数量超过有效上限。
-flash.delivery.multi.pickup.disabled=暂未开放多个取货点。
 flash.delivery.stops.invalid=订单站点资料不完整。
 flash.delivery.stop.invalid=该站点不属于本订单或不适用于此操作。
 flash.delivery.target.request.conflict=该请求编号已用于不同的目标调整。
@@ -428,7 +429,6 @@ flash.delivery.stops.required=请提交至少一个取货点和一个收货点
 flash.delivery.stop.type.invalid=站点类型不正确
 flash.delivery.stop.remark.too.long=站点备注不能超过200字
 flash.delivery.stops.limit.exceeded=取货点或收货点数量超出限制
-flash.delivery.multi.dropoff.disabled=暂不支持多个收货点
 flash.delivery.stops.order.invalid=收货点必须排在全部取货点之后
 
 # 034 Stall qualification: common material rules and private images

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

@@ -183,6 +183,8 @@ no.points.use.fail=積分抵扣未開啟
 no.points.not.exist=您的積分錢包不存在
 no.message.push.delivery.personnel.qspsz.order=騎手配送中
 no.message.push.delivery.personnel.qsysd.order=騎手已送達
+no.message.push.flash.pickup.stop.completed=第 {0} 站已完成取貨,取貨進度 {1}/{2}
+no.message.push.flash.delivery.stop.completed=第 {0} 站已送達,送達進度 {1}/{2}
 no.wallet.noexist.userinfo=用戶資訊不存在
 no.user.state.no.audit=帳號狀態未審核
 no.system.busy.try.again=系統繁忙,請稍後重試
@@ -398,7 +400,6 @@ rider.accept.total.limit.reached=進行中的配送任務總數已達接單上
 flash.delivery.pickup.input.conflict=取貨點格式或舊物品欄位不可同時提交。
 flash.delivery.pickups.required=請提交全部取貨點及各站物品資訊。
 flash.delivery.pickup.limit.exceeded=取貨點數量超過有效上限。
-flash.delivery.multi.pickup.disabled=暫未開放多個取貨點。
 flash.delivery.stops.invalid=訂單站點資料不完整。
 flash.delivery.stop.invalid=該站點不屬於本訂單或不適用於此操作。
 flash.delivery.target.request.conflict=該請求編號已用於不同的目標調整。
@@ -427,7 +428,6 @@ flash.delivery.stops.required=請提交至少一個取貨點和一個收貨點
 flash.delivery.stop.type.invalid=站點類型不正確
 flash.delivery.stop.remark.too.long=站點備註不能超過200字
 flash.delivery.stops.limit.exceeded=取貨點或收貨點數量超出限制
-flash.delivery.multi.dropoff.disabled=暫不支援多個收貨點
 flash.delivery.stops.order.invalid=收貨點必須排在全部取貨點之後
 
 # 034 Stall qualification: common material rules and private images

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

@@ -4,6 +4,7 @@ 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.dto.FlashDeliveryStopDeliverRequest;
 import com.ruoyi.app.flashdelivery.dto.FlashDeliveryQuoteView;
 import com.ruoyi.app.flashdelivery.exception.FlashDeliveryQuoteChangedException;
 import com.ruoyi.app.flashdelivery.service.FlashDeliveryApplicationService;
@@ -38,6 +39,66 @@ import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
 
 class FlashDeliveryControllerContractTest {
+    @Test
+    void newStopDeliveryAcceptsAllBusinessParametersInBody() throws Exception {
+        FlashDeliveryApplicationService service = mock(FlashDeliveryApplicationService.class);
+        var mvc = org.springframework.test.web.servlet.setup.MockMvcBuilders
+                .standaloneSetup(new FlashDeliveryRiderController(service)).build();
+        mvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders
+                        .post("/system/flashDelivery/rider/orders/stops/deliver")
+                        .header("token", JwtUtil.token("8", "rider"))
+                        .contentType(org.springframework.http.MediaType.APPLICATION_JSON)
+                        .content("{\"orderId\":10,\"stopId\":13,\"pinCode\":\"1111\",\"imageUrls\":[\"/profile/d.jpg\"]}"))
+                .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.status().isOk());
+        var request = org.mockito.ArgumentCaptor.forClass(FlashDeliveryDeliverRequest.class);
+        verify(service).deliverAtStop(org.mockito.ArgumentMatchers.eq(8L), org.mockito.ArgumentMatchers.eq(10L),
+                org.mockito.ArgumentMatchers.eq(13L), request.capture());
+        org.junit.jupiter.api.Assertions.assertEquals("1111", request.getValue().getPinCode());
+        org.junit.jupiter.api.Assertions.assertEquals(java.util.List.of("/profile/d.jpg"), request.getValue().getImageUrls());
+        org.junit.jupiter.api.Assertions.assertEquals(13L, request.getValue().getStopId());
+    }
+
+    @Test
+    void newStopDeliveryRejectsMissingOrNonPositiveBodyIds() throws Exception {
+        FlashDeliveryApplicationService service = mock(FlashDeliveryApplicationService.class);
+        var mvc = org.springframework.test.web.servlet.setup.MockMvcBuilders
+                .standaloneSetup(new FlashDeliveryRiderController(service))
+                .setControllerAdvice(new com.ruoyi.framework.web.exception.GlobalExceptionHandler()).build();
+        for (String body : java.util.List.of("{}", "{\"orderId\":0,\"stopId\":13}",
+                "{\"orderId\":-1,\"stopId\":13}", "{\"orderId\":10}",
+                "{\"orderId\":10,\"stopId\":0}", "{\"orderId\":10,\"stopId\":-1}")) {
+            mvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders
+                            .post("/system/flashDelivery/rider/orders/stops/deliver")
+                            .header("token", JwtUtil.token("8", "rider"))
+                            .contentType(org.springframework.http.MediaType.APPLICATION_JSON).content(body))
+                    .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.status().isOk())
+                    .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath("$.code").value(500));
+        }
+        org.mockito.Mockito.verifyNoInteractions(service);
+    }
+
+    @Test
+    void newV2EndpointsUseBodyDtosAndHeaderAuthentication() throws Exception {
+        for (Method method : java.util.List.of(
+                FlashDeliveryUserController.class.getMethod("quoteV2", String.class,
+                        com.ruoyi.app.flashdelivery.dto.FlashDeliveryV2QuoteRequest.class),
+                FlashDeliveryUserController.class.getMethod("createV2", String.class,
+                        com.ruoyi.app.flashdelivery.dto.FlashDeliveryV2CreateRequest.class),
+                FlashDeliveryUserController.class.getMethod("quoteAddressV2", String.class,
+                        com.ruoyi.app.flashdelivery.dto.FlashDeliveryV2AddressChangeRequest.class),
+                FlashDeliveryUserController.class.getMethod("updateAddressV2", String.class,
+                        com.ruoyi.app.flashdelivery.dto.FlashDeliveryV2AddressConfirmRequest.class),
+                FlashDeliveryRiderController.class.getMethod("deliverAtStop", String.class,
+                        FlashDeliveryStopDeliverRequest.class))) {
+            assertNotNull(method.getAnnotation(Auth.class));
+            assertNotNull(method.getParameters()[0].getAnnotation(RequestHeader.class));
+            assertNotNull(method.getParameters()[1].getAnnotation(RequestBody.class));
+            org.junit.jupiter.api.Assertions.assertEquals(2, method.getParameterCount());
+            assertTrue(java.util.Arrays.stream(method.getAnnotation(org.springframework.web.bind.annotation.PostMapping.class)
+                    .value()).noneMatch(path -> path.contains("{")));
+        }
+    }
+
     @Test
     void perStopFulfilmentUsesTokenExplicitPathsAndBodyDtos() throws Exception {
         for (Method method : java.util.List.of(

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

@@ -15,7 +15,7 @@ class FlashDeliveryRiderOrderListViewTest {
                 "quantity", "weightRange", "baseDeliveryFee", "urgentFee", "tipAmount",
                 "deliveryMode", "scheduledPickupStartAt", "scheduledPickupEndAt", "pickupAddress",
                 "pickupDetailAddress", "deliveryAddress", "deliveryDetailAddress",
-                "estimatedDurationSeconds", "amount", "currency", "paymentStatus", "deliveredAt", "createTime", "orderVersion", "pickupStopCount", "pickedUpStopCount", "canUserCancel");
+                "estimatedDurationSeconds", "amount", "currency", "paymentStatus", "deliveredAt", "createTime", "orderVersion", "pickupStopCount", "pickedUpStopCount", "deliveryStopCount", "deliveredStopCount", "canUserCancel");
         org.junit.jupiter.api.Assertions.assertEquals(expected, fields);
     }
 
@@ -29,7 +29,7 @@ class FlashDeliveryRiderOrderListViewTest {
                 "pinRequired", "pickupAddress", "pickupDetailAddress", "deliveryAddress",
                 "deliveryDetailAddress", "pickupDistanceMeters", "distanceMeters",
                 "estimatedDurationSeconds", "baseDeliveryFee", "distanceFee", "urgentFee",
-                "tipAmount", "amount", "currency", "paymentStatus", "createTime", "payType", "pickupStopCount", "pickedUpStopCount", "canUserCancel", "userNote");
+                "tipAmount", "amount", "currency", "paymentStatus", "createTime", "payType", "pickupStopCount", "pickedUpStopCount", "deliveryStopCount", "deliveredStopCount", "canUserCancel", "userNote");
         org.junit.jupiter.api.Assertions.assertEquals(expected, fields);
     }
 

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

@@ -2274,7 +2274,9 @@ class FlashDeliveryApplicationServiceTest {
                 pickup.setStatus(current != null && List.of("PICKED_UP", "DELIVERED", "COMPLETED").contains(current.getStatus())
                         ? "PICKED_UP" : "PENDING");
                 var delivery = new com.ruoyi.system.domain.flash.FlashDeliveryOrderStop();
-                delivery.setId(102L); delivery.setOrderId(10L); delivery.setStopType("DELIVERY"); delivery.setStopOrder(1); delivery.setStatus("PENDING");
+                delivery.setId(102L); delivery.setOrderId(10L); delivery.setStopType("DELIVERY"); delivery.setStopOrder(1);
+                delivery.setStatus(current != null && List.of("DELIVERED", "COMPLETED").contains(current.getStatus())
+                        ? "DELIVERED" : "PENDING");
                 return List.of(pickup, delivery);
             });
             when(lockService.withLock(any(), any())).thenAnswer(invocation -> {

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

@@ -211,15 +211,15 @@ class FlashDeliveryMultiPickupTest {
                 .getAnnotation(org.springframework.transaction.annotation.Transactional.class));
     }
 
-    @Test void disablingMultiPickupRejectsNewQuotesButDoesNotBlockExistingFulfilment() {
+    @Test void configuredLimitAllowsMultiPickupWithoutSwitchAndExistingFulfilment() {
         Fixture f = new Fixture();
         try (MockedStatic<DictUtils> dict = mockStatic(DictUtils.class)) {
             dict.when(() -> DictUtils.getDictCache("flash_pickup_stop_limit")).thenReturn(List.of(dictionary("10")));
-            dict.when(() -> DictUtils.getDictCache("flash_show_multi_pickup")).thenReturn(List.of(dictionary("0")));
-            assertThrows(ServiceException.class, () -> f.service.quote(quoteRequest(2)));
+            when(f.routes.calculate(anyList(), eq(1))).thenReturn(new RouteDistance(4000, 600, "ROUTE"));
+            assertDoesNotThrow(() -> f.service.quote(quoteRequest(2)));
             f.service.pickup(8L, 10L, proof(11L));
             assertEquals(1, f.order.getPickedUpStopCount());
-            verify(f.routes, never()).calculate(anyList(), eq(1));
+            verify(f.routes).calculate(anyList(), eq(1));
         }
     }
 
@@ -238,7 +238,6 @@ class FlashDeliveryMultiPickupTest {
         Fixture f = new Fixture();
         try (MockedStatic<DictUtils> dict = mockStatic(DictUtils.class)) {
             dict.when(() -> DictUtils.getDictCache("flash_pickup_stop_limit")).thenReturn(List.of(dictionary("10")));
-            dict.when(() -> DictUtils.getDictCache("flash_show_multi_pickup")).thenReturn(List.of(dictionary("1")));
             FlashDeliveryQuoteRequest request = quoteRequest(2);
             when(f.routes.calculate(anyList(), eq(1))).thenReturn(new RouteDistance(4000, 600, "ROUTE"));
             assertEquals(4000, f.service.quote(request).getDistanceMeters());
@@ -248,9 +247,12 @@ class FlashDeliveryMultiPickupTest {
                 assertThrows(ServiceException.class, () -> f.service.quote(quoteRequest(size)));
             }
             assertEquals(10, f.service.home().getPickupStopLimit());
+            assertEquals(Boolean.TRUE, f.service.home().getShowMultiPickup());
+            assertEquals(Boolean.TRUE, f.service.home().getShowUrgentOption());
             for (String invalid : List.of("", "0", "27", "-1", "1.5", "abc")) {
                 dict.when(() -> DictUtils.getDictCache("flash_pickup_stop_limit")).thenReturn(List.of(dictionary(invalid)));
                 assertEquals(1, f.service.home().getPickupStopLimit());
+                assertEquals(Boolean.FALSE, f.service.home().getShowMultiPickup());
                 assertThrows(ServiceException.class, () -> f.service.quote(request));
             }
             verify(f.routes, times(1)).calculate(anyList(), eq(1));
@@ -261,7 +263,6 @@ class FlashDeliveryMultiPickupTest {
         Fixture f = new Fixture();
         try (MockedStatic<DictUtils> dict = mockStatic(DictUtils.class)) {
             dict.when(() -> DictUtils.getDictCache("flash_pickup_stop_limit")).thenReturn(List.of(dictionary("10")));
-            dict.when(() -> DictUtils.getDictCache("flash_show_multi_pickup")).thenReturn(List.of(dictionary("1")));
             FlashDeliveryQuoteRequest request = quoteRequest(2); request.setPackageType("DOCUMENT");
             assertThrows(ServiceException.class, () -> f.service.quote(request));
             request.setPackageType(null); request.getPickups().get(1).setQuantity(0);
@@ -364,7 +365,6 @@ class FlashDeliveryMultiPickupTest {
     private static MockedStatic<DictUtils> multiEnabled() {
         MockedStatic<DictUtils> dict = mockStatic(DictUtils.class);
         dict.when(() -> DictUtils.getDictCache("flash_pickup_stop_limit")).thenReturn(List.of(dictionary("10")));
-        dict.when(() -> DictUtils.getDictCache("flash_show_multi_pickup")).thenReturn(List.of(dictionary("1")));
         return dict;
     }
 

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

@@ -1,5 +1,20 @@
 package com.ruoyi.app.flashdelivery.service;
 
+import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.JSONObject;
+import com.ruoyi.app.push.ApnsPushSender;
+import com.ruoyi.app.push.BusinessPushService;
+import com.ruoyi.app.push.FcmPushSender;
+import com.ruoyi.app.utils.PayPush;
+import com.ruoyi.app.utils.event.PushEvent;
+import com.ruoyi.app.utils.event.PushEventListener;
+import com.ruoyi.common.utils.LocaleUtils;
+import com.ruoyi.common.utils.spring.SpringUtils;
+import com.ruoyi.framework.manager.AsyncManager;
+import com.ruoyi.system.domain.PushMessage;
+import com.ruoyi.system.domain.flash.FlashDeliveryOrderStop;
+import com.ruoyi.system.service.IInfoUserService;
+import com.ruoyi.system.service.IPushMessageService;
 import com.ruoyi.app.utils.event.PushEventService;
 import com.ruoyi.app.order.dto.OrderPushBodyDto;
 import com.ruoyi.app.push.BusinessPushProperties;
@@ -14,9 +29,20 @@ import com.ruoyi.system.mapper.RiderPositionMapper;
 import com.ruoyi.common.utils.DictUtils;
 import org.junit.jupiter.api.Test;
 import org.mockito.MockedStatic;
+import org.springframework.context.ApplicationEventPublisher;
+import org.springframework.context.MessageSource;
+import org.springframework.context.support.ResourceBundleMessageSource;
+import org.springframework.test.util.ReflectionTestUtils;
+import org.springframework.transaction.support.TransactionSynchronization;
+import org.springframework.transaction.support.TransactionSynchronizationManager;
 
 import java.math.BigDecimal;
 import java.util.List;
+import java.util.ArrayList;
+import java.util.Locale;
+import java.util.Set;
+import java.util.TimerTask;
+import java.util.concurrent.ScheduledExecutorService;
 
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.anyString;
@@ -29,6 +55,13 @@ import static org.mockito.Mockito.spy;
 import static org.mockito.Mockito.timeout;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.mockStatic;
 
 class FlashDeliveryNotificationServiceTest {
 
@@ -251,6 +284,265 @@ class FlashDeliveryNotificationServiceTest {
         verify(service, never()).sendToUser(any(), any(), anyString(), anyString(), any());
     }
 
+    @Test
+    void pickupStopProgressIsQueuedOnlyAfterCommitAndSentOnlyToOrderSender() {
+        try (StopNotificationFixture fixture = new StopNotificationFixture()) {
+            FlashDeliveryOrder order = order();
+            order.setStatus("ACCEPTED");
+            order.setReceiverUserId(88L);
+            order.setDeliveryPinCode("order-secret-pin");
+            FlashDeliveryOrderStop stop = stop("PICKUP", 2);
+            TransactionSynchronizationManager.initSynchronization();
+            try {
+                assertDoesNotThrow(() -> fixture.service.notifyPickupStop(order, stop, 1, 2));
+                assertTrue(fixture.tasks.isEmpty());
+                assertTrue(fixture.messages.isEmpty());
+                assertTrue(fixture.lookedUpUsers.isEmpty());
+
+                TransactionSynchronizationManager.getSynchronizations().forEach(TransactionSynchronization::afterCommit);
+                assertEquals(1, fixture.tasks.size());
+                assertTrue(fixture.messages.isEmpty());
+                assertEquals(List.of(7L), fixture.lookedUpUsers);
+                fixture.runTasks();
+            } finally {
+                TransactionSynchronizationManager.clearSynchronization();
+            }
+            assertEquals(1, fixture.messages.size());
+            PushMessage message = fixture.messages.get(0);
+            assertEquals(7L, message.getUserId());
+            assertEquals("FL-20260813-021", message.getDdId());
+            assertEquals(3, message.getPushType());
+            JSONObject body = JSON.parseObject(message.getBody());
+            assertEquals(Set.of("ddId", "state", "type", "pushType", "eventType", "stopId", "stopType",
+                    "stopOrder", "completedStopCount", "totalStopCount"), body.keySet());
+            assertEquals("FL-20260813-021", body.getString("ddId"));
+            assertEquals("ACCEPTED", body.getString("state"));
+            assertEquals(-1, body.getIntValue("type"));
+            assertEquals(3, body.getIntValue("pushType"));
+            assertEquals("FLASH_PICKUP_STOP_COMPLETED", body.getString("eventType"));
+            assertEquals(41L, body.getLong("stopId"));
+            assertEquals("PICKUP", body.getString("stopType"));
+            assertEquals(2, body.getIntValue("stopOrder"));
+            assertEquals(1, body.getIntValue("completedStopCount"));
+            assertEquals(2, body.getIntValue("totalStopCount"));
+            assertFalse(message.getBody().contains("secret"));
+            assertEquals(1, fixture.sentContents.size());
+            assertTrue(fixture.sentContents.get(0).contains("stop 3"));
+            assertTrue(fixture.sentContents.get(0).contains("1/2"));
+        }
+    }
+
+    @Test
+    void deliveryStopProgressKeepsRealPartialAndFinalOrderStatesWithoutWholeOrderScene() {
+        try (StopNotificationFixture fixture = new StopNotificationFixture()) {
+            FlashDeliveryOrder partial = order();
+            partial.setStatus("PICKED_UP");
+            assertDoesNotThrow(() -> fixture.service.notifyDeliveryStop(partial, stop("DELIVERY", 3), 1, 2));
+            FlashDeliveryOrder finished = order();
+            finished.setStatus("DELIVERED");
+            assertDoesNotThrow(() -> fixture.service.notifyDeliveryStop(finished, stop("DELIVERY", 4), 2, 2));
+            fixture.runTasks();
+
+            assertEquals(2, fixture.messages.size());
+            List<JSONObject> bodies = fixture.messages.stream().map(message -> JSON.parseObject(message.getBody())).toList();
+            assertEquals(List.of("PICKED_UP", "DELIVERED"), bodies.stream().map(body -> body.getString("state")).toList());
+            assertEquals(List.of(1, 2), bodies.stream().map(body -> body.getIntValue("completedStopCount")).toList());
+            for (JSONObject body : bodies) {
+                assertEquals("FLASH_DELIVERY_STOP_COMPLETED", body.getString("eventType"));
+                assertEquals("DELIVERY", body.getString("stopType"));
+                assertEquals(2, body.getIntValue("totalStopCount"));
+                assertFalse(body.containsKey("eventScene"));
+            }
+            assertEquals(List.of(7L, 7L), fixture.messages.stream().map(PushMessage::getUserId).toList());
+        }
+    }
+
+    @Test
+    void rollbackDoesNotQueueOrPersistEitherStopProgressNotification() {
+        try (StopNotificationFixture fixture = new StopNotificationFixture()) {
+            TransactionSynchronizationManager.initSynchronization();
+            try {
+                assertDoesNotThrow(() -> fixture.service.notifyPickupStop(order(), stop("PICKUP", 0), 1, 2));
+                assertDoesNotThrow(() -> fixture.service.notifyDeliveryStop(order(), stop("DELIVERY", 2), 1, 2));
+                TransactionSynchronizationManager.getSynchronizations()
+                        .forEach(sync -> sync.afterCompletion(TransactionSynchronization.STATUS_ROLLED_BACK));
+                fixture.runTasks();
+                assertTrue(fixture.lookedUpUsers.isEmpty());
+                assertTrue(fixture.tasks.isEmpty());
+                assertTrue(fixture.messages.isEmpty());
+                assertTrue(fixture.sentContents.isEmpty());
+            } finally {
+                TransactionSynchronizationManager.clearSynchronization();
+            }
+        }
+    }
+
+    @Test
+    void missingDeviceAddressStillPersistsStopProgressThroughRealPayPushEventPath() {
+        try (StopNotificationFixture fixture = new StopNotificationFixture()) {
+            fixture.sender.setCid(null);
+            for (BusinessPushProperties.Mode mode : BusinessPushProperties.Mode.values()) {
+                fixture.properties.setMode(mode);
+                assertDoesNotThrow(() -> fixture.service.notifyPickupStop(order(), stop("PICKUP", 0), 1, 2));
+                assertDoesNotThrow(() -> fixture.service.notifyDeliveryStop(order(), stop("DELIVERY", 2), 1, 2));
+                fixture.runTasks();
+            }
+
+            assertEquals(4, fixture.messages.size());
+            assertTrue(fixture.messages.stream().allMatch(message -> Long.valueOf(7L).equals(message.getUserId())
+                    && Integer.valueOf(3).equals(message.getPushType())
+                    && "FL-20260813-021".equals(message.getDdId())));
+            assertTrue(fixture.sentContents.isEmpty());
+        }
+    }
+
+    @Test
+    void stopProgressTextUsesSenderLocaleAndRouteNumberWithCompletedAndTotalCounts() {
+        List<Locale> locales = List.of(Locale.ROOT, Locale.TAIWAN, Locale.SIMPLIFIED_CHINESE,
+                Locale.US, Locale.forLanguageTag("th-TH"));
+        List<String> pickupTexts = List.of("第 3 站已完成取貨,取貨進度 1/2", "第 3 站已完成取貨,取貨進度 1/2",
+                "第 3 站已完成取货,取货进度 1/2", "Pickup completed at stop 3. Pickup progress: 1/2",
+                "รับพัสดุที่จุด 3 แล้ว ความคืบหน้าการรับ: 1/2");
+        List<String> deliveryTexts = List.of("第 5 站已送達,送達進度 2/2", "第 5 站已送達,送達進度 2/2",
+                "第 5 站已送达,送达进度 2/2", "Delivery completed at stop 5. Delivery progress: 2/2",
+                "ส่งพัสดุถึงจุด 5 แล้ว ความคืบหน้าการส่ง: 2/2");
+        for (int i = 0; i < locales.size(); i++) {
+            try (StopNotificationFixture fixture = new StopNotificationFixture()) {
+                fixture.locale.when(() -> LocaleUtils.getUserLocale(7L)).thenReturn(locales.get(i));
+                assertDoesNotThrow(() -> fixture.service.notifyPickupStop(order(), stop("PICKUP", 2), 1, 2));
+                assertDoesNotThrow(() -> fixture.service.notifyDeliveryStop(order(), stop("DELIVERY", 4), 2, 2));
+                fixture.runTasks();
+                assertEquals(2, fixture.messages.size());
+                assertTrue(fixture.messages.get(0).getContent().contains(pickupTexts.get(i)));
+                assertTrue(fixture.messages.get(1).getContent().contains(deliveryTexts.get(i)));
+                assertTrue(fixture.messages.stream().allMatch(message -> !message.getContent().contains("{0}")
+                        && message.getContent().contains("FL-20260813-021")));
+            }
+        }
+    }
+
+    @Test
+    void stopNotificationLookupAndSchedulingFailuresDoNotEscapeBusinessOperation() {
+        try (StopNotificationFixture fixture = new StopNotificationFixture()) {
+            when(fixture.users.selectById(7L)).thenThrow(new IllegalStateException("lookup failed"));
+            assertDoesNotThrow(() -> fixture.service.notifyPickupStop(order(), stop("PICKUP", 0), 1, 2));
+            assertTrue(fixture.tasks.isEmpty());
+            org.mockito.Mockito.doReturn(fixture.sender).when(fixture.users).selectById(7L);
+            doThrow(new IllegalStateException("executor unavailable")).when(fixture.manager).execute(any(TimerTask.class));
+            assertDoesNotThrow(() -> fixture.service.notifyDeliveryStop(order(), stop("DELIVERY", 2), 1, 2));
+            assertTrue(fixture.messages.isEmpty());
+        }
+    }
+
+    @Test
+    void stopNotificationPersistenceFailureIsContainedAndNextProgressStillSends() {
+        try (StopNotificationFixture fixture = new StopNotificationFixture()) {
+            fixture.persistenceFailure = true;
+            assertDoesNotThrow(() -> fixture.service.notifyPickupStop(order(), stop("PICKUP", 0), 1, 2));
+            assertDoesNotThrow(fixture::runTasks);
+            assertTrue(fixture.messages.isEmpty());
+            fixture.persistenceFailure = false;
+            assertDoesNotThrow(() -> fixture.service.notifyDeliveryStop(order(), stop("DELIVERY", 2), 1, 2));
+            assertDoesNotThrow(fixture::runTasks);
+            assertEquals(1, fixture.messages.size());
+            assertEquals("FLASH_DELIVERY_STOP_COMPLETED", JSON.parseObject(fixture.messages.get(0).getBody()).getString("eventType"));
+        }
+    }
+
+    @Test
+    void externalStopPushFailureDoesNotLosePersistedProgressOrEscapeBusinessOperation() {
+        try (StopNotificationFixture fixture = new StopNotificationFixture()) {
+            doThrow(new IllegalStateException("network failure")).when(fixture.uni)
+                    .apppush(anyString(), anyString(), anyString(), anyString());
+            assertDoesNotThrow(() -> fixture.service.notifyDeliveryStop(order(), stop("DELIVERY", 2), 1, 2));
+            assertDoesNotThrow(fixture::runTasks);
+            assertEquals(1, fixture.messages.size());
+            assertEquals(7L, fixture.messages.get(0).getUserId());
+        }
+    }
+
+    private static FlashDeliveryOrderStop stop(String type, int routeOrder) {
+        FlashDeliveryOrderStop stop = new FlashDeliveryOrderStop();
+        stop.setId(41L);
+        stop.setOrderId(10L);
+        stop.setStopType(type);
+        stop.setStopOrder(routeOrder);
+        stop.setReceiverUserId(99L);
+        stop.setName("secret-contact");
+        stop.setPhone("secret-phone");
+        stop.setAddress("secret-address");
+        stop.setDeliveryPinCode("secret-pin");
+        return stop;
+    }
+
+    private static final class StopNotificationFixture implements AutoCloseable {
+        final InfoUserMapper users = mock(InfoUserMapper.class);
+        final InfoUser sender = new InfoUser();
+        final PayPush uni = mock(PayPush.class);
+        final BusinessPushProperties properties = new BusinessPushProperties();
+        final List<Long> lookedUpUsers = new ArrayList<>();
+        final List<TimerTask> tasks = new ArrayList<>();
+        final List<PushMessage> messages = new ArrayList<>();
+        final List<String> sentContents = new ArrayList<>();
+        final MockedStatic<SpringUtils> spring;
+        final MockedStatic<AsyncManager> async;
+        final MockedStatic<LocaleUtils> locale;
+        final AsyncManager manager;
+        final FlashDeliveryNotificationService service;
+        boolean persistenceFailure;
+
+        StopNotificationFixture() {
+            sender.setUserId(7L);
+            sender.setCid("sender-cid");
+            when(users.selectById(7L)).thenAnswer(call -> { lookedUpUsers.add(7L); return sender; });
+            IInfoUserService currentUsers = mock(IInfoUserService.class);
+            when(currentUsers.getById(7L)).thenReturn(sender);
+            BusinessPushService router = new BusinessPushService(properties, currentUsers,
+                    mock(FcmPushSender.class), mock(ApnsPushSender.class), uni);
+            IPushMessageService repository = mock(IPushMessageService.class);
+            doAnswer(call -> {
+                if (persistenceFailure) throw new IllegalStateException("message storage unavailable");
+                messages.add(call.getArgument(0));
+                return 1;
+            }).when(repository).insertPushMessage(any(PushMessage.class));
+            PushEventListener listener = new PushEventListener();
+            ReflectionTestUtils.setField(listener, "pushMessageService", repository);
+            PushEventService events = new PushEventService();
+            ApplicationEventPublisher publisher = event -> listener.onApplicationEvent((PushEvent) event);
+            ReflectionTestUtils.setField(events, "eventPublisher", publisher);
+            ResourceBundleMessageSource messageSource = new ResourceBundleMessageSource();
+            messageSource.setBasename("i18n/messages");
+            messageSource.setDefaultEncoding("UTF-8");
+            messageSource.setFallbackToSystemLocale(false);
+            spring = mockStatic(SpringUtils.class);
+            spring.when(() -> SpringUtils.getBean("scheduledExecutorService")).thenReturn(mock(ScheduledExecutorService.class));
+            spring.when(() -> SpringUtils.getBean(MessageSource.class)).thenReturn(messageSource);
+            spring.when(() -> SpringUtils.getBean(BusinessPushService.class)).thenReturn(router);
+            manager = mock(AsyncManager.class);
+            doAnswer(call -> { tasks.add(call.getArgument(0)); return null; }).when(manager).execute(any(TimerTask.class));
+            async = mockStatic(AsyncManager.class);
+            async.when(AsyncManager::me).thenReturn(manager);
+            locale = mockStatic(LocaleUtils.class);
+            locale.when(() -> LocaleUtils.getUserLocale(7L)).thenReturn(Locale.US);
+            doAnswer(call -> { sentContents.add(call.getArgument(2)); return null; }).when(uni)
+                    .apppush(eq("sender-cid"), anyString(), anyString(), anyString());
+            service = new FlashDeliveryNotificationService(users, events);
+        }
+
+        void runTasks() {
+            List<TimerTask> pending = new ArrayList<>(tasks);
+            tasks.clear();
+            pending.forEach(TimerTask::run);
+        }
+
+        @Override
+        public void close() {
+            locale.close();
+            async.close();
+            spring.close();
+        }
+    }
+
     private static FlashDeliveryOrder order() {
         FlashDeliveryOrder order = new FlashDeliveryOrder();
         order.setId(10L);

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

@@ -73,7 +73,7 @@ class FlashDeliveryRiderBillingTest {
         pickup.setStopType("PICKUP"); pickup.setStatus("PICKED_UP");
         var delivery = new FlashDeliveryOrderStop();
         delivery.setId(12L); delivery.setOrderId(10L); delivery.setStopOrder(1);
-        delivery.setStopType("DELIVERY"); delivery.setStatus("PENDING");
+        delivery.setStopType("DELIVERY"); delivery.setStatus("DELIVERED");
         when(stops.selectList(any(Wrapper.class))).thenReturn(List.of(pickup, delivery));
         when(orders.transitionByParticipant(eq(10L), anyLong(), eq("DELIVERED"), eq("COMPLETED"), any()))
                 .thenReturn(1, 0);

+ 208 - 7
ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryV2Test.java

@@ -40,9 +40,7 @@ class FlashDeliveryV2Test {
 
     private static void multiStopDicts(MockedStatic<DictUtils> dict) {
         dict.when(() -> DictUtils.getDictCache("flash_pickup_stop_limit")).thenReturn(List.of(dictionary("3")));
-        dict.when(() -> DictUtils.getDictCache("flash_show_multi_pickup")).thenReturn(List.of(dictionary("1")));
         dict.when(() -> DictUtils.getDictCache("flash_delivery_stop_limit")).thenReturn(List.of(dictionary("3")));
-        dict.when(() -> DictUtils.getDictCache("flash_show_multi_dropoff")).thenReturn(List.of(dictionary("1")));
     }
 
     @Test void v2CreateSavesUnifiedStopsWithOrderLevelItemAndPerStopReceivers() {
@@ -76,7 +74,7 @@ class FlashDeliveryV2Test {
         }
     }
 
-    @Test void v2RejectsDeliveryBeforePickupLimitAndDisabledSwitch() {
+    @Test void v2RejectsDeliveryBeforePickupAndOverLimit() {
         Fixture f = new Fixture();
         try (MockedStatic<DictUtils> dict = mockStatic(DictUtils.class)) {
             multiStopDicts(dict);
@@ -89,8 +87,19 @@ class FlashDeliveryV2Test {
             assertThrows(ServiceException.class, () -> f.service.createV2(7L, tooMany));
             verify(f.orders, never()).insert(any(FlashDeliveryOrder.class));
 
-            dict.when(() -> DictUtils.getDictCache("flash_show_multi_dropoff")).thenReturn(List.of(dictionary("0")));
-            assertThrows(ServiceException.class, () -> f.service.quoteV2(v2Create()));
+        }
+    }
+
+    @Test void homeDerivesMultiStopVisibilityFromLimitsAndAlwaysShowsUrgent() {
+        Fixture f = new Fixture();
+        try (MockedStatic<DictUtils> dict = mockStatic(DictUtils.class)) {
+            multiStopDicts(dict);
+            FlashDeliveryHomeView home = f.service.home();
+            assertEquals(3, home.getPickupStopLimit());
+            assertEquals(3, home.getDeliveryStopLimit());
+            assertEquals(Boolean.TRUE, home.getShowMultiPickup());
+            assertEquals(Boolean.TRUE, home.getShowMultiDropoff());
+            assertEquals(Boolean.TRUE, home.getShowUrgentOption());
         }
     }
 
@@ -107,6 +116,19 @@ class FlashDeliveryV2Test {
         }
     }
 
+    @Test void v2PickupNotifiesSenderForEachCommittedStopWithoutDuplicateStageMessage() {
+        Fixture f = new Fixture();
+        FlashDeliveryProofRequest proof = new FlashDeliveryProofRequest();
+        proof.setImageUrls(List.of("/profile/pickup.jpg"));
+        f.service.pickupAtStop(8L, 10L, 11L, proof);
+        f.service.pickupAtStop(8L, 10L, 11L, proof);
+        f.service.pickupAtStop(8L, 10L, 12L, proof);
+        verify(f.notifications).notifyPickupStop(same(f.order), same(f.rows.get(0)), eq(1), eq(2));
+        verify(f.notifications).notifyPickupStop(same(f.order), same(f.rows.get(1)), eq(2), eq(2));
+        verify(f.notifications, times(2)).notifyPickupStop(any(), any(), anyInt(), anyInt());
+        verify(f.notifications, never()).notifyPickedUp(any());
+    }
+
     @Test void perStopDeliveryAdvancesOrderOnlyAfterLastStopAndBlocksCancel() {
         Fixture f = new Fixture();
         f.order.setStatus("PICKED_UP"); f.order.setPinRequired(false);
@@ -132,7 +154,8 @@ class FlashDeliveryV2Test {
         assertEquals("DELIVERED", f.order.getStatus());
         assertEquals(2, f.order.getDeliveredStopCount());
         assertEquals(f.rows.get(3).getDeliveredAt(), f.order.getDeliveredAt());
-        verify(f.notifications, times(1)).notifyDelivered(any());
+        verify(f.notifications, times(2)).notifyDeliveryStop(any(), any(), anyInt(), eq(2));
+        verify(f.notifications, never()).notifyDelivered(any());
     }
 
     @Test void perStopPinIsValidatedAgainstThatStopOnly() {
@@ -153,6 +176,182 @@ class FlashDeliveryV2Test {
         assertEquals("DELIVERED", f.rows.get(2).getStatus());
     }
 
+
+    @Test void v2PinsAreReturnedPerStopOnlyToSenderAndMatchingReceiver() throws Exception {
+        Fixture f = ready(); receivers(f);
+        f.order.setPinRequired(true); f.order.setDeliveryPinCode("0000");
+        f.rows.get(2).setDeliveryPinCode("1111"); f.rows.get(3).setDeliveryPinCode("2222");
+        var json = new com.fasterxml.jackson.databind.ObjectMapper();
+        var sender = json.valueToTree(f.service.userDetail(7L, 10L));
+        assertEquals("1111", sender.at("/stops/2/deliveryPinCode").asText());
+        assertEquals("2222", sender.at("/stops/3/deliveryPinCode").asText());
+        assertTrue(sender.path("deliveryPinCode").isMissingNode() || sender.path("deliveryPinCode").isNull());
+        var receiver = json.valueToTree(assertDoesNotThrow(() -> f.service.userDetail(32L, 10L)));
+        assertEquals("2222", receiver.at("/stops/3/deliveryPinCode").asText());
+        assertTrue(receiver.at("/stops/2/deliveryPinCode").isMissingNode() || receiver.at("/stops/2/deliveryPinCode").isNull());
+        assertNull(f.service.userDetail(32L, 10L).getStops().get(2).getPhone());
+        assertNull(f.service.userDetail(32L, 10L).getStops().get(2).getAddress());
+        assertEquals("0911111113", f.service.userDetail(32L, 10L).getDelivery().getPhone());
+        var rider = json.valueToTree(f.service.riderDetail(8L, 10L));
+        assertTrue(rider.at("/stops/2/deliveryPinCode").isMissingNode() || rider.at("/stops/2/deliveryPinCode").isNull());
+        assertThrows(ServiceException.class, () -> f.service.userDetail(99L, 10L));
+    }
+
+    @Test void receiverOrderQueryIncludesEveryBoundDeliveryStopWithBoundParameters() {
+        Fixture f = ready(); receivers(f);
+        when(f.orders.selectPage(any(), any())).thenReturn(new com.baomidou.mybatisplus.extension.plugins.pagination.Page<FlashDeliveryOrder>());
+        f.service.userOrders(32L, 1, 10, "receiver");
+        var query = org.mockito.ArgumentCaptor.forClass(com.baomidou.mybatisplus.core.conditions.query.QueryWrapper.class);
+        verify(f.orders).selectPage(any(), query.capture());
+        String sql = query.getValue().getSqlSegment();
+        assertTrue(sql.contains("flash_delivery_order_stop"));
+        assertTrue(sql.contains("receiver_user_id"));
+        assertTrue(query.getValue().getParamNameValuePairs().containsValue(32L));
+    }
+
+    @Test void secondReceiverOrderCardShowsOwnDeliveryAddress() {
+        Fixture f = ready(); receivers(f);
+        f.order.setDeliveryAddress("first receiver address");
+        f.order.setDeliveryAddressDetail("first receiver floor");
+        f.rows.get(3).setAddressDetail("second receiver floor");
+        var page = new com.baomidou.mybatisplus.extension.plugins.pagination.Page<FlashDeliveryOrder>(1, 10);
+        page.setRecords(List.of(f.order));
+        when(f.orders.selectPage(any(), any())).thenReturn(page);
+        FlashDeliveryUserOrderListView card = f.service.userOrders(32L, 1, 10, "receiver").getRecords().get(0);
+        assertEquals("address 3", card.getDeliveryAddress());
+        assertEquals("second receiver floor", card.getDeliveryDetailAddress());
+    }
+
+    @Test void legacyDeliveryRejectsV2OrdersIncludingSingleDropoffBeforeAnyWrite() {
+        for (int count : List.of(1, 2)) {
+            Fixture f = ready(); if (count == 1) f.rows.remove(3); f.order.setDeliveryStopCount(count);
+            when(f.orders.transitionByRider(any(), any(), any(), any(), any(), any())).thenReturn(1);
+            assertThrows(ServiceException.class, () -> f.service.deliver(8L, 10L, deliveryProof(13L)));
+            verify(f.orders, never()).transitionByRider(any(), any(), any(), any(), any(), any());
+            verify(f.images, never()).insert(any(FlashDeliveryOrderImage.class));
+            assertTrue(f.rows.stream().filter(row -> "DELIVERY".equals(row.getStopType())).allMatch(row -> "PENDING".equals(row.getStatus())));
+        }
+    }
+
+    @Test void legacyAddressQuoteRejectsV2BeforeCalculatingOrDeletingStops() {
+        Fixture f = addressFixture(); f.rows.remove(1); f.order.setPickupStopCount(1);
+        FlashDeliveryAddressChangeRequest request = new FlashDeliveryAddressChangeRequest(); request.setOrderVersion(0);
+        request.setPickup(address(f.rows.get(0))); request.setDelivery(address(f.rows.get(1)));
+        assertThrows(ServiceException.class, () -> f.service.quoteAddress(7L, 10L, request));
+        verify(f.routes, never()).calculate(any(), any(), any());
+        verify(f.stops, never()).delete(any(com.baomidou.mybatisplus.core.conditions.Wrapper.class));
+        assertEquals(2, f.rows.stream().filter(row -> "DELIVERY".equals(row.getStopType())).count());
+    }
+
+    @Test void finalStopDeliveryRetriesRemainIdempotentAfterDeliveredAndCompleted() {
+        Fixture f = ready();
+        f.service.deliverAtStop(8L, 10L, 13L, deliveryProof(13L));
+        f.service.deliverAtStop(8L, 10L, 14L, deliveryProof(14L));
+        assertDoesNotThrow(() -> f.service.deliverAtStop(8L, 10L, 14L, deliveryProof(14L)));
+        f.order.setStatus("COMPLETED");
+        assertDoesNotThrow(() -> f.service.deliverAtStop(8L, 10L, 13L, deliveryProof(13L)));
+        verify(f.images, times(2)).insert(any(FlashDeliveryOrderImage.class));
+        verify(f.orders, times(2)).updateDeliveryFulfilment(any());
+        assertThrows(ServiceException.class, () -> f.service.deliverAtStop(9L, 10L, 14L, deliveryProof(14L)));
+    }
+
+    @Test void deliveryCannotSkipTargetWithoutRecordedSenderConsent() {
+        Fixture f = ready();
+        assertThrows(ServiceException.class, () -> f.service.deliverAtStop(8L, 10L, 14L, deliveryProof(14L)));
+        verify(f.images, never()).insert(any(FlashDeliveryOrderImage.class));
+        f.service.deliverAtStop(8L, 10L, 13L, deliveryProof(13L));
+        assertEquals(14L, f.service.riderDetail(8L, 10L).getCurrentTargetStopId());
+    }
+
+    @Test void senderConsentAllowsDeliveryTargetChangeAndReturnsToRemainingSequence() {
+        Fixture f = ready();
+        when(f.adjustments.insert(any(FlashDeliveryTargetAdjustment.class))).thenReturn(1);
+        FlashDeliveryTargetRequest request = new FlashDeliveryTargetRequest();
+        request.setClientRequestId("delivery-target-1"); request.setOrderVersion(0); request.setReason("sender asked to deliver second first");
+        request.setConsentDeclared(false);
+        assertThrows(ServiceException.class, () -> f.service.selectTarget(8L, 10L, 14L, request));
+        request.setConsentDeclared(true);
+        assertEquals(14L, assertDoesNotThrow(() -> f.service.selectTarget(8L, 10L, 14L, request)).getCurrentTargetStopId());
+        f.service.deliverAtStop(8L, 10L, 14L, deliveryProof(14L));
+        assertEquals(13L, f.service.riderDetail(8L, 10L).getCurrentTargetStopId());
+        assertNull(f.order.getCurrentTargetStopId());
+        assertEquals("PICKED_UP", f.order.getStatus());
+    }
+
+    @Test void incompleteDeliveryCannotBeConfirmedOrCompletedEvenIfOrderStatusWasWrong() {
+        Fixture f = ready(); f.order.setStatus("DELIVERED"); f.rows.get(2).setStatus("DELIVERED");
+        when(f.orders.transitionByParticipant(any(), any(), any(), any(), any())).thenReturn(1);
+        when(f.orders.transitionByStatus(any(), any(), any(), any(), any())).thenReturn(1);
+        assertThrows(ServiceException.class, () -> f.service.confirmReceipt(7L, 10L));
+        assertFalse(f.service.complete(1L, "ADMIN", 10L));
+        assertFalse(f.service.complete(null, "SYSTEM", 10L));
+        verify(f.billing, never()).insert(any(com.ruoyi.system.domain.UserBilling.class));
+    }
+
+    @Test void secondReceiverCanConfirmFullyDeliveredOrderAndBillOnlyOnce() {
+        Fixture f = ready(); receivers(f); f.order.setStatus("DELIVERED");
+        f.rows.stream().filter(row -> "DELIVERY".equals(row.getStopType())).forEach(row -> row.setStatus("DELIVERED"));
+        when(f.orders.transitionByParticipant(eq(10L), eq(32L), eq("DELIVERED"), eq("COMPLETED"), any())).thenAnswer(call -> {
+            if (!"DELIVERED".equals(f.order.getStatus())) return 0;
+            f.order.setStatus("COMPLETED"); return 1;
+        });
+        assertDoesNotThrow(() -> f.service.confirmReceipt(32L, 10L));
+        assertThrows(ServiceException.class, () -> f.service.confirmReceipt(32L, 10L));
+        verify(f.billing, times(1)).insert(any(com.ruoyi.system.domain.UserBilling.class));
+    }
+
+    @Test void v2AddressUpdatePreservesAllStopsItemsAndOriginalPricing() throws Exception {
+        Fixture f = addressFixture();
+        try (MockedStatic<DictUtils> dict = mockStatic(DictUtils.class)) {
+            multiStopDicts(dict);
+            var json = new com.fasterxml.jackson.databind.ObjectMapper();
+            Class<?> quoteType = assertDoesNotThrow(() -> Class.forName("com.ruoyi.app.flashdelivery.dto.FlashDeliveryV2AddressChangeRequest"));
+            Class<?> confirmType = assertDoesNotThrow(() -> Class.forName("com.ruoyi.app.flashdelivery.dto.FlashDeliveryV2AddressConfirmRequest"));
+            var body = json.createObjectNode(); body.put("orderId", 10); body.put("orderVersion", 0);
+            body.set("stops", json.valueToTree(v2Create().getStops()));
+            Object quoteRequest = json.treeToValue(body, quoteType);
+            FlashDeliveryQuoteView quote = (FlashDeliveryQuoteView) f.service.getClass().getMethod("quoteAddressV2", Long.class, quoteType).invoke(f.service, 7L, quoteRequest);
+            assertEquals(1L, quote.getPricingId()); assertEquals(100L, quote.getAmount());
+            body.put("quotedDistanceMeters", quote.getDistanceMeters()); body.put("quotedBaseDeliveryFee", quote.getBaseDeliveryFee());
+            body.put("quotedDistanceFee", quote.getDistanceFee()); body.put("quotedUrgentFee", quote.getUrgentFee()); body.put("quotedAmount", quote.getAmount());
+            Object confirm = json.treeToValue(body, confirmType);
+            when(f.orders.updateWaitingAddress(any(), eq(0))).thenReturn(1);
+            when(f.stops.delete(any(com.baomidou.mybatisplus.core.conditions.Wrapper.class))).thenAnswer(call -> { f.rows.clear(); return 4; });
+            when(f.stops.insert(any(FlashDeliveryOrderStop.class))).thenAnswer(call -> { FlashDeliveryOrderStop row = call.getArgument(0); row.setId(20L + f.rows.size()); f.rows.add(row); return 1; });
+            var detail = (FlashDeliveryOrderDetailView) f.service.getClass().getMethod("updateAddressV2", Long.class, confirmType).invoke(f.service, 7L, confirm);
+            assertEquals(4, detail.getStops().size()); assertEquals(2, f.order.getDeliveryStopCount());
+            assertEquals("FOOD_INGREDIENTS", f.order.getPackageType()); assertEquals(8, f.order.getQuantity());
+            assertEquals(List.of("取月餅", "取牛奶", "送管理室", "送前台"), f.rows.stream().map(FlashDeliveryOrderStop::getRemark).toList());
+            assertEquals(32L, f.rows.get(3).getReceiverUserId());
+            assertNotNull(f.rows.get(2).getDeliveryPinCode());
+            assertEquals(1, f.order.getVersion());
+            verify(f.prices, never()).selectAtTime(anyString(), anyInt());
+        }
+    }
+
+    private static Fixture ready() {
+        Fixture f = new Fixture(); f.order.setStatus("PICKED_UP"); f.order.setPinRequired(false);
+        f.rows.stream().filter(row -> "PICKUP".equals(row.getStopType())).forEach(row -> row.setStatus("PICKED_UP"));
+        return f;
+    }
+    private static void receivers(Fixture f) {
+        f.order.setReceiverUserId(31L); f.rows.get(2).setReceiverUserId(31L); f.rows.get(3).setReceiverUserId(32L);
+    }
+    private static FlashDeliveryDeliverRequest deliveryProof(Long stopId) {
+        FlashDeliveryDeliverRequest request = new FlashDeliveryDeliverRequest(); request.setStopId(stopId);
+        request.setImageUrls(List.of("/profile/d.jpg")); return request;
+    }
+    private static FlashDeliveryAddressRequest address(FlashDeliveryOrderStop row) {
+        FlashDeliveryAddressRequest request = new FlashDeliveryAddressRequest(); org.springframework.beans.BeanUtils.copyProperties(row, request); return request;
+    }
+    private static Fixture addressFixture() {
+        Fixture f = new Fixture(); f.order.setStatus("WAITING_ACCEPTANCE"); f.order.setRiderId(null); f.order.setPinRequired(true);
+        f.order.setServiceType("HELP_SEND"); f.order.setDeliveryType("NORMAL"); f.order.setVehicleType(1); f.order.setTipAmount(0L);
+        f.order.setPackageType("FOOD_INGREDIENTS"); f.order.setQuantity(8); f.order.setPricingId(1L); f.order.setPricingVersion(1);
+        f.order.setStartingDistance(BigDecimal.valueOf(3)); f.order.setStartingFare(80L); f.order.setDistance(BigDecimal.ONE);
+        f.order.setFreight(10L); f.order.setUrgentRate(BigDecimal.ZERO); f.order.setMinimumUrgentFee(0L); return f;
+    }
+
     private List<FlashDeliveryOrderStop> argThatStops(Fixture f) {
         var captor = org.mockito.ArgumentCaptor.forClass(FlashDeliveryOrderStop.class);
         verify(f.stops, atLeast(4)).insert(captor.capture());
@@ -201,16 +400,18 @@ class FlashDeliveryV2Test {
         final InfoUserMapper users = mock(InfoUserMapper.class);
         final FlashDeliveryRouteService routes = mock(FlashDeliveryRouteService.class);
         final FlashDeliveryNotificationService notifications = mock(FlashDeliveryNotificationService.class);
+        final com.ruoyi.system.mapper.UserBillingMapper billing = mock(com.ruoyi.system.mapper.UserBillingMapper.class);
         List<com.ruoyi.app.flashdelivery.route.GeoPoint> capturedPoints = new ArrayList<>();
         final FlashDeliveryApplicationService service = new FlashDeliveryApplicationService(orders, prices, images, logs, users, mock(com.ruoyi.system.mapper.InfoBankCardMapper.class),
                 routes, new FlashDeliveryPricingCalculator(), mock(RiderDeliveryLockService.class), mock(RiderDeliveryAcceptanceService.class),
                 stops, adjustments, mock(com.ruoyi.system.service.PaymentMethodGateService.class),
-                notifications, mock(com.ruoyi.system.mapper.UserBillingMapper.class));
+                notifications, billing);
         final FlashDeliveryOrder order = new FlashDeliveryOrder();
         final List<FlashDeliveryOrderStop> rows = new ArrayList<>(List.of(
                 stop(11L, 0, "PICKUP", "0911111110"), stop(12L, 1, "PICKUP", "0911111111"),
                 stop(13L, 2, "DELIVERY", "0911111112"), stop(14L, 3, "DELIVERY", "0911111113")));
         Fixture() {
+            order.setOrderNo("FL10"); when(billing.insert(any(com.ruoyi.system.domain.UserBilling.class))).thenReturn(1);
             order.setId(10L); order.setUserId(7L); order.setRiderId(8L); order.setStatus("ACCEPTED");
             order.setVersion(0); order.setPickupStopCount(2); order.setPickedUpStopCount(0);
             order.setDeliveryStopCount(2); order.setDeliveredStopCount(0);

+ 10 - 1
ruoyi-system/src/main/resources/mapper/flash/FlashDeliveryOrderMapper.xml

@@ -102,6 +102,7 @@
     <update id="updateWaitingAddress">
         update flash_delivery_order
         set pickup_stop_count = #{order.pickupStopCount}, picked_up_stop_count = 0, current_target_stop_id = null,
+            delivery_stop_count = #{order.deliveryStopCount}, delivered_stop_count = 0,
             package_type = #{order.packageType}, quantity = #{order.quantity},
             weight_range = #{order.weightRange}, specification = #{order.specification},
             pickup_name = #{order.pickupName}, pickup_phone = #{order.pickupPhone},
@@ -144,13 +145,18 @@
             <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}
+          and delivery_stop_count is null
     </update>
 
     <!-- 用户确认收货必须同时满足订单归属和已送达状态。 -->
     <update id="transitionByParticipant">
         update flash_delivery_order
         set status=#{nextStatus}, completed_at=#{now}, update_time=#{now}, version = version + 1
-        where id=#{id} and (user_id=#{userId} or receiver_user_id = #{userId}) and status=#{expectedStatus}
+        where id=#{id} and (user_id=#{userId} or receiver_user_id = #{userId}
+              or exists (select 1 from flash_delivery_order_stop s where s.order_id=flash_delivery_order.id
+                  and s.stop_type='DELIVERY' and s.receiver_user_id = #{userId})) and status=#{expectedStatus}
+          and not exists (select 1 from flash_delivery_order_stop s where s.order_id=flash_delivery_order.id
+              and s.stop_type='DELIVERY' and s.status &lt;&gt; 'DELIVERED')
     </update>
 
     <!-- 评分条件更新:仅寄件人的完成态订单、已接骑手且从未评价才成功,并发重复提交返回 0。 -->
@@ -172,6 +178,8 @@
         update flash_delivery_order
         set status=#{nextStatus}, completed_at=#{now}, update_time=#{now}, version = version + 1
         where id=#{id} and status=#{expectedStatus}
+          and not exists (select 1 from flash_delivery_order_stop s where s.order_id=flash_delivery_order.id
+              and s.stop_type='DELIVERY' and s.status &lt;&gt; 'DELIVERED')
     </update>
 
     <!-- 取消仅取件前;服务行锁和站点检查之外再加条件,防止旧入口绕过部分取件事实。 -->
@@ -212,6 +220,7 @@
     <update id="updateDeliveryFulfilment">
         update flash_delivery_order
         set status = #{order.status}, delivered_stop_count = #{order.deliveredStopCount},
+            current_target_stop_id = #{order.currentTargetStopId},
             delivered_at = #{order.deliveredAt}, update_time = #{order.updateTime}, version = version + 1
         where id = #{order.id} and rider_id = #{order.riderId}
           and status = 'PICKED_UP' and version = #{order.version}

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

@@ -18,6 +18,36 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
 
 class FlashDeliveryMapperXmlTest {
 
+
+    @Test
+    void deliveryTargetAndCompletionSqlEnforcePerStopFacts() throws Exception {
+        String resource = "mapper/flash/FlashDeliveryOrderMapper.xml";
+        Configuration configuration = new Configuration();
+        configuration.getTypeAliasRegistry().registerAlias("FlashDeliveryOrder", FlashDeliveryOrder.class);
+        try (InputStream input = getClass().getClassLoader().getResourceAsStream(resource)) {
+            new XMLMapperBuilder(input, configuration, resource, configuration.getSqlFragments()).parse();
+        }
+        String namespace = "com.ruoyi.system.mapper.flash.FlashDeliveryOrderMapper.";
+        FlashDeliveryOrder order = new FlashDeliveryOrder(); order.setId(10L); order.setVersion(2);
+        var delivery = configuration.getMappedStatement(namespace + "updateDeliveryFulfilment")
+                .getBoundSql(java.util.Map.of("order", order));
+        assertTrue(delivery.getSql().contains("current_target_stop_id"));
+        assertTrue(delivery.getParameterMappings().stream().anyMatch(mapping -> mapping.getProperty().equals("order.currentTargetStopId")));
+        var participant = configuration.getMappedStatement(namespace + "transitionByParticipant")
+                .getBoundSql(java.util.Map.of("id", 10L, "userId", 32L, "expectedStatus", "DELIVERED", "nextStatus", "COMPLETED", "now", new java.util.Date()));
+        String sql = participant.getSql().replaceAll("\\s+", " ").toLowerCase();
+        assertTrue(sql.contains("s.receiver_user_id = ?"));
+        assertTrue(sql.contains("flash_delivery_order_stop"));
+        assertTrue(sql.contains("not exists"));
+        assertTrue(sql.contains("s.status <> 'delivered'"));
+        var edit = configuration.getMappedStatement(namespace + "updateWaitingAddress")
+                .getBoundSql(java.util.Map.of("order", order, "expectedVersion", 2));
+        assertTrue(edit.getParameterMappings().stream().anyMatch(mapping -> mapping.getProperty().equals("order.deliveryStopCount")));
+        var complete = configuration.getMappedStatement(namespace + "transitionByStatus")
+                .getBoundSql(java.util.Map.of("id", 10L, "expectedStatus", "DELIVERED", "nextStatus", "COMPLETED", "now", new java.util.Date()));
+        assertTrue(complete.getSql().toLowerCase().contains("not exists"));
+    }
+
     @Test
     void stationFulfilmentLocksOrderAndPersistsExplicitTargetReset() throws Exception {
         String resource = "mapper/flash/FlashDeliveryOrderMapper.xml";

+ 23 - 6
specs/024-flash-delivery/contracts/api.md

@@ -173,11 +173,11 @@
 
 ### 下单、报价与待接单编辑
 
-- `home` 增加 `pickupStopLimit`、`showMultiPickup`、`showUrgentOption`。上限来自 `flash_pickup_stop_limit`,有效值 1..26,缺失或非法回退 1;`flash_show_multi_pickup` 关闭时只能提交一个取货点。
+- `home` 下发 `pickupStopLimit`、`deliveryStopLimit`(有效值 1..10,缺失或非法回退 1)。兼容字段 `showMultiPickup`、`showMultiDropoff` 分别由对应上限是否大于 1 派生;`showUrgentOption` 固定为 true,加急功能始终可用。无独立多点或加急展示字典开关。
 - 报价、创建新增 `pickups` 数组。每项直接包含地址字段(`name,phone,address,addressDetail,city,area,handoffMethod,latitude,longitude`)及独立物品字段(`packageType,quantity,weightRange,specification`),不再提交订单级物品。
 - 原 `pickup` 加订单级物品的单点请求仍可提交;与 `pickups` 同传或多点数组与订单级物品混传均拒绝。新订单一律保存取货及收货站点,不提供缺失站点历史订单回退。
 - 地址报价/确认使用完整 `pickups` 和唯一 `delivery`。多点不得仅传旧 `pickup`;旧单点编辑保留原物品。待接单调整站点顺序必须重新报价确认;接单后访问顺序调整不走改址,不重算金额。
-- 用户顺序用于整线地图报价,最多 26 个取货点及 1 个收货点,不优化或省略中途点;地图不可用时逐段直线求和,整线限制 40 公里。仅追加小费不调用路线、不重新校验开关及点数上限。
+- 用户顺序用于整线地图报价,最多 10 个取货点及 1 个收货点,不优化或省略中途点;地图不可用时逐段直线求和,整线限制 40 公里。仅追加小费不调用路线、不重新校验点数上限。
 
 ### 逐站履约
 
@@ -251,20 +251,37 @@
 }
 ```
 
-地址模型与 Lalamove 一致:站点只存 `address` 完整地址文本(台湾地址为连续文字,不做城市/区结构化拆分)、可选 `addressDetail`(樓層/室)与必填经纬度;路线与计价以经纬度为准,地址文本用于展示和骑手导航。物品主体 `packageType,quantity,weightRange?,specification?` 只出现一次;`remark` 选填、不超过 200 字;预约时间、支付方式、小费等订单级字段沿用现有契约。服务端校验:至少 1 个 `PICKUP` 与 1 个 `DELIVERY`、取货站必须全部位于收货站之前(先取完再送,交错取送属后续增量),并按 `flash_pickup_stop_limit`/`flash_delivery_stop_limit` 与对应多点开关限制数量;v2 运费计算使用独立的新计价方法,报价路线经点为全部站点,整线 40 公里上限不变。旧单点订单的市/区结构化字段仅随旧接口与旧订单继续存在。
+地址模型与 Lalamove 一致:站点只存 `address` 完整地址文本(台湾地址为连续文字,不做城市/区结构化拆分)、可选 `addressDetail`(樓層/室)与必填经纬度;路线与计价以经纬度为准,地址文本用于展示和骑手导航。物品主体 `packageType,quantity,weightRange?,specification?` 只出现一次;`remark` 选填、不超过 200 字;预约时间、支付方式、小费等订单级字段沿用现有契约。服务端校验:至少 1 个 `PICKUP` 与 1 个 `DELIVERY`、取货站必须全部位于收货站之前(先取完再送,交错取送属后续增量),并按 `flash_pickup_stop_limit`/`flash_delivery_stop_limit` 限制数量;v2 运费计算使用独立的新计价方法,报价路线经点为全部站点,整线 40 公里上限不变。旧单点订单的市/区结构化字段仅随旧接口与旧订单继续存在。
 
-响应要点:报价返回整线距离、时长与费用明细(结构同现有报价);创建返回订单详情,站点数组含每站 `stopId`、状态与备注;收货站按联系人电话固化 `receiverUserId`(可空)。启用收货码时每个收货站返回独立 `deliveryPinCode`,仅订单参与方可见。
+响应要点:报价返回整线距离、时长与费用明细(结构同现有报价);创建返回订单详情,`stops[].id` 是后续履约所需的站点 ID,另有状态与备注。服务端按收货站联系人电话固化 `receiverUserId`(可空),详情不返回该内部 ID。启用收货码时每个收货站生成独立 `deliveryPinCode`;发起人可见全部,已绑定收件人仅可见自己的站点,骑手不可见。v2 订单级 `deliveryPinCode` 为空。列表和详情返回 `deliveryStopCount`、`deliveredStopCount`。
 
 ### 骑手逐站送达
 
 | 方法 | 路径 | 说明 |
 | --- | --- | --- |
-| POST | `/system/flashDelivery/rider/orders/{id}/stops/{stopId}/deliver` | `{"imageUrls":[...]}` 至少一张;幂等重试返回已确认结果 |
+| POST | `/system/flashDelivery/rider/orders/stops/deliver` | body:`{"orderId":10,"stopId":13,"pinCode":"4821","imageUrls":[...]}`;订单与收货站 ID 必填正整数,图片至少一张;启用 PIN 时必须提交该站收货码 |
 
-全部收货站确认送达后才推进主状态 DELIVERED 并生成骑手收入账单;任一收货站已送达后订单进入禁止取消状态(用户与平台同口径)。
+全部收货站确认送达后才推进主状态 DELIVERED;随后确认收货、平台完成或系统自动完成进入 COMPLETED 时才生成一笔骑手收入账单。任一收货站已送达后订单进入禁止取消状态(用户与平台同口径)。旧整单送达接口拒绝全部 v2 订单,只有一个收货站的 v2 订单也使用此逐站接口并提供 `stopId`。
+
+每个取货站或收货站首次确认成功且事务提交后,向发起人推送一次逐站进度并写入消息中心。消息 payload 使用 `FLASH_PICKUP_STOP_COMPLETED` 或 `FLASH_DELIVERY_STOP_COMPLETED`,包含 `ddId,state,stopId,stopType,stopOrder,completedStopCount,totalStopCount`;`stopOrder` 为从 0 开始的路线下标,文案中站号从 1 开始。重试、失败和回滚不重复通知;最后一站不叠发同阶段整单提醒。逐站收件人通知本阶段暂缓。
+
+### v2 待接单改址
+
+| 方法 | 路径 | body | 说明 |
+| --- | --- | --- | --- |
+| POST | `/system/flashDelivery/v2/orders/address/quote` | `orderId,orderVersion,stops` | 完整站点重新报价,沿用订单级物品和原运价快照 |
+| POST | `/system/flashDelivery/v2/orders/address` | 上述字段及 `quotedDistanceMeters,quotedBaseDeliveryFee,quotedDistanceFee,quotedUrgentFee,quotedAmount` | 核对最新报价和订单版本,同一事务替换全部站点 |
+
+`stops` 必须包含完整的取货与收货站数组,顺序、字段及站点数量限制同 v2 创建。保存成功后以返回详情刷新全部站点 ID 和收货码;旧站点 ID 不再用于履约。旧改址接口仅保留旧订单行为,遇到 v2 订单会拒绝,不会删减收货站。
 
 ### 废弃与退役计划
 
 - 自 v2 上线起,`POST /system/flashDelivery/quote`、`POST /system/flashDelivery/orders`(含旧单点 `pickup`、逐站物品 `pickups`、混传校验)及旧送达接口标注废弃:代码 `@Deprecated` + 本契约标注"过渡期保留";行为冻结,不接收新功能,既有线上 App 不受影响。
 - 退役删除为独立收尾批次,前置条件:用户/骑手 App 两端全部切换 v2 并通过真实环境联调;删除范围含旧结构 DTO 与互斥校验、对应测试、本契约废弃段落及 `pickupStopLimit`/`showMultiPickup` 等被 v2 字段取代的首页配置。
 - 数据无迁移:v2 订单与旧订单共用站点表;`remark`、按站收件人与按站收货码仅 v2 新订单写入。
+
+### 2026-10-14:新增接口业务参数统一放 body
+
+本次新增的 `/v2/quote`、`/v2/orders`、逐站送达及 v2 改址接口采用该规则;GET 的简单 query/路径参数及已有旧接口保持原样(含 LINE Pay 创建、取件、目标调整、改址和平台接口)。`token` 继续使用请求头,不放 body。
+
+新逐站送达请求 DTO:`FlashDeliveryStopDeliverRequest`。`orderId` 与 `stopId` 只从 body 取得,缺失或非正数返回国际化业务错误;单收货站也必须传 `stopId`。旧整单送达仍为 `/rider/orders/{id}/deliver`,仅供旧订单过渡使用。

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

@@ -57,5 +57,5 @@
 
 - `flash_delivery_order_stop` 增加 `remark VARCHAR(200)`(选填备注,骑手了解该点取/送什么);收货站增加可空 `receiver_user_id`(按站匹配的注册收件人)与可空 `delivery_pin_code`(启用收货码时按站生成)。取货站两列为空,历史语义不变。
 - `flash_delivery_order` 增加 `delivery_stop_count`、`delivered_stop_count`,镜像取件侧计数;全部收货站完成才推进 DELIVERED。订单级物品字段恢复为唯一物品来源,站点表不再承载结构化物品;订单级 `delivery_pin_code` 仅对 v2 之前的旧订单继续使用。
-- 字典新增 `flash_delivery_stop_limit`(1..10,缺失或非法回退 1)与 `flash_show_multi_dropoff`(关);与取货侧字典独立配置。
-- 全部 DDL 修订 `updatesql/sql.md` 中尚未执行的多站点增量段落,由开发者手动执行;无历史订单站点迁移、回写或兼容清理。
+- 字典仅保留 `flash_pickup_stop_limit` 与 `flash_delivery_stop_limit` 两项站点数量上限(均为 1..10,缺失或非法回退 1,默认 1)。无多点独立开关,加急选项始终开放。
+- DDL 与字典调整见 `updatesql/sql.md`:测试库已按旧版增量路径执行并核对 18 条站点记录,其他环境仍需按实际表结构选择执行路径;无历史订单站点迁移或回写。

+ 28 - 7
specs/024-flash-delivery/plan.md

@@ -716,11 +716,11 @@ The staged list must not contain `.claude/homunculus/observations.jsonl`、`.tmp
 **约束**:JDK 21;ruoyi-admin -> ruoyi-system;明确 DTO、token 请求头、Service 国际化校验;SQL 只记录 updatesql/sql.md;保留原编码换行。本轮按用户授权在 test-202609v1 实现后端、平台 PC 并同步关联文档,运行定向测试及构建;SQL 仅记录不执行。商家 PC 与现有 App 目录无闪送页面源码,客户端接入任务保留待办。
 
 1. **数据层(T089、T090)**:在 ruoyi-system 的 domain/flash 新增 FlashDeliveryOrderStop,mapper/flash 新增 Mapper 与 XML;存每站联系人、地址、packageType/quantity/weightRange/specification、状态和确认时间,关联站点凭证。订单实体与 FlashDeliveryOrderMapper.xml 的 resultMap、select/insert/update 同步 pickup_stop_count、首点地址及物品摘要;站点按 order_id 查询并建立同单 stop_order 唯一约束。updatesql/sql.md 记录建表、订单加列、凭证关联所需 SQL 及三个字典项,上限初始化为 1;不执行 SQL。
-2. **配置及路线(T090、T091)**:RouteDistanceProvider 新增多经点方法,GoogleRoutesDistanceProvider 以首取点为 origin、其余 intermediates、收货点 destination,不开启 optimizeWaypointOrder;FlashDeliveryRouteService 降级逐段直线求和。数量有效范围 1..26,字典缺失/非法回退 1,超限在地图调用前拒绝;路线与降级均校验整线 40 公里。FlashDeliveryHomeView 下发 pickupStopLimit、showMultiPickup、showUrgentOption。
-3. **请求、报价及创建(T091)**:在 ruoyi-admin 的 flashdelivery/dto 新增含完整地址和物品字段的取货点 DTO,QuoteRequest/CreateRequest 扩展 pickups 数组;保留旧 pickup + 订单级物品单点适配。两种地址输入同时出现、数组与旧订单级物品同时提交均拒绝;逐站校验物品、数量、重量范围、规格、地址/坐标及取收同址,并统一校验开关和上限。FlashDeliveryApplicationService 沿用创建幂等、路线及费用复算,创建站点、摘要与图片处于同一事务。
-4. **待接单编辑(T091)**:AddressChangeRequest/AddressConfirmRequest 同步完整 pickups + delivery;多点不得以 pickup 覆盖站点集合,旧单点编辑保留物品;待接单调整顺序也使用完整站点序列重报价并确认,接单后骑手调整访问先后不走地址编辑和重报价。沿用原运价快照及 orderVersion,确认时复算整线;先执行校验创建人/待接单/无骑手/版本的订单条件 UPDATE,成功后同事务保存站点、首点摘要、数量和收件人绑定,失败整体回滚。addTip 保留只增加小费及 amount,不查路线、不重算其他费用,不重验开关或数量上限。
+2. **配置及路线(T090、T091)**:RouteDistanceProvider 新增多经点方法,GoogleRoutesDistanceProvider 以首取点为 origin、其余 intermediates、收货点 destination,不开启 optimizeWaypointOrder;FlashDeliveryRouteService 降级逐段直线求和。数量有效范围 1..10,字典缺失/非法回退 1,超限在地图调用前拒绝;路线与降级均校验整线 40 公里。FlashDeliveryHomeView 下发 pickupStopLimit;兼容 showMultiPickup 由上限派生,showUrgentOption 固定为 true。
+3. **请求、报价及创建(T091)**:在 ruoyi-admin 的 flashdelivery/dto 新增含完整地址和物品字段的取货点 DTO,QuoteRequest/CreateRequest 扩展 pickups 数组;保留旧 pickup + 订单级物品单点适配。两种地址输入同时出现、数组与旧订单级物品同时提交均拒绝;逐站校验物品、数量、重量范围、规格、地址/坐标及取收同址,并统一校验数量上限。FlashDeliveryApplicationService 沿用创建幂等、路线及费用复算,创建站点、摘要与图片处于同一事务。
+4. **待接单编辑(T091)**:AddressChangeRequest/AddressConfirmRequest 同步完整 pickups + delivery;多点不得以 pickup 覆盖站点集合,旧单点编辑保留物品;待接单调整顺序也使用完整站点序列重报价并确认,接单后骑手调整访问先后不走地址编辑和重报价。沿用原运价快照及 orderVersion,确认时复算整线;先执行校验创建人/待接单/无骑手/版本的订单条件 UPDATE,成功后同事务保存站点、首点摘要、数量和收件人绑定,失败整体回滚。addTip 保留只增加小费及 amount,不查路线、不重算其他费用,不重验数量上限。
 5. **取件及取消(T092、T093)**:FlashDeliveryProofRequest 增加可选 stopId;原 pickup 接口单点可省略、多点必传,另提供路径 stopId 接口;两处 ID 同传必须一致,共用取件业务实现,不自动选择下一未取站。FlashDeliveryOrderMapper 新增按订单 ID SELECT ... FOR UPDATE;取件、用户/平台取消均在事务内先锁订单行,再读最新站点状态,不能使用锁前读到的状态校验。校验订单、骑手、PICKUP 类型;新取件目标须符合 FR-096,非默认站须先保存调整原因及已取得同意声明,不能直接以 stopId 绕过;已确认站点重试无新写入,不能因目标已变化拒绝幂等重试。新取件必须处于 ACCEPTED,站点和凭证保存后检查是否全部取完,递增订单版本并记录站点日志;仅全部取完更新 PICKED_UP/pickedUpAt。用户取消锁内检查任意已取站点并拒绝;平台沿用介入权限,按 FR-098 记录异常站、处理结果、已取物品去向及交接凭证后再关闭异常订单,不能漏取、虚假取件或直接完成。图片、日志或其他更新失败必须全部回滚。
-6. **视图及契约(T093、T094)**:列表增加 pickupStopCount 并保留首点摘要,各角色详情增加带 ID、独立物品及凭证的 stops,pickupImageUrls 仅作聚合展示;接单前逐站隐藏联系人、电话、坐标、PIN 及图片。用户、骑手及平台展示已取数量/总数量,部分取件仍为 ACCEPTED,但用户取消入口按任意站是否已取控制并在刷新时更新;平台详情同步展示各站和调整记录供介入核对。实现阶段同步 contracts/api.md、data-model.md、quickstart.md、docs/flash-delivery-app-api.md 和现有全部后端语言资源;平台改动同步四语言。错误 key 覆盖缺少 stopId、越权、输入冲突、点数超限、开关关闭;允许按规则调整,补充缺少原因/同意声明、目标或调整记录不匹配等国际化错误。导航使用 travelmode=two-wheeler,默认指向用户顺序中最早的未取站,保存调整后为选定单站,全部取完后为收货点;客户端生成 URL,导航不等同取件。
+6. **视图及契约(T093、T094)**:列表增加 pickupStopCount 并保留首点摘要,各角色详情增加带 ID、独立物品及凭证的 stops,pickupImageUrls 仅作聚合展示;接单前逐站隐藏联系人、电话、坐标、PIN 及图片。用户、骑手及平台展示已取数量/总数量,部分取件仍为 ACCEPTED,但用户取消入口按任意站是否已取控制并在刷新时更新;平台详情同步展示各站和调整记录供介入核对。实现阶段同步 contracts/api.md、data-model.md、quickstart.md、docs/flash-delivery-app-api.md 和现有全部后端语言资源;平台改动同步四语言。错误 key 覆盖缺少 stopId、越权、输入冲突、点数超限;允许按规则调整,补充缺少原因/同意声明、目标或调整记录不匹配等国际化错误。导航使用 travelmode=two-wheeler,默认指向用户顺序中最早的未取站,保存调整后为选定单站,全部取完后为收货点;客户端生成 URL,导航不等同取件。
 7. **测试(T088、T095)**:扩展 ApplicationService、ControllerContract、RouteService、OrderView、RiderOrderListView、MapperXml、I18nContract 测试。覆盖独立物品、旧单点适配/冲突、完整站点编辑/物品保持/版本竞争;单点省略 ID、多点缺 ID 拒绝、记录同意声明及原因后 B 先于 A 成功、缺少调整记录/原因/声明拒绝、跨单/收货站/他人骑手拒绝;响应丢失后重试及并发重复请求不追加凭证或推进别站,最后两站并发完成只推进一次,凭证失败整体回滚。取消与取件并发验证两种先后结果,不能仅用顺序调用宣称并发通过。数量覆盖 0、1、26、27 及字典缺失/非法,26 点对应 25 intermediates;覆盖整线 40 公里、降级、脱敏和追加小费不调用路线。
 8. **验证交付(T095)**:实现后临时设置本机 JDK 21,运行受影响闪送定向测试及 mvn -pl ruoyi-admin -am -DskipTests package,再执行 git diff --check、变更范围和编码换行检查。需要 MySQL 的事务/并发检查独立执行记录,单元和 Mapper 文本检查不能替代数据库验证。真实地图、数据库、App 联调及未执行回归明确列出;核对暂存仅含本功能文件后中文提交。
 
@@ -776,9 +776,30 @@ The staged list must not contain `.claude/homunculus/observations.jsonl`、`.tmp
 
 ## 2026-10-14 多点取送统一契约实施计划(确认待实施)
 
-1. 数据与字典:修订尚未执行的多站点增量 SQL——`flash_delivery_order_stop` 加 `remark VARCHAR(200)`、收货站 `receiver_user_id`(可空)与 `delivery_pin_code`(可空,启用收货码时按站);`flash_delivery_order` 加 `delivery_stop_count`、`delivered_stop_count`;字典新增 `flash_delivery_stop_limit`、`flash_show_multi_dropoff`,初始 1/关。物品主体仍在 `flash_delivery_order` 原有订单级字段,无迁移。
-2. v2 契约:新增 v2 报价/创建 DTO 与服务入口(统一 `stops[]` 站点数组,取/送以 `type` 区分、数组顺序即路线顺序即 `stop_order`,订单级物品+每站备注),校验至少 1 取 1 收、取货站全部位于收货站之前、上限/开关,收件人按站匹配;复用既有路线、运价、订单锁、站点写入与图片机制,不为 v2 复制业务表。
+1. 数据与字典:修订尚未执行的多站点增量 SQL——`flash_delivery_order_stop` 加 `remark VARCHAR(200)`、收货站 `receiver_user_id`(可空)与 `delivery_pin_code`(可空,启用收货码时按站);`flash_delivery_order` 加 `delivery_stop_count`、`delivered_stop_count`;字典新增 `flash_delivery_stop_limit`,初始值 1;取货侧继续使用 `flash_pickup_stop_limit`,不设多点或加急展示开关。物品主体仍在 `flash_delivery_order` 原有订单级字段,无迁移。
+2. v2 契约:新增 v2 报价/创建 DTO 与服务入口(统一 `stops[]` 站点数组,取/送以 `type` 区分、数组顺序即路线顺序即 `stop_order`,订单级物品+每站备注),校验至少 1 取 1 收、取货站全部位于收货站之前及数量上限,收件人按站匹配;复用既有路线、运价、订单锁、站点写入与图片机制,不为 v2 复制业务表。
 3. 送达状态机:新增站点级送达确认端点与幂等(镜像取件侧实现),部分送达不推进主状态、末站完成条件更新推进 DELIVERED 并沿用账单/推送编排;任一收货站已送达后复用 FR-111/112 的取消门禁。收货码按站生成校验,订单级旧码仅对旧订单保留。
 4. 展示与推送:平台 PC 站点列表含备注与送达状态;用户/骑手列表摘要首取首收+计数;逐站送达推送对应收件人,沿用既有推送场景框架。
 5. 退役管理:旧报价/创建/送达接口标注 `@Deprecated` 与契约废弃说明,行为冻结;删除为收尾独立批次(App 两端切换并联调通过后执行),删除范围含旧结构分支、逐站物品 DTO、互斥校验、对应测试与文档。
-6. 验证:TDD 覆盖 v2 契约、上限/开关、整线报价、逐站送达幂等与并发、部分送达取消门禁、按站收件人与推送、旧接口过渡行为;JDK 21 定向与全量、模块构建、平台构建。SQL 执行、真实 Google Routes、App 联调另行验收;进度按 未开始/实现中/已提交/已验证 口径报告。
+6. 验证:TDD 覆盖 v2 契约、数量上限、整线报价、逐站送达幂等与并发、部分送达取消门禁、按站收件人与推送、旧接口过渡行为;JDK 21 定向与全量、模块构建、平台构建。SQL 执行、真实 Google Routes、App 联调另行验收;进度按 未开始/实现中/已提交/已验证 口径报告。
+
+## 2026-10-14 新增多点取送接口统一 body 参数(用户确认旧接口不动)
+
+1. 仅本次新增的 v2 报价、创建与逐站送达遵循 body 统一业务参数;GET 简单参数保持现状。已有 LINE Pay、旧报价/创建/送达、取件、目标调整、改址及平台接口均不修改。
+2. v2 quote/orders 已是固定路径和明确请求体;新增逐站送达改为 POST /system/flashDelivery/rider/orders/stops/deliver,独立 FlashDeliveryStopDeliverRequest 承载 orderId、stopId、pinCode、imageUrls,token 仍从请求头取得。
+3. Controller 校验必需 orderId/stopId 为正整数,使用现有国际化错误;调用既有 deliverAtStop,服务层状态机及其他审查问题不在此次参数调整范围。同步 024 spec、tasks、契约与 App 文档;不改 SQL 和旧 DTO。
+4. 接口测试验证新增路径、body ID 实际传递、缺失/非正数 ID 拒绝、既有 GET/旧接口契约保持;JDK 21 定向回归与差异检查。
+
+## 2026-10-14 审查问题修复与通知范围调整(用户已授权)
+
+1. 流程修复:旧整单送达拒绝 v2;完成、自动完成与确认收货都检查所有 DELIVERY 已送达。逐站送达先核实所属骑手/站点,再幂等返回已确认站;新确认严格跟随目标,送达目标过滤已完成站,目标调整支持 PICKED_UP 阶段的 DELIVERY,继续保留版本与同意声明审计。
+2. v2 完整改址:新增固定路径 `/v2/orders/address/quote`、`/v2/orders/address` 和独立 DTO,仅 body 接收 orderId/version/完整 stops(确认保存另含回传报价),共享运价快照与条件更新;旧改址拒绝 v2,保留原参数及历史单行为。站点全量保存计数、备注、收件人与 PIN,物品仍从订单级保留,不直接套用旧单收货点保存方法。
+3. 权限与展示:v2 不生成订单级替代码;用户详情按站返回 PIN(发起人全部、收件人仅本人、骑手不返回)。按站收件人覆盖 receiver 查询、详情及确认收货,DTO 补收货站总数/进度,未绑定用户不追溯认领。
+4. 通知独立实现:每站首次成功提交后仅通知发起人,payload 站点与进度明确、状态真实,末站不叠发相同阶段整单提醒;国际化复用当前五语言资源。逐站收件人通知明确暂缓,后续用户要求再补,当前不排期。
+5. 验证:先补问题场景回归并确认 RED,再批量修复,统一 JDK 21 定向与后端全量回归、模块构建及差异/换行核对。真实数据库多连接并发、推送设备、Routes 和 App 联调另列未验证;SQL 不执行、已有未跟踪 .test-hold 不动。
+
+## 2026-10-14 简化多点与加急配置(用户确认)
+
+1. 移除 `flash_show_multi_pickup`、`flash_show_multi_dropoff`、`flash_show_urgent_option` 三个字典依赖;取/收货点只按各自 1..10 的数量上限校验,加急始终可用。
+2. 首页保留现有 `showMultiPickup`、`showMultiDropoff`、`showUrgentOption` 字段兼容客户端;前两个按上限是否大于 1 派生,后者固定为 true。
+3. 在 `updatesql/sql.md` 登记两项上限初始化和旧开关字典清理语句;用户后续明确要求更新测试库,已按旧版增量路径执行并复核。其他环境不自动执行;同步现有规格、接口契约、前端交接文档及定向测试。

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

@@ -25,8 +25,8 @@
 
 ## 多取货点验收(2026-09-15)
 
-1. 开发者手动执行多站点 SQL,确认上限默认 1;完成所需客户端适配后,手动开启 `flash_show_multi_pickup` 并将 `flash_pickup_stop_limit` 设置为测试值。单点新订单同样必须有取送站点。
-2. 创建 A → B → C → 收货点订单,每站提供不同物品。检查顺序、整线距离和金额,逐站资料独立;26 个取货点可配置,27 个取货点应在地图调用前拒绝。关闭开关及非法上限回退 1 后,多点报价/创建/改址拒绝。
+1. 开发者手动执行多站点 SQL,确认上限默认 1;完成所需客户端适配后,将 `flash_pickup_stop_limit` 设置为测试值。单点新订单同样必须有取送站点。
+2. 创建 A → B → C → 收货点订单,每站提供不同物品。检查顺序、整线距离和金额,逐站资料独立;10 个取货点可配置,11 个取货点应在地图调用前拒绝。将上限设为 1 或非法值回退 1 后,多点报价/创建/改址拒绝。
 3. 待接单编辑完整数组,调整顺序后先报价再确认;故意保留旧报价应拒绝保存。收件人绑定、首点摘要、物品、站点数及费用同事务变更;骑手抢单或用户取消竞争时不得部分保存。
 4. 未接单骑手查看详情,逐站联系人、电话、坐标和凭证均不可见;原始交付 PIN 始终不能对骑手返回。接单后详情默认目标为 A。
 5. 上传 B 的图片直接确认取件应拒绝;先向下单人沟通,再以原因、声明、版本和幂等号选择 B。重复目标请求只保留一条记录;缺少原因/声明、过期版本或外单站点拒绝。

+ 20 - 14
specs/024-flash-delivery/spec.md

@@ -311,9 +311,9 @@
 
 **验收场景**:
 
-1. **假如** 多地点开关开启,**当** 用户报价或创建多点订单,**那么** 各站分别校验联系人和物品,按用户填写顺序计算整条路线距离、时长和费用。
+1. **假如** 取货点数量上限大于 1,**当** 用户报价或创建多点订单,**那么** 各站分别校验联系人和物品,按用户填写顺序计算整条路线距离、时长和费用。
 2. **假如** 数量超过字典有效上限,**当** 报价、创建或修改取货点,**那么** 返回国际化错误;字典缺失或非法时有效上限为 1。
-3. **假如** 多地点开关关闭,**当** 报价、创建或编辑提交多于一个取货点,**那么** 拒绝;既有订单履约及仅追加小费不受开关和上限调整影响。
+3. **假如** 取货点数量上限为 1,**当** 报价、创建或编辑提交多于一个取货点,**那么** 拒绝;既有订单履约及仅追加小费不受上限调整影响。
 4. **假如** 订单只有一个取货点,**当** 骑手不传 stopId,**那么** 定位唯一取货点;多点订单未传 stopId 必须拒绝,不自动选择未取货站点。
 5. **假如** 用户顺序为 A → B → C 且均未取件,**当** 骑手查看订单,**那么** 默认突出显示前往 A;骑手取得下单人同意、填写原因并声明已取得同意,保存先前往 B 的调整记录后,可以实际到 B 取货并上传凭证确认;主状态保持 ACCEPTED,全部取货点完成后才进入 PICKED_UP。
 6. **假如** 请求指定非本单站点、收货站点或他人订单站点,**当** 取件确认,**那么** 拒绝;同一骑手重试已确认站点只返回已确认结果,不重复写凭证、时间、日志或推进其他站点。
@@ -334,9 +334,9 @@
 
 - FR-079:通用站点表 flash_delivery_order_stop 保存订单 ID、类型(PICKUP/DELIVERY)、用户填写顺序、快照和履约信息,作为新订单站点数据唯一真相;所有新订单(含单点)统一写入取货和收货站点。订单表 pickup_* 维护首取货点摘要,新增 pickup_stop_count。当前为测试阶段,不做历史订单迁移、无站点履约回退或历史详情适配,不自动清理测试数据。
 - FR-080:每个取货点独立保存联系人、电话、地址、详细地址、市/区、交接方式、经纬度和物品字段 packageType、quantity、weightRange、specification;逐站按既有物品规则校验并供骑手核对。唯一收货点保存收件信息,收件账号绑定和交付 PIN 维持订单级一套。既有订单级物品字段仅保留首取货点兼容摘要,不代表多点订单全部物品,不作为站点物品来源。
-- FR-081:数据字典 flash_pickup_stop_limit 配置取货点上限,不含收货点;仅接受 1 至 26 的整数,缺失、空值或非法配置时有效上限为 1。报价、创建、修改取货点均校验非空且不超过有效上限,超限返回国际化错误;首页下发有效 pickupStopLimit。26 个取货点对应 25 个中途点,超点数在地图调用前拒绝,不作为地图故障降级。
-- FR-082:flash_show_multi_pickup 控制多点能力;关闭时多于一个取货点的报价、创建和地址编辑均拒绝。开关或上限调整不影响已创建订单取件、送达及仅追加小费。
-- FR-083:flash_show_urgent_option 经首页配置下发,仅控制 App 加急选项展示;后端仍按 deliveryType 执行既有校验,不受该展示开关影响。
+- FR-081:数据字典 `flash_pickup_stop_limit` 配置取货点上限,不含收货点;仅接受 1 至 10 的整数,缺失、空值或非法配置时有效上限为 1。报价、创建、修改取货点均校验非空且不超过有效上限,超限返回国际化错误;首页下发有效 `pickupStopLimit`。超点数在地图调用前拒绝,不作为地图故障降级。
+- FR-082:多取货点没有独立开关;报价、创建和地址编辑按 `flash_pickup_stop_limit` 校验数量。上限调整不影响已创建订单取件、送达及仅追加小费。首页兼容字段 `showMultiPickup` 由有效上限是否大于 1 派生。
+- FR-083:加急功能始终可用,不设展示字典开关;首页兼容字段 `showUrgentOption` 固定返回 true。后端继续按 `deliveryType` 执行既有加急校验与计费。
 - FR-084:初始及地址编辑报价按用户填写顺序计算:首取货点为起点,其余取货点为中途点,收货点为终点,不自动优化顺序;地图失败逐段直线距离求和,40 公里上限按整线执行。stop_order 用于报价、展示及默认取件引导;骑手按 FR-095 改变实际取件顺序不重排用户站点、不重算订单金额。
 - FR-085:沿用 FR-004 至 FR-006、FR-070 至 FR-072 的运价、报价回传校验和整数 TWD 取整,以整线距离替代原单段距离,不新增站点附加费或按物品计价项;保留 FR-076 小费只增加小费及总金额,不重算路线、基础配送费或加急费。
 - FR-086:deliveryType=URGENT 允许多个取货点,FR-065、FR-066 骑手独占规则不变。
@@ -415,22 +415,28 @@
 
 **优先级原因**:多点取送是闪送对复杂配送场景的核心能力;物品一次设置贴合真实整车配送并简化填单。
 
-**独立测试**:验证订单级物品一次设置、每站联系人与备注独立、取/收站点上限与开关、整线报价含全部经点、逐站送达确认与凭证幂等、部分送达禁止自助取消、末站完成才推进主状态、按站收件人匹配与送达通知、旧接口过渡期行为不变及退役前置条件。
+**独立测试**:验证订单级物品一次设置、每站联系人与备注独立、取/收站点上限、整线报价含全部经点、逐站送达确认与凭证幂等、部分送达禁止自助取消、末站完成才推进主状态、按站收件人匹配与发起人逐站进度通知、旧接口过渡期行为不变及退役前置条件。
 
 **验收场景**:
 1. **假如**用户在 `stops` 数组按添加顺序填写 2 个取货点和 3 个收货点及订单级物品,**当**请求 v2 报价或创建,**那么**按整条路线(全部经点)计价,站点按添加顺序保存 `stop_order`,备注逐站留存。
-2. **假如**取货点或收货点数量超过字典上限或对应多点开关关闭,**当**请求 v2 报价或创建,**那么**返回国际化错误;既有订单履约和仅追加小费不受影响。
-3. **假如**订单有多个收货站,**当**骑手确认某一站送达,**那么**必须传站点 ID、上传至少一张凭证并幂等重试;全部收货站完成才推进 DELIVERED 并触发完成账单,任一站重复确认不重复写凭证或推进状态。
+2. **假如**取货点或收货点数量超过对应字典上限,**当**请求 v2 报价或创建,**那么**返回国际化错误;既有订单履约和仅追加小费不受影响。
+3. **假如**订单有多个收货站,**当**骑手确认某一站送达,**那么**必须传站点 ID、上传至少一张凭证并幂等重试;全部收货站完成才推进 DELIVERED,随后订单进入 COMPLETED 时才生成一次骑手收入账单,任一站重复确认不重复写凭证或推进状态。
 4. **假如**任一收货站已送达,**当**用户自助取消,**那么**拒绝并要求平台介入;取消与送达并发时按订单行锁内最新事实判定,先成功者生效。
-5. **假如**收货站电话能匹配注册用户,**当**创建订单,**那么**按站固化收件人并向其推送送达进度;匹配不到则该站不固化,仅保留联系人文字。启用收货码时按收货站生成与校验。
+5. **假如**收货站电话能匹配注册用户,**当**创建订单,**那么**按站固化收件人;逐站收件人通知按用户最新要求本阶段不做,后续明确要求时再补充;匹配不到则该站不固化,仅保留联系人文字。启用收货码时按收货站生成与校验。
 6. **假如**客户端仍调用旧报价/创建接口,**当**过渡期内请求,**那么**行为与现状一致并返回废弃标注;退役批次在 App 两端切换并联调通过前不得执行。
 
 - FR-116:v2 报价与创建契约只接受统一站点数组 `stops[]`:取货点与收货点放在同一数组,元素以 `type`(`PICKUP`/`DELIVERY`)区分,包含联系人、电话、完整地址文本、可选详细地址(樓層/室)、必填经纬度及选填备注(不超过 200 字);地址采用单一文本字段(如「台北市大安區信義路三段56號10樓」),不做城市/区结构化拆分,路由与计价以经纬度为准(与 Lalamove 地址模型一致);数组顺序即用户添加顺序,也是骑手路线顺序与站点 `stop_order`,类型是取/送的唯一区别。物品主体(类型、数量、重量段、规格)只在订单级设置一次,新契约不存在逐站物品字段。数组必须至少含 1 个取货站与 1 个收货站,且取货站全部位于收货站之前(先取完再送,与设计稿一致;交错取送属后续增量)。
-- FR-117:取货点和收货点数量上限分别走数据字典 `flash_pickup_stop_limit`、`flash_delivery_stop_limit`(有效值 1..10,字典缺失或非法回退 1),多点开关 `flash_show_multi_pickup`、`flash_show_multi_dropoff` 关闭时只能提交一个对应站点;初始上限 1、开关关闭,App 就绪联调后再放开。
+- FR-117:取货点和收货点数量上限分别走数据字典 `flash_pickup_stop_limit`、`flash_delivery_stop_limit`(有效值 1..10,字典缺失或非法回退 1)。不设独立多点开关;数量超过对应有效上限时拒绝报价、创建及地址编辑。初始上限均为 1,App 就绪联调后可分别调高。首页兼容字段 `showMultiPickup`、`showMultiDropoff` 分别由对应上限是否大于 1 派生。
 - FR-118:v2 运费计算使用独立的新计价方法(不挂接旧报价逻辑),整条路线距离与车型运价规则复用底层实现,车型按订单级重量段判定;旧报价计算方法随 FR-122 退役批次删除;报价路线经点为全部取货站与收货站,整线仍不得超过 40 公里,不新增每站点附加费。
-- FR-119:每个收货站按联系人电话匹配注册普通用户并按站固化收件人,匹配不到不固化且不影响创建;送达推送、取件码/收货码及详情可见性按站判定。取货站仍以收货站电话匹配的收件人为准,不存在按取货站认领。
+- FR-119:每个收货站按联系人电话匹配注册普通用户并按站固化收件人,匹配不到不固化且不影响创建;收货码及详情可见性按站判定;逐站收件人推送暂缓,见 FR-126。取货站仍以收货站电话匹配的收件人为准,不存在按取货站认领。
 - FR-120:送达阶段沿用取件侧状态机规则:主状态 PICKED_UP 后骑手按顺序或经下单人同意调整后逐站确认送达,站点级幂等、凭证入 `flash_delivery_order_image`(`stop_id` 指向收货站);全部收货站完成才推进 DELIVERED,仅推进一次。任一收货站已送达后用户与平台均不能取消(镜像 FR-111/FR-112),异常由客服人工处理。
-- FR-121:逐站送达触发对应收件人推送;启用收货码时每收货站独立生成、校验,不再使用订单级单码。骑手收入账单仍按整单一次记录,不按站点拆分。
-- FR-122:新增 `POST /system/flashDelivery/v2/quote` 与 `POST /system/flashDelivery/v2/orders`;骑手新增 `POST /system/flashDelivery/rider/orders/{id}/stops/{stopId}/deliver`。旧报价/创建接口及旧送达接口自 v2 上线起在契约与代码中标注废弃,过渡期行为不变、不接收新功能;删除为独立收尾批次,前置条件是用户/骑手 App 两端已全部切换 v2 并通过真实环境联调,删除时同步更新契约文档与测试。
-- FR-123:`flash_delivery_order_stop` 增加备注与按站收件人字段,`flash_delivery_order` 增加收货站计数与已送达计数;待执行 SQL 直接修订(多站点增量 SQL 尚未在任何环境执行),无历史订单迁移、不回写旧单。
+- FR-121:逐站收件人通知按 2026-10-14 用户最新要求暂不实施,后续明确要求时再补充;启用收货码时每收货站独立生成、校验,不再使用订单级单码。骑手收入账单仍按整单一次记录,不按站点拆分。
+- FR-122:新增 `POST /system/flashDelivery/v2/quote` 与 `POST /system/flashDelivery/v2/orders`;骑手新增 `POST /system/flashDelivery/rider/orders/stops/deliver`。旧报价/创建接口及旧送达接口自 v2 上线起在契约与代码中标注废弃,过渡期行为不变、不接收新功能;删除为独立收尾批次,前置条件是用户/骑手 App 两端已全部切换 v2 并通过真实环境联调,删除时同步更新契约文档与测试。
+- FR-123:`flash_delivery_order_stop` 增加备注与按站收件人字段,`flash_delivery_order` 增加收货站计数与已送达计数;SQL 按环境区分完整结构与旧版增量路径;2026-10-14 已在测试库执行旧版增量路径,其他环境未执行。无历史订单迁移、不回写旧单。
 - FR-124:平台 PC 与用户/骑手列表摘要以首取站、首收站及站点数展示,详情返回全部站点(含备注与送达状态);响应仍不暴露非参与方的联系人精确信息与内部计价审计。
+
+- FR-125(2026-10-14 用户补充):本次新增的多点取送接口只要带 body,全部业务参数统一放在明确 DTO 内,不与路径或 query 混传;token 仍从请求头读取,GET 简单参数不在此要求内。新逐站送达使用固定路径 `/system/flashDelivery/rider/orders/stops/deliver`,body 必须含正整数 `orderId`、`stopId`,并携带 `pinCode?` 与 `imageUrls`;只有一个收货站时也必须传 `stopId`。已有旧接口(包括 LINE Pay 创建、旧取件/送达/目标调整/改址与平台接口)不因本规则改动;服务层原有状态机与校验继续生效。
+
+- FR-126(2026-10-14 审查修复):v2 每个取货/收货站首次确认成功并提交事务后,向订单发起人推送该站进度并沿既有通道写入消息中心,含站点 ID、站号、类型与已完成/总数量,通知中的订单状态使用真实主状态,末站不重复发送同阶段整单通知。幂等重试、失败或回滚不产生新通知;通知失败不回滚履约。逐站收件人通知不做且不作为当前交付阻塞,后续用户明确要求时再实施。
+- FR-127(2026-10-14 审查修复):旧整单送达与旧改址入口拒绝处理 v2 订单,但旧接口路径、参数和历史订单行为保持;v2 新增全 body 改址报价/保存入口,接收完整 stops,复用运价快照与条件更新,保留订单级物品并原子保存全体站点、计数、按站收件人与 PIN,确认保存后客户端刷新新 stopId/PIN。用户/平台/自动完成均核实全部收货站的真实已送达状态后才允许完成记账。
+- FR-128(2026-10-14 审查修复):发起人可见全部收货站的 PIN,注册收件人仅可见本人的收货站 PIN/联系人/凭证,骑手始终不返回实际 PIN;receiver 列表、详情与确认收货覆盖所有已按站固化的收件人,未注册或未匹配者不增加 App 通知渠道。送达默认指向最早未送达站,改顺序须记录发起人同意;末站成功后即使主状态已为 DELIVERED/COMPLETED,所属骑手重试已确认站仍幂等。

+ 34 - 9
specs/024-flash-delivery/tasks.md

@@ -164,13 +164,13 @@
 
 本阶段按 2026-09-15 用户确认的方式 2 更新,以 FR-094 至 FR-098 替换原无条件自由取件规则。后端、平台 PC 和关联文档已实现并执行本地验证;T097 仅记录需求修订。T095、T099、T100 的真实 MySQL 并发/回滚及 App 联调部分仍待执行,不能用 Mock 测试替代,详细结果见 validation.md。
 
-- [x] T087 记录多取货点规格、计划与任务:多取一送、通用站点表、整线距离计价、每站独立联系人及物品、原取件顺序设计(已由 T097 修订)、用户顺序用于报价展示、加急允许多点、点间无独立距离限制、双开关与数量上限走数据字典
+- [x] T087 记录多取货点规格、计划与任务:多取一送、通用站点表、整线距离计价、每站独立联系人及物品、原取件顺序设计(已由 T097 修订)、用户顺序用于报价展示、加急允许多点、点间无独立距离限制、原设计的双开关已由 T137 取消,现只保留数量上限字典
 - [x] T088 编写并运行多点定向测试确认旧实现缺失:独立物品、输入兼容/冲突、单点可省略及多点必传 stopId、默认按序及记录同意声明后调整、前往与取件分离、重试/并发幂等、取消与取件竞争、末站并发及事务回滚、字典 1..26/非法回退;数据库并发验证与模拟测试分开记录
 - [x] T089 在 updatesql/sql.md 记录站点表(独立联系人、地址、物品、履约信息及索引)、订单 pickup_stop_count、站点凭证关联及三个字典项 SQL;上限初始值 1,只记录不执行,不做历史迁移/回退或测试数据清理
 - [x] T090 新增 FlashDeliveryOrderStop 实体/Mapper/XML,扩展订单映射、首点地址/物品摘要及订单行锁查询;实现多经点请求和逐段直线降级,最多 26 取点/25 中途点及整线 40 公里校验
-- [x] T091 实现逐站物品 pickups[] 报价/创建、旧 pickup 单点适配及冲突拒绝;统一开关/上限/地址/物品校验、站点写入和复算;地址编辑同步完整站点、物品、摘要及收件人绑定,同事务版本保护;小费仅增金额、不查路线
+- [x] T091 实现逐站物品 pickups[] 报价/创建、旧 pickup 单点适配及冲突拒绝;统一上限/地址/物品校验(原开关校验已由 T137 移除)、站点写入和复算;地址编辑同步完整站点、物品、摘要及收件人绑定,同事务版本保护;小费仅增金额、不查路线
 - [x] T092 实现原 pickup 与路径 stopId 接口:单点可省略、多点必传、两处 ID 一致性、按 FR-096 校验当前目标及必要的调整记录、每站凭证、重试无副作用、全部取完才推进 PICKED_UP;多点缺少 stopId 不得动态选择下一未取站,已确认站点重试不受当前目标变化影响
-- [x] T093 实现取件与用户/平台取消共用订单行锁及事务,任意站已取禁止用户自助取消,取消后禁止新取件;补 stops 独立物品/凭证、列表首点摘要及 pickupStopCount、已取数量/总数量与取消条件、逐站脱敏、平台详情及首页有效 pickupStopLimit/双开关
+- [x] T093 实现取件与用户/平台取消共用订单行锁及事务,任意站已取禁止用户自助取消,取消后禁止新取件;补 stops 独立物品/凭证、列表首点摘要及 pickupStopCount、已取数量/总数量与取消条件、逐站脱敏、平台详情及首页有效 pickupStopLimit(原双开关已由 T137 移除)
 - [x] T094 更新 contracts/api.md、data-model.md、quickstart.md、docs/flash-delivery-app-api.md 和现有全部后端语言资源,平台改动同步四语言;明确新旧输入、站点物品、地址编辑及顺序重报价、幂等及取消、默认下一站、调整记录/骑手声明和异常交接;导航到默认或沟通后调整的单站并使用 travelmode=two-wheeler,前往与取件分离,不将本项目方案写成 Lalamove 已核实流程
 - [ ] T095 JDK 21 运行闪送定向测试、模块构建、git diff --check,检查变更范围及编码换行;实际验证取消/取件与末站并发事务,明确未执行的数据库/地图/App 联调及回归,核对暂存范围后中文提交
 - [x] T096 按用户逐项确认修订本三件套:每站独立物品、单点可省略/多点必传 stopId、原自由取件规则(已由 T097 替换)、任意站取货后禁止用户取消及并发保护、不考虑历史订单、字典限制 1..26/非法回退 1;其余采用审核建议,保留旧单点输入、小费不重算和修正导航参数
@@ -252,8 +252,8 @@ Phase 24 本地验证:最终后端 164 类/1140 项测试、0 失败/错误/
 
 用户确认:多取货点+多收货点;每站联系人信息与选填备注;物品主体整单一次设置随订单;使用新的创建/报价接口,旧接口过渡期保留、全部调整完成且 App 切换联调通过后统一删除。本阶段修订 2026-09-14 节的逐站物品模型。
 
-- [x] T124 修订待执行 SQL 与字典:`flash_delivery_order_stop` 加 `remark`、收货站 `receiver_user_id`、按站 `delivery_pin_code`;`flash_delivery_order` 加 `delivery_stop_count`、`delivered_stop_count`;新增 `flash_delivery_stop_limit`(1..10)、`flash_show_multi_dropoff`,初始 1/关。
-- [x] T125 v2 报价与创建:统一 `stops[]` 站点数组(`type` 区分取/送、顺序即路线顺序)、订单级物品+每站备注;校验至少 1 取 1 收且取前收后、上限/开关、整线经点报价、按站收件人匹配;补契约与服务定向测试(RED→GREEN)。
+- [x] T124 修订待执行 SQL 与字典:`flash_delivery_order_stop` 加 `remark`、收货站 `receiver_user_id`、按站 `delivery_pin_code`;`flash_delivery_order` 加 `delivery_stop_count`、`delivered_stop_count`;新增 `flash_delivery_stop_limit`(1..10),初始值 1;后续取消多点独立开关,见 T137。
+- [x] T125 v2 报价与创建:统一 `stops[]` 站点数组(`type` 区分取/送、顺序即路线顺序)、订单级物品+每站备注;校验至少 1 取 1 收且取前收后、数量上限、整线经点报价、按站收件人匹配;补契约与服务定向测试(RED→GREEN)。
 - [x] T126 逐站送达状态机:站点级送达确认端点、凭证与幂等重试、部分送达禁自助取消(用户与平台)、末站推进 DELIVERED 一次;收货码按站生成校验;补并发与门禁测试。
 - [x] T127 展示与推送:平台 PC 站点列表(备注/送达状态)、用户/骑手列表摘要首取首收+计数、逐站送达推送对应收件人;四语文案同步。
 - [x] T128 退役管理:旧报价/创建/送达接口代码与契约标注废弃、行为冻结;在 tasks.md 记录删除批次清单及前置条件(App 两端切 v2 且真实联调通过),本阶段不删除。
@@ -261,7 +261,32 @@ Phase 24 本地验证:最终后端 164 类/1140 项测试、0 失败/错误/
 
 ### Phase 26 实现记录(2026-10-14)
 
-- 后端:`FlashDeliveryV2QuoteRequest/V2CreateRequest/V2StopRequest`、`quoteV2/createV2/deliverAtStop` 及统一站点校验(取前收后、上限与开关、站点去重、备注≤200、订单级物品)已实现;站点表/实体/XML/视图移除 city/area 与逐站物品列,新增 remark、receiver_user_id、delivery_pin_code;订单新增 delivery_stop_count/delivered_stop_count 与 `updateDeliveryFulfilment`(仅在 PICKED_UP 推进);`editablePickups` 单点改址回退改从订单级取物品;上限统一 1..10;home 增加 deliveryStopLimit/showMultiDropoff;i18n 五语言包补 6 个 key;旧 quote/orders/deliver 已标 `@Deprecated`。
-- 定向测试:FlashDelivery*Test + FlashLinePay*Test 全部通过;后端全量回归 1024/1024 通过(JDK 21)(含新增 FlashDeliveryV2Test 5 项:统一站点保存与按站收件人/收货码、取前收后与上限开关拒绝、整线按序报价、逐站送达末站才推进且部分送达禁取消、按站收货码校验)。
-- T127 说明:列表/详情经 StopView 透出备注与送达状态,送达推送暂沿用既有 notifyDelivered 场景(按收件人差异化通知随 App 接入联调);平台 PC 旧列展示不受影响。
-- 未验证:SQL 未执行、真实数据库并发、真实 Google Routes、App 未接入;`@Deprecated` 仅标注未删除,退役批次前置条件见 FR-122。
+- 后端:`FlashDeliveryV2QuoteRequest/V2CreateRequest/V2StopRequest`、`quoteV2/createV2/deliverAtStop` 及统一站点校验(取前收后、数量上限、站点去重、备注≤200、订单级物品)已实现;站点表/实体/XML/视图移除 city/area 与逐站物品列,新增 remark、receiver_user_id、delivery_pin_code;订单新增 delivery_stop_count/delivered_stop_count 与 `updateDeliveryFulfilment`(仅在 PICKED_UP 推进);`editablePickups` 单点改址回退改从订单级取物品;上限统一 1..10;home 增加 deliveryStopLimit/showMultiDropoff(后者现按上限派生);i18n 五语言包补 6 个 key;旧 quote/orders/deliver 已标 `@Deprecated`。
+- 定向测试:FlashDelivery*Test + FlashLinePay*Test 全部通过;后端全量回归 1024/1024 通过(JDK 21)(含新增 FlashDeliveryV2Test 5 项:统一站点保存与按站收件人/收货码、取前收后与上限拒绝、整线按序报价、逐站送达末站才推进且部分送达禁取消、按站收货码校验)。
+- T127 历史说明:当时列表/详情经 StopView 透出备注与送达状态,送达推送沿用既有 notifyDelivered 场景。2026-10-14 Phase 28 已改为发起人逐站进度通知;逐站收件人通知明确暂缓。平台 PC 旧列展示不受影响。
+- 未验证:SQL 未执行、真实数据库并发、真实 Google Routes、App 未接入;`@Deprecated` 仅标注未删除,退役批次前置条件见 FR-122。
+## Phase 27:新增多点取送接口统一 body 参数(2026-10-14,实现中)
+
+- [x] T130 仅新增逐站送达改为固定路径 `/rider/orders/stops/deliver`,独立 DTO 接收 `orderId/stopId/pinCode/imageUrls`,校验必需 ID;v2 报价/创建已满足 body 规则。GET 简单参数及所有已有旧接口(含 LINE Pay)不改,服务层履约行为不改;同步 spec、plan、API 契约与 App 文档,无 SQL 变更。
+- [x] T131 验证新地址 JSON 绑定与业务 ID 传递、缺失/非正数 ID 拒绝、旧接口与 GET 契约保持;JDK 21 定向/后端回归及差异检查,记录未验证 App 联调和提交状态。
+
+## Phase 28:多点取送审查问题修复(2026-10-14,实现中)
+
+- [x] T132 修复按站 PIN 返回/权限及 receiver 列表、详情、确认收货;补收货侧计数展示。
+- [x] T133 旧送达拒绝 v2,完成/记账核实全体已送达;修复末站重试、送达顺序/目标与同意调整。
+- [x] T134 新 v2 全 body 改址报价/保存,复用运价快照与事务保存完整站点;旧改址拒绝 v2、旧接口契约保持。
+- [x] T135 每站首次成功后推送发起人进度并入库,失败/回滚/重试不重复通知,末站不叠发整单通知;五语言文案和测试。
+- [x] T136 JDK 21 定向 269 项通过、后端全量 `ruoyi-admin -am test` 中 admin 1045 项通过、独立复核后隐私定向 52 项通过;最终 `ruoyi-admin -am -DskipTests package` 成功。差异与 CRLF 检查通过。真实数据库/推送设备/App 联调及 SQL 执行未做;当前工作区尚未提交。
+
+### 明确暂缓的功能
+
+2026-10-14 用户要求:逐站收件人通知本阶段不做,仅记录;后续用户明确要求时再补充,当前不作为交付阻塞。这一决定仅针对收件人,不取消发起人逐站进度通知。
+
+## Phase 29:取消多点与加急展示字典开关(2026-10-14,已验证)
+
+- [x] T137 取/收货多站只按上限校验,首页兼容字段由上限派生,加急展示固定为 true;调整定向测试并完成 RED→GREEN。
+- [x] T138 `updatesql/sql.md` 仅保留两项站点上限字典,登记旧开关字典清理 SQL;同步 spec、plan、契约和前端文档,执行 JDK 21 定向测试与差异/换行检查。测试库 SQL 单独执行并验证;App 联调未执行。
+
+Phase 29 验证:先移除测试里的开关桩,定向 41 项出现预期 RED(2 失败、8 错误,均为旧开关拒绝或首页字段不符);实现后定向测试通过。JDK 21 执行 `mvn -q -pl ruoyi-admin -am test`,本轮 admin 报告 136 类/1048 项、0 失败/错误/跳过;`mvn -q -pl ruoyi-admin -am -DskipTests package` 成功。19 个受影响文件保留 CRLF,`git -c core.whitespace=cr-at-eol diff --check` 通过。当时 SQL 与真实 App 联调尚未执行;后续测试库执行结果见 T139,App 联调仍未执行。
+
+- [x] T139 用户明确要求后,连接配置标注的测试站点 `foodie`,核对旧版表结构及 18 条站点旧列数据与订单级数据一致;执行旧版增量 ALTER、两项上限字典初始化和三项废弃字典清理。复查新列/旧列、18 条站点及仅存的两项上限值 1;正式库与 App 联调未执行。

+ 7 - 1
specs/024-flash-delivery/validation.md

@@ -36,7 +36,7 @@ Mock 测试覆盖业务分支、事务注解、订单锁及当前读调用顺序
 
 ## 未验证及上线前事项
 
-- `updatesql/sql.md` 的新表、字段、索引和字典由开发者手动执行,本次未执行任何数据库迁移。上限初始为 1;App 适配及联调后再配置多点开关和上限。
+- `updatesql/sql.md` 的新表、字段、索引和字典由开发者手动执行,本次未执行任何数据库迁移。上限初始为 1;App 适配及联调后再调高对应站点上限。
 - 真实 MySQL 中的取件/取消双向竞争、末站并发、锁等待后的当前读可见性、失败后的完整回滚与 SQL 应用结果未验证;按 quickstart.md 执行。
 - 用户/骑手闪送 App 页面源码不在本次工作区,排序、目标选择、导航、进度与取消入口仍需客户端接入和联调;T099、T100 对应部分继续保留待办。
 - 未调用真实 Google Routes,未部署或切换 Foodie_HCM_Map,未验证真机导航。当前报价扩展既有 Google provider 的有序多经点与整线直线降级。
@@ -51,3 +51,9 @@ Mock 测试覆盖业务分支、事务注解、订单锁及当前读调用顺序
 - 最终使用本机 JDK 21(`C:\Users\99029\.jdks\graalvm-jdk-21.0.12+7.1`)运行 `mvn -pl ruoyi-admin -am test`,本轮 141 个测试类、975 项测试,0 失败、0 错误、0 跳过;原 target 中过期的已删除测试报告不计入。
 - 执行 `mvn -pl ruoyi-admin -am -DskipTests package`,全部参与模块 `BUILD SUCCESS`。独立只读审查后修复唯一低严重度来源过滤问题,检查变更范围、CRLF 与 `git diff --check`(临时 `core.whitespace` 包含 `cr-at-eol`)。
 - 未执行 `user_billing.order_source` 迁移 SQL、真实 MySQL 多连接竞争/失败回滚、App 联调或部署。代码仅对未来成功完成的订单生成账单,不自动补历史账单,不进入钱包/提现。提交与推送状态以 Git 记录为准。
+
+## 2026-10-14 测试库多点取送 SQL 执行核对
+
+- 用户明确要求后,连接 `application-druid.yml` 标注的测试站点 `foodie`(MySQL 5.7.44),执行 `updatesql/sql.md` 的旧版增量补丁、两项上限字典初始化及废弃开关清理;未执行完整结构路径。
+- 执行前 18 条站点旧列内容与对应订单级字段核对;执行后站点仍为 18 条,新增站点 3 列和订单 2 列,旧站点 6 列已删除。字典仅有取货/收货上限各一条,均为 1。
+- 仅验证数据库结构及字典状态;真实 App 联调、线上迁移和真实并发履约未验证。

+ 116 - 109
updatesql/sql.md

@@ -1569,115 +1569,6 @@ ALTER TABLE pos_order
 ```
 
 
-## 2026-09-15 闪送多取货点、逐站凭证和目标调整审计(2026-10-14 修订为多点取送统一契约)
-
-仅记录 SQL,由开发者手动执行。测试阶段不迁移历史订单,不删除任何已有测试数据。
-2026-10-14 修订:按多点取送统一契约调整本段(尚未在任何环境执行,站点表无存量数据可自由调整)——站点表移除城市/区与逐站物品列,新增备注、按站收件人与按站收货码;订单增加收货站计数;字典上限改为 1..10 并新增收货侧开关。站点地址为完整文本加必填经纬度;物品主体在订单级原有字段;旧创建路径同步改为新站点结构。
-
-```sql
-ALTER TABLE flash_delivery_order
-    ADD COLUMN pickup_stop_count INT NULL COMMENT '完整取货站数量,新订单必填',
-    ADD COLUMN picked_up_stop_count INT NOT NULL DEFAULT 0 COMMENT '已确认取货站数量',
-    ADD COLUMN delivery_stop_count INT NULL COMMENT '完整收货站数量,v2新订单必填',
-    ADD COLUMN delivered_stop_count INT NOT NULL DEFAULT 0 COMMENT '已确认送达站数量',
-    ADD COLUMN current_target_stop_id BIGINT NULL COMMENT '骑手当前选择目标,取货后清空恢复原顺序',
-    ADD COLUMN problem_stop_id BIGINT NULL COMMENT '平台介入问题站,允许收货站',
-    ADD COLUMN handling_result VARCHAR(500) NULL COMMENT '平台异常处理结果',
-    ADD COLUMN picked_goods_disposition VARCHAR(500) NULL COMMENT '已取物品去向';
-
-CREATE TABLE flash_delivery_order_stop (
-    id BIGINT NOT NULL AUTO_INCREMENT,
-    order_id BIGINT NOT NULL,
-    stop_type VARCHAR(16) NOT NULL COMMENT 'PICKUP或DELIVERY',
-    stop_order INT NOT NULL COMMENT '用户原始顺序,从0开始,收货站排最后',
-    name VARCHAR(64) NOT NULL,
-    phone VARCHAR(32) NOT NULL,
-    address VARCHAR(255) NOT NULL COMMENT '完整地址文本,不做城市/区拆分',
-    address_detail VARCHAR(255) NULL COMMENT '楼层/室等补充',
-    handoff_method VARCHAR(40) NULL,
-    longitude DECIMAL(12,8) NOT NULL,
-    latitude DECIMAL(12,8) NOT NULL,
-    remark VARCHAR(200) NULL COMMENT '选填备注,告知骑手该站取什么或送什么',
-    receiver_user_id BIGINT NULL COMMENT '收货站按联系人电话匹配的注册用户,匹配不到为空',
-    delivery_pin_code VARCHAR(16) NULL COMMENT '启用收货码时按收货站生成',
-    status VARCHAR(16) NOT NULL DEFAULT 'PENDING',
-    picked_up_at DATETIME(3) NULL,
-    delivered_at DATETIME(3) NULL,
-    create_time DATETIME(3) NOT NULL,
-    PRIMARY KEY (id),
-    UNIQUE KEY uk_flash_stop_order (order_id, stop_order)
-) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='闪送新订单站点唯一真值';
-
-ALTER TABLE flash_delivery_order_image
-    ADD COLUMN stop_id BIGINT NULL COMMENT '实际凭证站点,寄件图片保持订单级',
-    ADD INDEX idx_flash_image_stop (order_id, stop_id, proof_type);
-
-CREATE TABLE flash_delivery_target_adjustment (
-    id BIGINT NOT NULL AUTO_INCREMENT,
-    order_id BIGINT NOT NULL,
-    from_stop_id BIGINT NOT NULL,
-    to_stop_id BIGINT NOT NULL,
-    rider_id BIGINT NOT NULL,
-    client_request_id VARCHAR(64) NOT NULL,
-    order_version INT NOT NULL COMMENT '原请求版本,供幂等重试核对',
-    reason VARCHAR(500) NULL,
-    consent_declared TINYINT(1) NULL COMMENT '仅骑手声明,不等于用户App审批',
-    create_time DATETIME(3) NOT NULL,
-    PRIMARY KEY (id),
-    UNIQUE KEY uk_flash_target_request (order_id, rider_id, client_request_id),
-    KEY idx_flash_target_order (order_id, create_time)
-) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='闪送目标选择与骑手声明审计';
-
-INSERT INTO sys_dict_type (dict_name, dict_type, status, create_by, create_time, remark)
-SELECT '闪送取货点上限', 'flash_pickup_stop_limit', '0', 'admin', NOW(), '整数1至10,缺失或非法回退1'
-WHERE NOT EXISTS (SELECT 1 FROM sys_dict_type WHERE dict_type='flash_pickup_stop_limit');
-INSERT INTO sys_dict_type (dict_name, dict_type, status, create_by, create_time, remark)
-SELECT '闪送多取货点开关', 'flash_show_multi_pickup', '0', 'admin', NOW(), '1开启;仅约束报价、创建和地址编辑'
-WHERE NOT EXISTS (SELECT 1 FROM sys_dict_type WHERE dict_type='flash_show_multi_pickup');
-INSERT INTO sys_dict_type (dict_name, dict_type, status, create_by, create_time, remark)
-SELECT '闪送加急选项展示', 'flash_show_urgent_option', '0', 'admin', NOW(), '1展示;仅控制客户端展示'
-WHERE NOT EXISTS (SELECT 1 FROM sys_dict_type WHERE dict_type='flash_show_urgent_option');
-INSERT INTO sys_dict_data (dict_sort, dict_label, dict_value, dict_type, is_default, status, create_by, create_time)
-SELECT 1, '取货点上限', '1', 'flash_pickup_stop_limit', 'Y', '0', 'admin', NOW()
-WHERE NOT EXISTS (SELECT 1 FROM sys_dict_data WHERE dict_type='flash_pickup_stop_limit');
-INSERT INTO sys_dict_data (dict_sort, dict_label, dict_value, dict_type, is_default, status, create_by, create_time)
-SELECT 1, '多取货点开关', '0', 'flash_show_multi_pickup', 'Y', '0', 'admin', NOW()
-WHERE NOT EXISTS (SELECT 1 FROM sys_dict_data WHERE dict_type='flash_show_multi_pickup');
-INSERT INTO sys_dict_type (dict_name, dict_type, status, create_by, create_time, remark)
-SELECT '闪送收货点上限', 'flash_delivery_stop_limit', '0', 'admin', NOW(), '整数1至10,缺失或非法回退1'
-WHERE NOT EXISTS (SELECT 1 FROM sys_dict_type WHERE dict_type='flash_delivery_stop_limit');
-INSERT INTO sys_dict_type (dict_name, dict_type, status, create_by, create_time, remark)
-SELECT '闪送多收货点开关', 'flash_show_multi_dropoff', '0', 'admin', NOW(), '1开启;仅约束v2报价与创建'
-WHERE NOT EXISTS (SELECT 1 FROM sys_dict_type WHERE dict_type='flash_show_multi_dropoff');
-INSERT INTO sys_dict_data (dict_sort, dict_label, dict_value, dict_type, is_default, status, create_by, create_time)
-SELECT 1, '收货点上限', '1', 'flash_delivery_stop_limit', 'Y', '0', 'admin', NOW()
-WHERE NOT EXISTS (SELECT 1 FROM sys_dict_data WHERE dict_type='flash_delivery_stop_limit');
-INSERT INTO sys_dict_data (dict_sort, dict_label, dict_value, dict_type, is_default, status, create_by, create_time)
-SELECT 1, '多收货点开关', '0', 'flash_show_multi_dropoff', 'Y', '0', 'admin', NOW()
-WHERE NOT EXISTS (SELECT 1 FROM sys_dict_data WHERE dict_type='flash_show_multi_dropoff');
-
--- ============================================================
--- 2026-10-14 增量补丁:仅针对"已执行过 2026-10-14 之前旧版多站点 SQL"的环境
--- (旧版含 city/area 与逐站物品列、无 remark/按站收件人/按站收货码)。
--- 全新环境直接执行上方修订后的建表语句,勿重复执行本补丁。
--- ============================================================
--- ALTER TABLE flash_delivery_order_stop
---     DROP COLUMN city, DROP COLUMN area,
---     DROP COLUMN package_type, DROP COLUMN quantity, DROP COLUMN weight_range, DROP COLUMN specification,
---     ADD COLUMN remark VARCHAR(200) NULL COMMENT '选填备注,告知骑手该站取什么或送什么' AFTER latitude,
---     ADD COLUMN receiver_user_id BIGINT NULL COMMENT '收货站按联系人电话匹配的注册用户,匹配不到为空' AFTER remark,
---     ADD COLUMN delivery_pin_code VARCHAR(16) NULL COMMENT '启用收货码时按收货站生成' AFTER receiver_user_id;
---
--- ALTER TABLE flash_delivery_order
---     ADD COLUMN delivery_stop_count INT NULL COMMENT '完整收货站数量,v2新订单必填',
---     ADD COLUMN delivered_stop_count INT NOT NULL DEFAULT 0 COMMENT '已确认送达站数量';
---
--- 新字典(flash_delivery_stop_limit / flash_show_multi_dropoff)用上方幂等 INSERT 补齐即可。
-INSERT INTO sys_dict_data (dict_sort, dict_label, dict_value, dict_type, is_default, status, create_by, create_time)
-SELECT 1, '加急选项展示', '1', 'flash_show_urgent_option', 'Y', '0', 'admin', NOW()
-WHERE NOT EXISTS (SELECT 1 FROM sys_dict_data WHERE dict_type='flash_show_urgent_option');
-```
-
 ## 2026-09-16 骑手进行中配送任务接单上限
 
 > 用途:分别限制骑手进行中的外卖、闪送和两类任务合计数量。有效值为 1 至 20;缺失、空值、非法、越界或读取异常时服务端保守回退为 1。初始值均为 1;脚本只记录,由开发者手动执行。
@@ -2064,3 +1955,119 @@ WHERE NOT EXISTS(SELECT 1 FROM sys_menu WHERE perms='infouser:riderLinePay:toggl
 -- 旧营销系统(vip_quanyi/sales_promotion 系)已被 promotion_* 新系统替代(spec 008),
 -- 平台管理端三个旧页面不再使用,隐藏菜单入口;代码保留备查,路由仍可直链访问。
 UPDATE sys_menu SET visible = '1' WHERE menu_id IN (2003, 2020, 2118, 2179);
+
+## 2026-10-14 闪送多点取送 SQL 执行说明
+
+本轮审查修复增加了 v2 完整改址、逐站发起人通知、收件人权限与履约状态校验;这些逻辑只使用现有订单表、站点表和推送消息表,**没有新增 DDL**;本次取消三个开关字典,旧环境可按末尾语句清理。多收货站所需的 `delivery_stop_count`、`delivered_stop_count`、站点 `receiver_user_id`、`delivery_pin_code`、`remark` 和目标调整表,已包含在下方 2026-10-14 修订的多点取送 SQL 中。逐站发起人通知沿用现有 `push_message`,无需新表。
+
+请按环境实际执行历史选择下方两种结构变更之一:未执行过多站点 SQL 的环境执行完整结构;已执行过旧版多站点 SQL 的环境执行增量补丁。然后执行共用上限字典语句;已安装旧开关字典的环境再执行末尾清理语句。两种结构变更互斥,不得重复执行。本节末尾记录测试库执行结果;其余环境仍需开发者核对后手动执行。
+
+### 多站点完整结构(原 2026-09-15 方案,2026-10-14 修订)
+
+仅适用于尚未执行过旧版多站点 SQL 的环境。
+2026-10-14 修订:按多点取送统一契约调整本段——站点表移除城市/区与逐站物品列,新增备注、按站收件人与按站收货码;订单增加收货站计数;字典上限改为 1..10 并新增收货点上限。站点地址为完整文本加必填经纬度;物品主体在订单级原有字段;旧创建路径同步改为新站点结构。
+
+```sql
+ALTER TABLE flash_delivery_order
+    ADD COLUMN pickup_stop_count INT NULL COMMENT '完整取货站数量,新订单必填',
+    ADD COLUMN picked_up_stop_count INT NOT NULL DEFAULT 0 COMMENT '已确认取货站数量',
+    ADD COLUMN delivery_stop_count INT NULL COMMENT '完整收货站数量,v2新订单必填',
+    ADD COLUMN delivered_stop_count INT NOT NULL DEFAULT 0 COMMENT '已确认送达站数量',
+    ADD COLUMN current_target_stop_id BIGINT NULL COMMENT '骑手当前选择目标,取货后清空恢复原顺序',
+    ADD COLUMN problem_stop_id BIGINT NULL COMMENT '平台介入问题站,允许收货站',
+    ADD COLUMN handling_result VARCHAR(500) NULL COMMENT '平台异常处理结果',
+    ADD COLUMN picked_goods_disposition VARCHAR(500) NULL COMMENT '已取物品去向';
+
+CREATE TABLE flash_delivery_order_stop (
+    id BIGINT NOT NULL AUTO_INCREMENT,
+    order_id BIGINT NOT NULL,
+    stop_type VARCHAR(16) NOT NULL COMMENT 'PICKUP或DELIVERY',
+    stop_order INT NOT NULL COMMENT '用户原始顺序,从0开始,收货站排最后',
+    name VARCHAR(64) NOT NULL,
+    phone VARCHAR(32) NOT NULL,
+    address VARCHAR(255) NOT NULL COMMENT '完整地址文本,不做城市/区拆分',
+    address_detail VARCHAR(255) NULL COMMENT '楼层/室等补充',
+    handoff_method VARCHAR(40) NULL,
+    longitude DECIMAL(12,8) NOT NULL,
+    latitude DECIMAL(12,8) NOT NULL,
+    remark VARCHAR(200) NULL COMMENT '选填备注,告知骑手该站取什么或送什么',
+    receiver_user_id BIGINT NULL COMMENT '收货站按联系人电话匹配的注册用户,匹配不到为空',
+    delivery_pin_code VARCHAR(16) NULL COMMENT '启用收货码时按收货站生成',
+    status VARCHAR(16) NOT NULL DEFAULT 'PENDING',
+    picked_up_at DATETIME(3) NULL,
+    delivered_at DATETIME(3) NULL,
+    create_time DATETIME(3) NOT NULL,
+    PRIMARY KEY (id),
+    UNIQUE KEY uk_flash_stop_order (order_id, stop_order)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='闪送新订单站点唯一真值';
+
+ALTER TABLE flash_delivery_order_image
+    ADD COLUMN stop_id BIGINT NULL COMMENT '实际凭证站点,寄件图片保持订单级',
+    ADD INDEX idx_flash_image_stop (order_id, stop_id, proof_type);
+
+CREATE TABLE flash_delivery_target_adjustment (
+    id BIGINT NOT NULL AUTO_INCREMENT,
+    order_id BIGINT NOT NULL,
+    from_stop_id BIGINT NOT NULL,
+    to_stop_id BIGINT NOT NULL,
+    rider_id BIGINT NOT NULL,
+    client_request_id VARCHAR(64) NOT NULL,
+    order_version INT NOT NULL COMMENT '原请求版本,供幂等重试核对',
+    reason VARCHAR(500) NULL,
+    consent_declared TINYINT(1) NULL COMMENT '仅骑手声明,不等于用户App审批',
+    create_time DATETIME(3) NOT NULL,
+    PRIMARY KEY (id),
+    UNIQUE KEY uk_flash_target_request (order_id, rider_id, client_request_id),
+    KEY idx_flash_target_order (order_id, create_time)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='闪送目标选择与骑手声明审计';
+
+```
+
+### 已执行旧版多站点 SQL 的环境:增量补丁
+
+仅适用于已建有旧版 `flash_delivery_order_stop`(包含 `city`、`area` 和逐站物品列)且订单表尚无收货站计数字段的环境。执行前核对表结构和旧列数据;删除列会删除对应数据。不要与上方完整结构一起执行。
+
+```sql
+ALTER TABLE flash_delivery_order_stop
+    DROP COLUMN city, DROP COLUMN area,
+    DROP COLUMN package_type, DROP COLUMN quantity, DROP COLUMN weight_range, DROP COLUMN specification,
+    ADD COLUMN remark VARCHAR(200) NULL COMMENT '选填备注,告知骑手该站取什么或送什么' AFTER latitude,
+    ADD COLUMN receiver_user_id BIGINT NULL COMMENT '收货站按联系人电话匹配的注册用户,匹配不到为空' AFTER remark,
+    ADD COLUMN delivery_pin_code VARCHAR(16) NULL COMMENT '启用收货码时按收货站生成' AFTER receiver_user_id;
+
+ALTER TABLE flash_delivery_order
+    ADD COLUMN delivery_stop_count INT NULL COMMENT '完整收货站数量,v2新订单必填',
+    ADD COLUMN delivered_stop_count INT NOT NULL DEFAULT 0 COMMENT '已确认送达站数量';
+```
+
+### 两种环境共用:站点上限字典初始化
+
+结构变更完成后执行以下幂等字典语句。
+
+```sql
+INSERT INTO sys_dict_type (dict_name, dict_type, status, create_by, create_time, remark)
+SELECT '闪送取货点上限', 'flash_pickup_stop_limit', '0', 'admin', NOW(), '整数1至10,缺失或非法回退1'
+WHERE NOT EXISTS (SELECT 1 FROM sys_dict_type WHERE dict_type='flash_pickup_stop_limit');
+INSERT INTO sys_dict_data (dict_sort, dict_label, dict_value, dict_type, is_default, status, create_by, create_time)
+SELECT 1, '取货点上限', '1', 'flash_pickup_stop_limit', 'Y', '0', 'admin', NOW()
+WHERE NOT EXISTS (SELECT 1 FROM sys_dict_data WHERE dict_type='flash_pickup_stop_limit');
+INSERT INTO sys_dict_type (dict_name, dict_type, status, create_by, create_time, remark)
+SELECT '闪送收货点上限', 'flash_delivery_stop_limit', '0', 'admin', NOW(), '整数1至10,缺失或非法回退1'
+WHERE NOT EXISTS (SELECT 1 FROM sys_dict_type WHERE dict_type='flash_delivery_stop_limit');
+INSERT INTO sys_dict_data (dict_sort, dict_label, dict_value, dict_type, is_default, status, create_by, create_time)
+SELECT 1, '收货点上限', '1', 'flash_delivery_stop_limit', 'Y', '0', 'admin', NOW()
+WHERE NOT EXISTS (SELECT 1 FROM sys_dict_data WHERE dict_type='flash_delivery_stop_limit');
+```
+
+### 已执行旧字典 SQL 的环境:清理废弃开关
+
+本次取消多取货点、多收货点及加急展示三个独立开关。若旧环境已安装对应字典,手动执行以下语句清理;全新环境不需要执行。服务端不再读取这些键。执行后刷新管理端字典缓存。
+
+```sql
+DELETE FROM sys_dict_data WHERE dict_type IN ('flash_show_multi_pickup', 'flash_show_multi_dropoff', 'flash_show_urgent_option');
+DELETE FROM sys_dict_type WHERE dict_type IN ('flash_show_multi_pickup', 'flash_show_multi_dropoff', 'flash_show_urgent_option');
+```
+
+### 2026-10-14 测试库执行记录
+
+用户明确要求后,已在 `application-druid.yml` 标注的测试站点 `foodie`(MySQL 5.7.44)按“已执行旧版多站点 SQL”路径执行增量补丁、两项上限字典初始化及废弃开关字典清理;未执行互斥的完整结构语句。执行前核对 18 条旧站点记录:被删除的城市/区域及取货站物品值均可由订单级字段保留。执行后站点仍为 18 条,新增 `remark/receiver_user_id/delivery_pin_code`,删除旧 6 列;订单新增 `delivery_stop_count/delivered_stop_count`。字典仅剩取货/收货上限各一条,值均为 1。正式库及其他环境未执行;真实 App 联调未验证。