فهرست منبع

支持闪送待接单订单修改地址和追加小费

新增地址重报价与确认保存、追加小费功能,沿用订单计价快照,通过状态和版本条件更新防止并发覆盖及重复追加。修改地址和追加小费的订单编号改由请求体 orderId 传入。

同步 App 接口文档、规格、多语言及测试。JDK 21 阶段完整回归 474 项通过;最终入参调整后编译和 72 项定向测试通过,暂存差异检查通过。真实地图、数据库及 App 联调未执行。
qmj 1 روز پیش
والد
کامیت
de5399d741
25فایلهای تغییر یافته به همراه979 افزوده شده و 14 حذف شده
  1. 163 14
      docs/flash-delivery-app-api.md
  2. 29 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/controller/FlashDeliveryUserController.java
  3. 12 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryAddressChangeRequest.java
  4. 17 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryAddressConfirmRequest.java
  5. 3 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryOrderDetailView.java
  6. 3 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryQuoteView.java
  7. 16 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryTipAddRequest.java
  8. 128 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryApplicationService.java
  9. 3 0
      ruoyi-admin/src/main/resources/i18n/messages.properties
  10. 3 0
      ruoyi-admin/src/main/resources/i18n/messages_en_US.properties
  11. 3 0
      ruoyi-admin/src/main/resources/i18n/messages_th_TH.properties
  12. 3 0
      ruoyi-admin/src/main/resources/i18n/messages_vi.properties
  13. 3 0
      ruoyi-admin/src/main/resources/i18n/messages_zh_CN.properties
  14. 3 0
      ruoyi-admin/src/main/resources/i18n/messages_zh_TW.properties
  15. 63 0
      ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/controller/FlashDeliveryControllerContractTest.java
  16. 301 0
      ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryApplicationServiceTest.java
  17. 2 0
      ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryI18nContractTest.java
  18. 8 0
      ruoyi-system/src/main/java/com/ruoyi/system/mapper/flash/FlashDeliveryOrderMapper.java
  19. 31 0
      ruoyi-system/src/main/resources/mapper/flash/FlashDeliveryOrderMapper.xml
  20. 35 0
      ruoyi-system/src/test/java/com/ruoyi/system/mapper/flash/FlashDeliveryMapperXmlTest.java
  21. 79 0
      specs/024-flash-delivery/contracts/api.md
  22. 18 0
      specs/024-flash-delivery/plan.md
  23. 13 0
      specs/024-flash-delivery/quickstart.md
  24. 11 0
      specs/024-flash-delivery/spec.md
  25. 29 0
      specs/024-flash-delivery/tasks.md

+ 163 - 14
docs/flash-delivery-app-api.md

@@ -2,15 +2,18 @@
 
 本文档供用户端 App 和骑手端 App 接入闪送功能使用,以当前后端实现为准。文档只描述接口契约和业务流程,不包含 App 前端实现代码。
 
-> 更新时间:2026-09-07。当前口径已对齐下单设计稿:帮送/帮取与普通/加急正交、统一分时段运价、完整物品信息、费用明细及创建订单报价校验;闪送仍不接入支付。
+> 更新时间:2026-09-09。新增待接单订单修改发件/收件信息与追加小费:修改地址先重报价,用户确认后保存;保存时校验订单状态和版本。闪送仍不接入支付。
 
-本次调整后的下单契约要点:
+当前下单与订单编辑契约要点:
 
 - `serviceType` 只区分 `HELP_SEND`(帮送)和 `HELP_PICKUP`(帮取),不再使用 `URGENT` 作为服务类型。
 - 是否加急由独立字段 `deliveryType=NORMAL/URGENT` 表达;帮送和帮取使用同一套分时段基础运价。
 - 报价和创建订单都必须提交取件方式、物品类别、数量、四档重量范围、规格说明和骑手小费;立即取件使用 `deliveryMode=NOW`,预约取件使用 `deliveryMode=SCHEDULED` 并提交取件时间段。
 - App 必须先报价,再把报价 ID、版本及四个费用字段原样回传创建订单接口。后端重新计算不一致时不会创建订单,而会返回最新报价。
 
+- 本次新增两个业务操作、三个 POST 接口:修改地址预报价 `quoteAddress`、确认保存 `updateAddress`,以及追加小费 `addTip`,详见 7.8—7.10。
+- 三个新增接口仅供订单创建人操作仍未被骑手接走的订单,必须回传用户详情中的 `orderVersion`;追加小费传本次增量 `additionalTipAmount`。
+
 ## 1. 功能范围
 
 当前闪送支持:
@@ -19,7 +22,8 @@
 - 物品类别、数量、设计稿四档重量范围和体积/规格。
 - 基础配送费、距离费、加急费、骑手小费和订单总金额明细。
 - 共享地址簿、路线报价、立即配送和预约配送。
-- 用户发布订单,并按“我发的/我收的”查询订单;发件人可取消,发件人或创建时绑定的收件人可确认收货。
+- 待接单订单修改发件/收件地址、联系人和电话,重新报价并确认保存;追加小费并同步更新总金额。
+- 用户发布订单,并按“我发的/我收的”查询订单;发件人可取消,发件人或当前绑定的收件人可确认收货。
 - 骑手查看待抢订单、抢单、确认取件和确认送达。
 - 可选的四位交付 PIN。
 - 寄件、取件和送达图片凭证。
@@ -192,10 +196,10 @@ WAITING_ACCEPTANCE -> ACCEPTED -> PICKED_UP -> DELIVERED -> COMPLETED
 
 | 值 | 含义 | 下一步 |
 |---|---|---|
-| `WAITING_ACCEPTANCE` | 待骑手接单 | 骑手抢单,或发件人取消 |
+| `WAITING_ACCEPTANCE` | 待骑手接单 | 骑手抢单;发件人可修改取送信息、追加小费或取消 |
 | `ACCEPTED` | 骑手已接单 | 骑手确认取件,或发件人取消 |
 | `PICKED_UP` | 骑手已取件 | 骑手确认送达 |
-| `DELIVERED` | 骑手已送达 | 发件人或创建时绑定的收件人确认收货;超过 24 小时可由系统自动完成 |
+| `DELIVERED` | 骑手已送达 | 发件人或当前绑定的收件人确认收货;超过 24 小时可由系统自动完成 |
 | `COMPLETED` | 已完成 | 终态 |
 | `CANCELLED` | 已取消 | 终态 |
 
@@ -208,7 +212,8 @@ WAITING_ACCEPTANCE -> ACCEPTED -> PICKED_UP -> DELIVERED -> COMPLETED
 3. 调用报价接口,展示服务端返回的路线和费用。
 4. 用户确认后调用创建订单接口。创建时服务端会重新计算路线和金额,最终以创建响应为准。
 5. 使用 `role=sender` 查看“我发的”,使用 `role=receiver` 查看“我收的”,并通过列表或详情刷新状态。当前闪送模块没有 App 实时推送接口。
-6. 仅发件人可在 `WAITING_ACCEPTANCE` 或 `ACCEPTED` 状态取消;发件人和创建时绑定的收件人都可在 `DELIVERED` 状态确认收货。
+6. 在“我发的”待接单详情页,使用最新 `orderVersion` 调用地址预报价与确认保存接口,或调用追加小费接口;每次成功后使用返回的完整详情刷新页面及版本。
+7. 仅发件人可在 `WAITING_ACCEPTANCE` 或 `ACCEPTED` 状态取消;发件人和当前绑定的收件人都可在 `DELIVERED` 状态确认收货。
 
 ### 4.2 骑手端
 
@@ -257,7 +262,7 @@ https://api.example.com/profile/upload/2026/09/01/example.jpg
 
 ### 6.1 下单地址对象
 
-报价和创建订单中的 `pickup`、`delivery` 使用以下结构:
+报价、创建订单及修改订单地址中的 `pickup`、`delivery` 使用以下结构:
 
 ```json
 {
@@ -293,7 +298,8 @@ App 完整详情使用以下订单字段,响应的 `data` 直接返回这些
 
 | 字段 | 类型 | 说明 |
 |---|---|---|
-| `id` | number | 订单主键,后续接口路径使用此值 |
+| `id` | number | 订单主键;确认修改地址和追加小费时,将此值放入请求体 `orderId` |
+| `orderVersion` | integer | 用户详情新增的订单版本;修改地址及追加小费必传,成功后递增。用户列表和骑手响应不返回 |
 | `orderNo` | string | 闪送订单号 |
 | `serviceType` | string | 业务场景:`HELP_SEND` 或 `HELP_PICKUP` |
 | `deliveryType` | string | 配送等级:`NORMAL` 或 `URGENT` |
@@ -387,9 +393,10 @@ App 完整详情使用以下订单字段,响应的 `data` 直接返回这些
 }
 ```
 
+- 用户创建、详情及编辑成功的响应还包含 `orderVersion`;骑手响应不包含。编辑前从用户详情取得最新版本,每次成功后替换本地版本。
 - `senderImageUrls`、`pickupImageUrls` 和 `deliveryImageUrls` 分别表示寄件、取件和送达图片数组。
 - App 详情不返回原始状态日志;页面使用 `status`、`acceptedAt`、`pickedUpAt`、`deliveredAt`、`completedAt` 和 `cancelledAt` 展示进度。
-- `deliveryPinCode` 只向订单发件人和创建时绑定的收件人返回,并且只在启用 PIN 时出现;骑手响应永不返回此字段。
+- `deliveryPinCode` 只向订单发件人和当前绑定的收件人返回,并且只在启用 PIN 时出现;骑手响应永不返回此字段。
 - `rider` 只向用户返回。订单未接单时为 `null`。骑手对象不包含真实电话号码,联系入口使用 `imUserId` 打开 IM 会话,原型中的通话按钮没有接口支撑。
 - 骑手位置只在订单状态为 `ACCEPTED` 或 `PICKED_UP` 时向用户返回,其他状态下经纬度为 `null`。
 
@@ -680,7 +687,7 @@ GET /system/flashDelivery/orders?page=1&size=10&role=sender
 
 仅查询当前用户作为指定角色的订单,按创建时间倒序返回全部状态的订单摘要。接口不接受 `status`、`scene` 或 `serviceType`;响应 `data.records` 中每项为用户列表摘要对象。
 
-`role` 只接受 `sender` 或 `receiver`,不区分大小写;传空值按 `sender` 处理,传其他值返回业务错误。“我收的”完全依据订单创建时固化的收件账号,不会在查询时重新按手机号匹配。
+`role` 只接受 `sender` 或 `receiver`,不区分大小写;传空值按 `sender` 处理,传其他值返回业务错误。“我收的”依据订单当前绑定的收件账号;创建时匹配,确认修改取送信息时重新匹配收件电话,查询时不会重新按手机号匹配。
 
 设计原型中的“待接单、配送中、已完成、已取消”筛选页签当前没有对应查询参数。第一版建议只展示“全部”列表;如需页签,只能基于当前页数据本地过滤,且不应依赖它得到准确的分页总数。
 
@@ -690,7 +697,7 @@ GET /system/flashDelivery/orders?page=1&size=10&role=sender
 GET /system/flashDelivery/orders/{id}
 ```
 
-订单发件人和创建时固化的收件人均可访问。响应 `data` 直接为 App 完整订单字段,包括订单快照、三类图片数组、骑手摘要和可选的交付 PIN,不返回原始状态日志。响应不包含支付状态或支付方式字段,详情页不要展示“已付款”等支付信息。
+订单发件人和当前绑定的收件人均可访问。响应 `data` 直接为 App 完整订单字段,包括订单快照、三类图片数组、骑手摘要和可选的交付 PIN,不返回原始状态日志。响应不包含支付状态或支付方式字段,详情页不要展示“已付款”等支付信息。
 
 ### 7.6 用户取消订单
 
@@ -718,7 +725,141 @@ POST /system/flashDelivery/orders/{id}/cancel
 POST /system/flashDelivery/orders/{id}/confirmReceipt
 ```
 
-无请求体。订单发件人或创建时固化的收件人都可操作;仅 `DELIVERED` 状态可确认,成功后状态变为 `COMPLETED`。
+无请求体。订单发件人或当前绑定的收件人都可操作;仅 `DELIVERED` 状态可确认,成功后状态变为 `COMPLETED`。
+
+### 7.8 修改发件/收件信息:预报价
+
+```http
+POST /system/flashDelivery/orders/{id}/address/quote
+```
+
+后端方法:`quoteAddress`;请求 DTO:`FlashDeliveryAddressChangeRequest`。
+
+请求体:
+
+```json
+{
+  "orderVersion": 2,
+  "pickup": {
+    "name": "王小明",
+    "phone": "0912345678",
+    "address": "台北市信义区市府路1号",
+    "addressDetail": "3楼A室",
+    "city": "台北市",
+    "area": "信义区",
+    "handoffMethod": "请电话联系",
+    "longitude": 121.5645,
+    "latitude": 25.033
+  },
+  "delivery": {
+    "name": "李小华",
+    "phone": "0922222222",
+    "address": "台北市大安区信义路三段56号",
+    "addressDetail": "交前台",
+    "city": "台北市",
+    "area": "大安区",
+    "handoffMethod": "交前台",
+    "longitude": 121.543,
+    "latitude": 25.0337
+  }
+}
+```
+
+| 字段 | 必填 | 说明 |
+|---|---:|---|
+| `orderVersion` | 是 | 用户详情返回的非负整数订单版本,不是 `pricingVersion` |
+| `pickup` | 是 | 完整发件信息,结构和校验见 6.1 |
+| `delivery` | 是 | 完整收件信息,结构和校验见 6.1 |
+
+- 可以只修改一端,也可以两端一起修改;请求始终提交两端完整信息,未修改的一端原样回传。可选字段不传或传 `null` 表示清空。
+- 仅订单创建人可调用,收件人不能调用。订单必须仍为 `WAITING_ACCEPTANCE` 且未分配骑手。
+- 服务端沿用订单原有计价规则和已有小费,重新计算路线、距离、预计时长和费用。后台调价或跨时段不会切换这笔订单的计价规则;仍待接单的预约单即使已到预约时间也可修改。
+- 此接口不保存地址、费用或状态;App 展示报价供用户确认后,再调用 7.9。
+- 修改的是当前订单,公共地址簿不会同步修改。
+
+成功 `data` 使用 7.2 的完整报价结构,并增加 `orderVersion`。以下为需要展示和回传的关键字段示例(费用以实际响应为准):
+
+```json
+{
+  "orderVersion": 2,
+  "distanceMeters": 4500,
+  "distanceSource": "ROUTE",
+  "estimatedDurationSeconds": 900,
+  "baseDeliveryFee": 113,
+  "distanceFee": 23,
+  "urgentFee": 0,
+  "tipAmount": 10,
+  "amount": 123,
+  "currency": "TWD"
+}
+```
+
+`distanceFee` 已包含在 `baseDeliveryFee` 中,总金额为 `baseDeliveryFee + urgentFee + tipAmount`。地址编辑使用本接口报价,不使用 7.2 的新订单报价接口。
+
+### 7.9 修改发件/收件信息:确认保存
+
+```http
+POST /system/flashDelivery/orders/address
+```
+
+后端方法:`updateAddress`;请求 DTO:`FlashDeliveryAddressConfirmRequest`。
+
+订单 ID 不放 URL,必须在请求体中提交 `orderId`(用户订单详情的 `id`,正整数)。同时提交与 7.8 同一次报价对应的完整 `pickup`、`delivery` 和 `orderVersion`,并增加以下必填字段:
+
+| 确认字段 | 类型 | 从地址报价响应取值 |
+|---|---|---|
+| `orderId` | integer | 用户订单详情的 `id`,不是订单号 |
+| `quotedDistanceMeters` | integer | `distanceMeters` |
+| `quotedBaseDeliveryFee` | integer | `baseDeliveryFee` |
+| `quotedDistanceFee` | integer | `distanceFee` |
+| `quotedUrgentFee` | integer | `urgentFee`;普通配送也必须传 0 |
+| `quotedAmount` | integer | `amount` |
+
+例如在上节完整请求体中增加 `orderId=101`、`quotedDistanceMeters=4500`、`quotedBaseDeliveryFee=113`、`quotedDistanceFee=23`、`quotedUrgentFee=0`、`quotedAmount=123`。不要在确认报价后更换地址;更换地址后应重新预报价。
+
+保存规则:
+
+- 服务端再次计算路线及费用,逐项核对上述距离与费用。任一不一致或漏传时,返回 `code=500`、国际化“报价已变化,请重新确认”及 `data` 中的最新完整报价,订单保持原值。App 应重新展示,并等待用户确认后再提交。
+- 只有订单归属、待接单状态、骑手为空及 `orderVersion` 同时匹配,才能原子保存。报价后被骑手接走、被取消或发生其他编辑时,保存会失败。
+- 成功时 `data` 直接返回完整订单详情,含更新后的两端信息、路线、费用和递增后的 `orderVersion`;订单 ID、编号、物品、原计价规则、预约信息和已有小费保持不变。
+- 收件电话重新匹配当前可绑定账号;无法唯一匹配时清空原收件人绑定。原账号不再是当前收件人时,随即失去“我收的”、详情及签收权限;创建人的权限不受影响。
+
+### 7.10 追加骑手小费
+
+```http
+POST /system/flashDelivery/orders/tip
+```
+
+后端方法:`addTip`;请求 DTO:`FlashDeliveryTipAddRequest`。
+
+请求体:
+
+```json
+{
+  "orderId": 101,
+  "orderVersion": 2,
+  "additionalTipAmount": 20
+}
+```
+
+| 字段 | 必填 | 规则 |
+|---|---:|---|
+| `orderVersion` | 是 | 用户详情返回的最新订单版本 |
+| `orderId` | 是 | 用户订单详情的 `id`,正整数;不放 URL |
+| `additionalTipAmount` | 是 | 本次追加的正整数 TWD;小数、零、负数、空值及超出金额范围均拒绝 |
+
+- 仅订单创建人能在待接单、无骑手时追加。金额是本次增量,例如原小费为 10,追加 20 后小费变为 30;不是把小费设为 20。
+- 仅增加 `tipAmount` 和 `amount`,基础配送费、加急费、路线和距离不变。此接口不发起支付或扣款。
+- 成功 `data` 直接返回更新后的完整订单详情,含递增后的 `orderVersion`。App 必须用响应刷新小费、总金额和版本。
+- 同一请求成功后,重复提交旧版本会被拒绝,不会重复加小费。网络超时应先刷新详情核对结果,不能自动换新版本再次追加。
+- 如果地址预报价后追加了小费,先前地址确认使用的版本已过期,需刷新详情并重新获取地址报价。
+
+新增接口通用失败处理:
+
+- 缺失或负数版本:提示提供有效的订单版本,重新读取详情。
+- 旧版本或保存时订单发生变化:提示状态变化,刷新详情后由用户决定后续操作。
+- 已接单、已取消或已有骑手:关闭地址修改和追加小费入口。
+- 收件人及其他用户无权调用:按订单不存在或无权访问处理。
 
 ## 8. 骑手端接口
 
@@ -941,7 +1082,7 @@ POST /system/address/{id}/top
 |---|---|---|
 | token 已过期 | 登录态失效 | 清理登录态并重新登录 |
 | 对应取件时段暂无可用运价 | 立即时刻或预约开始时刻没有匹配统一运价 | 重新选择取件时间或稍后重试 |
-| 报价已变化,请重新确认最新费用 | 运价版本、路线或费用与创建请求回传值不一致 | 用响应 `data` 刷新费用并让用户再次确认 |
+| 报价已变化,请重新确认最新费用 | 创建订单报价不一致,或修改地址的确认距离/费用与复算结果不一致 | 用响应 `data` 刷新费用并让用户再次确认 |
 | 物品数量、重量范围或规格信息不正确 | 数量非正整数、`weightRange` 缺失或不属于四个合法枚举、规格过长 | 返回物品信息页修正 |
 | 取件地址和收件地址不能相同 | 地址文本相同或经纬度完全相同 | 要求用户修改地址 |
 | 配送距离不能超过 40 公里 | 服务端路线距离超限 | 提示当前路线不可下单 |
@@ -950,6 +1091,10 @@ POST /system/address/{id}/top
 | 骑手存在进行中的独占配送任务 | 当前订单或骑手已有急送冲突 | 保留当前页并刷新可接任务 |
 | 系统繁忙,请稍后重试 | Redis 不可用或 3 秒内未取得骑手锁 | 不重复提交,稍后刷新并重试 |
 | 当前订单状态不允许此操作 | 页面状态已过期或重复操作 | 重新拉取订单详情 |
+| 仅待接单且未分配骑手的订单可以修改 | 修改地址/追加小费时已接单、已取消或已有骑手 | 刷新详情并关闭编辑入口 |
+| 请提供有效的订单版本 | 缺失或负数 `orderVersion` | 读取用户详情取得版本 |
+| 订单状态已变化,请刷新后重试 | 编辑使用旧版本,或最终保存时发生接单/取消/其他编辑 | 刷新详情;地址重新报价;小费先核对是否已追加 |
+| 追加小费必须为正整数且不能超出金额范围 | 小数、零、负数、空值或金额溢出 | 修正本次追加金额 |
 | 交付 PIN 不正确 | 骑手提交 PIN 错误 | 保留页面并重新输入 PIN |
 | 闪送订单不存在或无权访问 | ID 不存在或不属于当前账号 | 返回列表并刷新数据 |
 | 凭证图片地址无效 | URL 不是完整 HTTP(S) 地址 | 检查上传结果并补全资源域名 |
@@ -961,6 +1106,10 @@ POST /system/address/{id}/top
 - 创建订单原样回传报价的配置 ID、版本、基础配送费、距离费、加急费和总金额;报价变化时展示最新明细并重新确认。
 - 金额按整数 TWD 展示;`baseDeliveryFee` 已包含 `distanceFee`,距离费只作为基础配送费的明细展示,总金额只按 `baseDeliveryFee + urgentFee + tipAmount` 计算;不显示支付成功、骑手收入、时长费或尖峰费用。
 - 创建订单具备稳定的 `clientRequestId` 重试策略。
+- 待接单详情的“改地址电话”“加小费”仅向订单创建人开放;三个新增接口均携带最新 `orderVersion`。
+- 修改地址先调用 7.8 展示新路线和费用,用户确认后调用 7.9;报价变化必须再次确认,不提前更新本地订单。
+- 追加小费传本次增量,成功后同步展示返回的小费及总额;超时先查询,不自动更换版本重复追加。
+- 报价后接单、取消或追加小费导致版本过期时,旧地址确认必须失败;编辑成功后用返回的版本替换本地版本。
 - 预约时间满足未来三天内、固定 30 分钟的约束。
 - App 使用响应体 `code` 判断成功或失败。
 - 分页列表从 `data.records` 和 `data.total` 取值。
@@ -971,6 +1120,6 @@ POST /system/address/{id}/top
 - 启用 PIN 时,骑手送达必须提交用户端详情显示的四位 PIN。
 - 对抢单冲突、状态变化和重复点击,均以刷新后的服务端订单状态为准。
 - 用户订单列表使用 `role=sender/receiver` 切换“我发的”和“我收的”;状态页签如需展示,仅基于当前页数据本地过滤。
-- “我收的”只展示创建时唯一匹配并固化到订单的账号;不在列表或详情查询时动态认领历史订单。
+- “我收的”按订单当前绑定的账号展示;创建或确认修改取送信息时匹配收件电话,清空或更换绑定后旧收件人不再有访问权;查询时不动态认领历史订单。
 - 发布成功页显示“发布后等待附近骑手接单”,不显示虚构的骑手数量或预计接单时长。
 - 骑手端金额文案统一为“订单金额”,不使用“收益”;联系骑手/用户通过 IM,不提供通话。

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

@@ -68,6 +68,35 @@ public class FlashDeliveryUserController extends BaseController {
         return success(service.userDetail(userId(token), id));
     }
 
+    /** 修改取送信息的预报价,不写订单;沿用该订单计价快照。 */
+    @PostMapping("/orders/{id}/address/quote")
+    @Auth
+    public AjaxResult quoteAddress(@RequestHeader String token, @PathVariable Long id,
+                                   @RequestBody FlashDeliveryAddressChangeRequest request) {
+        return success(service.quoteAddress(userId(token), id, request));
+    }
+
+    /** 确认修改当前订单取送信息;路线或报价变化时返回最新报价。 */
+    @PostMapping("/orders/address")
+    @Auth
+    public AjaxResult updateAddress(@RequestHeader String token,
+                                    @RequestBody FlashDeliveryAddressConfirmRequest request) {
+        try {
+            return success(service.updateAddress(userId(token), request == null ? null : request.getOrderId(), request));
+        } catch (FlashDeliveryQuoteChangedException exception) {
+            return AjaxResult.error(MessageUtils.message("flash.delivery.quote.changed"),
+                    exception.getLatestQuote());
+        }
+    }
+
+    /** 待接单时追加正整数小费,同步增加订单总金额。 */
+    @PostMapping("/orders/tip")
+    @Auth
+    public AjaxResult addTip(@RequestHeader String token,
+                            @RequestBody FlashDeliveryTipAddRequest request) {
+        return success(service.addTip(userId(token), request == null ? null : request.getOrderId(), request));
+    }
+
     /** 在待接单或已接单阶段取消本人订单。 */
     @PostMapping("/orders/{id}/cancel")
     @Auth

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

@@ -0,0 +1,12 @@
+package com.ruoyi.app.flashdelivery.dto;
+
+import lombok.Data;
+
+/** 待接单订单修改取送信息;两端均提交完整地址,未修改的一端原样回传。 */
+@Data
+public class FlashDeliveryAddressChangeRequest {
+    /** 用户订单详情或地址报价返回的订单版本,必填。 */
+    private Integer orderVersion;
+    private FlashDeliveryAddressRequest pickup;
+    private FlashDeliveryAddressRequest delivery;
+}

+ 17 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryAddressConfirmRequest.java

@@ -0,0 +1,17 @@
+package com.ruoyi.app.flashdelivery.dto;
+
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+/** 用户确认修改地址时,原样回传本次地址报价中的距离和费用。 */
+@Data
+@EqualsAndHashCode(callSuper = true)
+public class FlashDeliveryAddressConfirmRequest extends FlashDeliveryAddressChangeRequest {
+    /** 用户订单详情的 id,必填正整数。 */
+    private Long orderId;
+    private Integer quotedDistanceMeters;
+    private Long quotedBaseDeliveryFee;
+    private Long quotedDistanceFee;
+    private Long quotedUrgentFee;
+    private Long quotedAmount;
+}

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

@@ -10,6 +10,9 @@ import java.util.List;
 @Data
 @EqualsAndHashCode(callSuper = true)
 public class FlashDeliveryOrderDetailView extends FlashDeliveryOrderView {
+    /** 用户编辑待接单订单使用的版本;骑手详情不返回。 */
+    @JsonInclude(JsonInclude.Include.NON_NULL)
+    private Integer orderVersion;
     private List<String> senderImageUrls = List.of();
     private List<String> pickupImageUrls = List.of();
     private List<String> deliveryImageUrls = List.of();

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

@@ -7,6 +7,9 @@ import java.math.BigDecimal;
 /** 服务端按当前时段运价生成的闪送报价。 */
 @Data
 public class FlashDeliveryQuoteView {
+    /** 仅修改订单地址的报价返回,确认保存时原样回传。 */
+    @com.fasterxml.jackson.annotation.JsonInclude(com.fasterxml.jackson.annotation.JsonInclude.Include.NON_NULL)
+    private Integer orderVersion;
     private String serviceType;
     private String deliveryType;
     private String deliveryMode;

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

@@ -0,0 +1,16 @@
+package com.ruoyi.app.flashdelivery.dto;
+
+import lombok.Data;
+
+import java.math.BigDecimal;
+
+/** 待接单订单追加小费;同一版本重复请求不会重复增加金额。 */
+@Data
+public class FlashDeliveryTipAddRequest {
+    /** 用户订单详情的 id,必填正整数。 */
+    private Long orderId;
+    /** 用户订单详情返回的订单版本,必填。 */
+    private Integer orderVersion;
+    /** 本次追加金额,必须为正整数新台币。 */
+    private BigDecimal additionalTipAmount;
+}

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

@@ -226,6 +226,133 @@ public class FlashDeliveryApplicationService {
         return participantDetail(order, true, false);
     }
 
+    /** 地址修改先报价,用户确认前不改动订单。 */
+    public FlashDeliveryQuoteView quoteAddress(Long userId, Long orderId,
+                                               FlashDeliveryAddressChangeRequest request) {
+        if (request == null) throw fail("flash.delivery.request.required");
+        FlashDeliveryOrder order = requireEditableOrder(userId, orderId, request.getOrderVersion());
+        return calculateAddressQuote(order, request);
+    }
+
+    @Transactional
+    public FlashDeliveryOrderDetailView updateAddress(Long userId, Long orderId,
+                                                       FlashDeliveryAddressConfirmRequest request) {
+        if (request == null) throw fail("flash.delivery.request.required");
+        FlashDeliveryOrder order = requireEditableOrder(userId, orderId, request.getOrderVersion());
+        FlashDeliveryQuoteView quote = calculateAddressQuote(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);
+        }
+        copyAddress(request.getPickup(), order, true);
+        copyAddress(request.getDelivery(), order, false);
+        order.setReceiverUserId(resolveReceiverUserId(order.getDeliveryPhone()));
+        order.setDistanceMeters(quote.getDistanceMeters());
+        order.setDistanceSource(quote.getDistanceSource());
+        order.setEstimatedDurationSeconds(quote.getEstimatedDurationSeconds());
+        order.setDistanceFee(quote.getDistanceFee());
+        order.setBaseDeliveryFee(quote.getBaseDeliveryFee());
+        order.setUrgentFee(quote.getUrgentFee());
+        order.setAmount(quote.getAmount());
+        Date now = new Date();
+        order.setUpdateTime(now);
+        // 即使上面的查询仍显示待接单,骑手也可能已接走,最终以条件 UPDATE 为准。
+        if (orderMapper.updateWaitingAddress(order, request.getOrderVersion()) != 1) {
+            throw fail("flash.delivery.state.changed");
+        }
+        order.setVersion(order.getVersion() + 1);
+        writeLog(orderId, WAITING_ACCEPTANCE, WAITING_ACCEPTANCE, "USER", userId, "ADDRESS_UPDATED", now);
+        return participantDetail(order, true, false);
+    }
+
+    @Transactional
+    public FlashDeliveryOrderDetailView addTip(Long userId, Long orderId, FlashDeliveryTipAddRequest request) {
+        if (request == null) throw fail("flash.delivery.request.required");
+        FlashDeliveryOrder order = requireEditableOrder(userId, orderId, request.getOrderVersion());
+        if (request.getAdditionalTipAmount() == null || request.getAdditionalTipAmount().signum() <= 0) {
+            throw fail("flash.delivery.tip.additional.invalid");
+        }
+        long tip;
+        long amount;
+        try {
+            // 保留 JSON 原始数值,禁止 1.9 等小数在绑定时被截断为整数小费。
+            long additionalTip = request.getAdditionalTipAmount().longValueExact();
+            tip = Math.addExact(order.getTipAmount(), additionalTip);
+            amount = Math.addExact(order.getAmount(), additionalTip);
+        } catch (ArithmeticException exception) {
+            throw fail("flash.delivery.tip.additional.invalid");
+        }
+        order.setTipAmount(tip);
+        order.setAmount(amount);
+        Date now = new Date();
+        order.setUpdateTime(now);
+        if (orderMapper.updateWaitingTip(order, request.getOrderVersion()) != 1) {
+            throw fail("flash.delivery.state.changed");
+        }
+        order.setVersion(order.getVersion() + 1);
+        writeLog(orderId, WAITING_ACCEPTANCE, WAITING_ACCEPTANCE, "USER", userId, "TIP_ADDED", now);
+        return participantDetail(order, true, false);
+    }
+
+    private FlashDeliveryOrder requireEditableOrder(Long userId, Long orderId, Integer version) {
+        requireUserId(userId);
+        if (orderId == null || orderId <= 0) throw fail("flash.delivery.order.not.found");
+        FlashDeliveryOrder order = requireOrder(orderId);
+        if (!Objects.equals(userId, order.getUserId())) throw fail("flash.delivery.order.not.found");
+        if (!WAITING_ACCEPTANCE.equals(order.getStatus()) || order.getRiderId() != null) {
+            throw fail("flash.delivery.edit.not.allowed");
+        }
+        if (version == null || version < 0) throw fail("flash.delivery.order.version.invalid");
+        if (!Objects.equals(version, order.getVersion())) throw fail("flash.delivery.state.changed");
+        return order;
+    }
+
+    private FlashDeliveryQuoteView calculateAddressQuote(FlashDeliveryOrder order,
+                                                         FlashDeliveryAddressChangeRequest request) {
+        validateAddress(request.getPickup());
+        validateAddress(request.getDelivery());
+        if (sameAddressText(request.getPickup(), request.getDelivery())
+                || request.getPickup().getLatitude().compareTo(request.getDelivery().getLatitude()) == 0
+                && request.getPickup().getLongitude().compareTo(request.getDelivery().getLongitude()) == 0) {
+            throw fail("flash.delivery.address.same");
+        }
+        RouteDistance route = routeService.calculate(
+                new GeoPoint(request.getPickup().getLatitude(), request.getPickup().getLongitude()),
+                new GeoPoint(request.getDelivery().getLatitude(), request.getDelivery().getLongitude()));
+        if (route.distanceMeters() > MAX_DISTANCE_METERS) throw fail("flash.delivery.distance.too.far");
+        // 沿用订单快照,避免地址编辑顺带切换运价或重验已经到时的预约单。
+        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());
+        FlashDeliveryPriceBreakdown breakdown;
+        try {
+            breakdown = pricingCalculator.calculateBreakdown(pricing, route.distanceMeters(),
+                    order.getDeliveryType(), order.getTipAmount());
+        } catch (ArithmeticException exception) {
+            throw fail("flash.delivery.tip.invalid");
+        }
+        FlashDeliveryQuoteRequest quoteRequest = new FlashDeliveryQuoteRequest();
+        quoteRequest.setServiceType(order.getServiceType());
+        quoteRequest.setDeliveryType(order.getDeliveryType());
+        quoteRequest.setDeliveryMode(order.getDeliveryMode());
+        quoteRequest.setScheduledPickupStartAt(order.getScheduledPickupStartAt());
+        quoteRequest.setScheduledPickupEndAt(order.getScheduledPickupEndAt());
+        FlashDeliveryQuoteView view = quoteView(quoteRequest, new QuoteContext(pricing, route, breakdown));
+        view.setOrderVersion(order.getVersion());
+        return view;
+    }
+
     @Transactional
     public void userCancel(Long userId, Long orderId, FlashDeliveryReasonRequest request) {
         FlashDeliveryOrder order = requireOrder(orderId);
@@ -614,6 +741,7 @@ public class FlashDeliveryApplicationService {
                                                            boolean hideSensitiveFields) {
         FlashDeliveryOrderDetailView view = detailOrderView(order, !hideSensitiveFields);
         if (userView) {
+            view.setOrderVersion(order.getVersion());
             view.setDeliveryPinCode(Boolean.TRUE.equals(order.getPinRequired()) ? order.getDeliveryPinCode() : null);
             view.setRider(riderSummary(order));
         }

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

@@ -332,3 +332,6 @@ address.access.denied=Address does not exist or is not accessible
 address.data.invalid=Contact, address, or coordinates are invalid
 flash.delivery.rider.type.not.enabled=当前账号未开通闪送配送
 flash.delivery.rider.exclusive.conflict=The rider has an active exclusive delivery and cannot accept this order
+flash.delivery.edit.not.allowed=仅待接单且未分配骑手的订单可以修改
+flash.delivery.order.version.invalid=请提供有效的订单版本
+flash.delivery.tip.additional.invalid=追加小费必须为正整数且不能超出金额范围

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

@@ -335,3 +335,6 @@ address.access.denied=Address does not exist or is not accessible
 address.data.invalid=Contact, address, or coordinates are invalid
 flash.delivery.rider.type.not.enabled=This account is not enabled for flash delivery orders
 flash.delivery.rider.exclusive.conflict=The rider has an active exclusive delivery and cannot accept this order
+flash.delivery.edit.not.allowed=Only orders awaiting acceptance with no assigned rider can be edited
+flash.delivery.order.version.invalid=Please provide a valid order version
+flash.delivery.tip.additional.invalid=Additional tip must be a positive integer within the supported amount range

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

@@ -336,3 +336,6 @@ address.access.denied=ไม่มีที่อยู่นี้หรือ
 address.data.invalid=ข้อมูลผู้ติดต่อ ที่อยู่ หรือพิกัดไม่ถูกต้อง
 flash.delivery.rider.type.not.enabled=บัญชีนี้ยังไม่ได้เปิดใช้บริการส่งด่วน
 flash.delivery.rider.exclusive.conflict=ไรเดอร์มีงานจัดส่งแบบเฉพาะที่กำลังดำเนินการ จึงยังไม่สามารถรับคำสั่งซื้อนี้ได้
+flash.delivery.edit.not.allowed=แก้ไขได้เฉพาะคำสั่งซื้อที่รอรับงานและยังไม่มีไรเดอร์
+flash.delivery.order.version.invalid=กรุณาระบุเวอร์ชันคำสั่งซื้อที่ถูกต้อง
+flash.delivery.tip.additional.invalid=ทิปเพิ่มเติมต้องเป็นจำนวนเต็มบวกและไม่เกินวงเงินที่รองรับ

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

@@ -335,3 +335,6 @@ address.access.denied=Địa chỉ không tồn tại hoặc không có quyền
 address.data.invalid=Thông tin liên hệ, địa chỉ hoặc tọa độ không hợp lệ
 flash.delivery.rider.type.not.enabled=Tài khoản này chưa mở giao hàng nhanh
 flash.delivery.rider.exclusive.conflict=Tài xế đang có nhiệm vụ giao hàng độc quyền và tạm thời không thể nhận đơn này
+flash.delivery.edit.not.allowed=Chỉ có thể sửa đơn đang chờ nhận và chưa có tài xế
+flash.delivery.order.version.invalid=Vui lòng cung cấp phiên bản đơn hàng hợp lệ
+flash.delivery.tip.additional.invalid=Tiền boa thêm phải là số nguyên dương trong phạm vi số tiền được hỗ trợ

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

@@ -336,3 +336,6 @@ address.access.denied=地址不存在或无权访问
 address.data.invalid=联系人、地址或经纬度信息无效
 flash.delivery.rider.type.not.enabled=当前账号未开通闪送配送
 flash.delivery.rider.exclusive.conflict=骑手存在进行中的独占配送任务,暂时不能接此订单
+flash.delivery.edit.not.allowed=仅待接单且未分配骑手的订单可以修改
+flash.delivery.order.version.invalid=请提供有效的订单版本
+flash.delivery.tip.additional.invalid=追加小费必须为正整数且不能超出金额范围

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

@@ -336,3 +336,6 @@ address.access.denied=地址不存在或無權存取
 address.data.invalid=聯絡人、地址或經緯度資訊無效
 flash.delivery.rider.type.not.enabled=當前賬號未開通闁送配送
 flash.delivery.rider.exclusive.conflict=騎手存在進行中的獨佔配送任務,暫時不能接此訂單
+flash.delivery.edit.not.allowed=僅待接單且未分配騎手的訂單可以修改
+flash.delivery.order.version.invalid=請提供有效的訂單版本
+flash.delivery.tip.additional.invalid=追加小費必須為正整數且不能超出金額範圍

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

@@ -55,6 +55,33 @@ class FlashDeliveryControllerContractTest {
         new SpringUtils().postProcessBeanFactory(originalBeanFactory);
     }
 
+    @Test
+    void waitingOrderEditingEndpointsExistAndUseExplicitAuthenticatedDtos() {
+        java.util.Set<String> paths = new java.util.HashSet<>();
+        for (Method method : FlashDeliveryUserController.class.getDeclaredMethods()) {
+            var post = method.getAnnotation(org.springframework.web.bind.annotation.PostMapping.class);
+            if (post == null) continue;
+            for (String path : post.value()) {
+                if (!java.util.Set.of("/orders/{id}/address/quote", "/orders/address", "/orders/tip").contains(path)) continue;
+                paths.add(path);
+                assertNotNull(method.getAnnotation(Auth.class));
+                assertNotNull(method.getParameters()[0].getAnnotation(RequestHeader.class));
+                int bodyIndex;
+                if (path.endsWith("/quote")) {
+                    bodyIndex = 2;
+                    assertNotNull(method.getParameters()[1].getAnnotation(org.springframework.web.bind.annotation.PathVariable.class));
+                } else {
+                    org.junit.jupiter.api.Assertions.assertEquals(2, method.getParameterCount());
+                    bodyIndex = 1;
+                }
+                assertNotNull(method.getParameters()[bodyIndex].getAnnotation(RequestBody.class));
+                assertTrue(!java.util.Map.class.isAssignableFrom(method.getParameters()[bodyIndex].getType()));
+            }
+        }
+        assertTrue(paths.containsAll(java.util.Set.of("/orders/{id}/address/quote",
+                "/orders/address", "/orders/tip")), "Missing waiting-order editing endpoints");
+    }
+
     @Test
     void appControllersReadIdentityFromTokenAndUseRequestBodies() throws Exception {
         Method create = FlashDeliveryUserController.class.getMethod("create", String.class,
@@ -118,6 +145,42 @@ class FlashDeliveryControllerContractTest {
         assertSame(detail, acceptResult.get(AjaxResult.DATA_TAG));
     }
 
+    @Test
+    void editingEndpointsReturnServiceQuoteAndUpdatedDetails() throws Exception {
+        FlashDeliveryApplicationService service = mock(FlashDeliveryApplicationService.class);
+        var change = new com.ruoyi.app.flashdelivery.dto.FlashDeliveryAddressChangeRequest();
+        var json = new com.fasterxml.jackson.databind.ObjectMapper();
+        var confirmation = json.readValue("{\"orderId\":10,\"orderVersion\":2}",
+                com.ruoyi.app.flashdelivery.dto.FlashDeliveryAddressConfirmRequest.class);
+        var tip = json.readValue("{\"orderId\":10,\"orderVersion\":2,\"additionalTipAmount\":20}",
+                com.ruoyi.app.flashdelivery.dto.FlashDeliveryTipAddRequest.class);
+        FlashDeliveryQuoteView quote = new FlashDeliveryQuoteView();
+        FlashDeliveryOrderDetailView detail = new FlashDeliveryOrderDetailView();
+        when(service.quoteAddress(7L, 10L, change)).thenReturn(quote);
+        when(service.updateAddress(7L, 10L, confirmation)).thenReturn(detail);
+        when(service.addTip(7L, 10L, tip)).thenReturn(detail);
+        var controller = new FlashDeliveryUserController(service);
+        String token = JwtUtil.token("7", "user");
+        assertSame(quote, controller.quoteAddress(token, 10L, change).get(AjaxResult.DATA_TAG));
+        assertSame(detail, controller.updateAddress(token, confirmation).get(AjaxResult.DATA_TAG));
+        assertSame(detail, controller.addTip(token, tip).get(AjaxResult.DATA_TAG));
+    }
+
+    @Test
+    void addressConfirmationReturnsLatestQuoteForAnotherUserConfirmation() {
+        FlashDeliveryApplicationService service = mock(FlashDeliveryApplicationService.class);
+        var input = new com.ruoyi.app.flashdelivery.dto.FlashDeliveryAddressConfirmRequest();
+        input.setOrderId(10L);
+        FlashDeliveryQuoteView latest = new FlashDeliveryQuoteView();
+        latest.setOrderVersion(2);
+        latest.setAmount(123L);
+        when(service.updateAddress(7L, 10L, input)).thenThrow(new FlashDeliveryQuoteChangedException(latest));
+        AjaxResult result = new FlashDeliveryUserController(service)
+                .updateAddress(JwtUtil.token("7", "user"), input);
+        assertSame(latest, result.get(AjaxResult.DATA_TAG));
+        assertTrue(((Number) result.get(AjaxResult.CODE_TAG)).intValue() != 200);
+    }
+
     @Test
     void createReturnsLatestQuoteWhenClientQuoteChanged() {
         FlashDeliveryApplicationService service = mock(FlashDeliveryApplicationService.class);

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

@@ -3,6 +3,10 @@ package com.ruoyi.app.flashdelivery.service;
 import com.baomidou.mybatisplus.core.conditions.Wrapper;
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.ruoyi.app.flashdelivery.dto.FlashDeliveryAddressRequest;
+import com.ruoyi.app.flashdelivery.dto.FlashDeliveryAddressChangeRequest;
+import com.ruoyi.app.flashdelivery.dto.FlashDeliveryAddressConfirmRequest;
+import com.ruoyi.app.flashdelivery.dto.FlashDeliveryTipAddRequest;
+import com.ruoyi.app.flashdelivery.dto.FlashDeliveryQuoteView;
 import com.ruoyi.app.flashdelivery.dto.FlashDeliveryCreateRequest;
 import com.ruoyi.app.flashdelivery.dto.FlashDeliveryProofRequest;
 import com.ruoyi.app.flashdelivery.dto.FlashDeliveryPricingRequest;
@@ -1051,6 +1055,303 @@ class FlashDeliveryApplicationServiceTest {
         return image;
     }
 
+
+    @Test
+    void missingOrInvalidBodyOrderIdIsRejectedBeforeDatabaseAccess() {
+        Fixture fixture = new Fixture();
+        for (Long orderId : java.util.Arrays.asList(null, 0L, -1L)) {
+            ServiceException addressFailure = assertThrows(ServiceException.class,
+                    () -> fixture.service.updateAddress(7L, orderId, new FlashDeliveryAddressConfirmRequest()));
+            ServiceException tipFailure = assertThrows(ServiceException.class,
+                    () -> fixture.service.addTip(7L, orderId, tipRequest(20L, 2)));
+            assertEquals("flash.delivery.order.not.found", addressFailure.getMessage());
+            assertEquals("flash.delivery.order.not.found", tipFailure.getMessage());
+        }
+        org.mockito.Mockito.verifyNoInteractions(fixture.orderMapper);
+    }
+
+    @Test
+    void addressQuoteUsesOrderPricingSnapshotAndDoesNotWrite() {
+        Fixture fixture = new Fixture();
+        FlashDeliveryOrder order = editableOrder(fixture);
+        // 已到预约时间也允许待接单订单改地址,不重新验证创建时的预约窗口。
+        order.setDeliveryMode("SCHEDULED");
+        order.setScheduledPickupStartAt(new Date(1));
+        order.setScheduledPickupEndAt(new Date(1800001));
+        FlashDeliveryQuoteView quote = fixture.service.quoteAddress(7L, 10L, addressChange());
+        assertEquals(4500, quote.getDistanceMeters());
+        assertEquals(900, quote.getEstimatedDurationSeconds());
+        assertEquals(23L, quote.getDistanceFee());
+        assertEquals(113L, quote.getBaseDeliveryFee());
+        assertEquals(10L, quote.getTipAmount());
+        assertEquals(123L, quote.getAmount());
+        assertEquals(2, quote.getOrderVersion());
+        assertEquals(1, quote.getPricingVersion());
+        assertEquals("pickup road", order.getPickupAddress());
+        verify(fixture.pricingMapper, never()).selectAtTime(anyString());
+        verify(fixture.orderMapper, never()).updateWaitingAddress(any(), any());
+        verify(fixture.logMapper, never()).insert(any(com.ruoyi.system.domain.flash.FlashDeliveryOrderLog.class));
+    }
+
+    @Test
+    void addressSaveUpdatesBothEndsRouteFeesAndReceiverTogether() {
+        Fixture fixture = new Fixture();
+        FlashDeliveryOrder order = editableOrder(fixture);
+        when(fixture.userMapper.selectOrdinaryUserIdsByNormalizedPhone("0922222222"))
+                .thenReturn(List.of(88L));
+        when(fixture.orderMapper.updateWaitingAddress(any(), eq(2))).thenReturn(1);
+        FlashDeliveryAddressConfirmRequest input = confirmAddress(fixture);
+        input.getPickup().setName(" new sender ");
+        input.getPickup().setAddressDetail(null);
+        input.getDelivery().setName("new receiver");
+        var view = fixture.service.updateAddress(7L, 10L, input);
+        assertEquals("new sender", view.getPickup().getName());
+        assertEquals("new receiver", view.getDelivery().getName());
+        assertNull(view.getPickup().getAddressDetail());
+        assertEquals(4500, view.getDistanceMeters());
+        assertEquals(123L, view.getAmount());
+        assertEquals(3, view.getOrderVersion());
+        assertEquals(88L, order.getReceiverUserId());
+        assertEquals(10L, order.getTipAmount());
+        assertEquals("WAITING_ACCEPTANCE", order.getStatus());
+        verify(fixture.orderMapper).updateWaitingAddress(order, 2);
+        verify(fixture.logMapper).insert(any(com.ruoyi.system.domain.flash.FlashDeliveryOrderLog.class));
+    }
+
+    @Test
+    void addressChangeClearsFormerReceiverWhenNewPhoneDoesNotMatch() {
+        Fixture fixture = new Fixture();
+        FlashDeliveryOrder order = editableOrder(fixture);
+        order.setReceiverUserId(88L);
+        when(fixture.orderMapper.updateWaitingAddress(any(), eq(2))).thenReturn(1);
+        fixture.service.updateAddress(7L, 10L, confirmAddress(fixture));
+        assertNull(order.getReceiverUserId());
+    }
+
+    @Test
+    void addressQuotePreservesUrgentRulesAndExistingTip() {
+        Fixture fixture = new Fixture();
+        FlashDeliveryOrder order = editableOrder(fixture);
+        order.setDeliveryType("URGENT");
+        var quote = fixture.service.quoteAddress(7L, 10L, addressChange());
+        assertEquals(23L, quote.getUrgentFee());
+        assertEquals(146L, quote.getAmount());
+        assertEquals(10L, quote.getTipAmount());
+    }
+
+    @Test
+    void changedRouteOrUnconfirmedPriceReturnsLatestQuoteWithoutSaving() {
+        Fixture fixture = new Fixture();
+        editableOrder(fixture);
+        FlashDeliveryAddressConfirmRequest input = confirmAddress(fixture);
+        input.setQuotedAmount(1L);
+        var exception = assertThrows(FlashDeliveryQuoteChangedException.class,
+                () -> fixture.service.updateAddress(7L, 10L, input));
+        assertEquals(123L, exception.getLatestQuote().getAmount());
+        input.setQuotedAmount(123L);
+        input.setQuotedDistanceMeters(4499);
+        assertThrows(FlashDeliveryQuoteChangedException.class,
+                () -> fixture.service.updateAddress(7L, 10L, input));
+        input.setQuotedDistanceMeters(4500);
+        input.setQuotedUrgentFee(null);
+        assertThrows(FlashDeliveryQuoteChangedException.class,
+                () -> fixture.service.updateAddress(7L, 10L, input));
+        verify(fixture.orderMapper, never()).updateWaitingAddress(any(), any());
+        verify(fixture.logMapper, never()).insert(any(com.ruoyi.system.domain.flash.FlashDeliveryOrderLog.class));
+    }
+
+    @Test
+    void receiverAndOtherUsersCannotQuoteEditOrAddTip() {
+        Fixture fixture = new Fixture();
+        FlashDeliveryOrder order = editableOrder(fixture);
+        order.setReceiverUserId(88L);
+        for (Long user : List.of(88L, 99L)) {
+            assertThrows(ServiceException.class, () -> fixture.service.quoteAddress(user, 10L, addressChange()));
+            assertThrows(ServiceException.class, () -> fixture.service.updateAddress(user, 10L, new FlashDeliveryAddressConfirmRequest()));
+            assertThrows(ServiceException.class, () -> fixture.service.addTip(user, 10L, tipRequest(5L, 2)));
+        }
+        verify(fixture.routeService, never()).calculate(any(), any());
+        verify(fixture.orderMapper, never()).updateWaitingTip(any(), any());
+    }
+
+    @Test
+    void everyNonWaitingStateAndAssignedRiderRejectsBothChanges() {
+        for (String status : List.of("ACCEPTED", "PICKED_UP", "DELIVERED", "COMPLETED", "CANCELLED")) {
+            Fixture fixture = new Fixture();
+            FlashDeliveryOrder order = editableOrder(fixture);
+            order.setStatus(status);
+            assertThrows(ServiceException.class, () -> fixture.service.quoteAddress(7L, 10L, addressChange()));
+            assertThrows(ServiceException.class, () -> fixture.service.updateAddress(7L, 10L, new FlashDeliveryAddressConfirmRequest()));
+            assertThrows(ServiceException.class, () -> fixture.service.addTip(7L, 10L, tipRequest(5L, 2)));
+        }
+        Fixture fixture = new Fixture();
+        editableOrder(fixture).setRiderId(8L);
+        assertThrows(ServiceException.class, () -> fixture.service.quoteAddress(7L, 10L, addressChange()));
+        assertThrows(ServiceException.class, () -> fixture.service.addTip(7L, 10L, tipRequest(5L, 2)));
+    }
+
+    @Test
+    void staleOrMissingVersionCannotOverwriteNewerOrder() {
+        Fixture fixture = new Fixture();
+        editableOrder(fixture);
+        FlashDeliveryAddressChangeRequest input = addressChange();
+        input.setOrderVersion(1);
+        assertThrows(ServiceException.class, () -> fixture.service.quoteAddress(7L, 10L, input));
+        for (Integer version : java.util.Arrays.asList(null, -1, 1)) {
+            assertThrows(ServiceException.class,
+                    () -> fixture.service.addTip(7L, 10L, tipRequest(5L, version)));
+        }
+        verify(fixture.orderMapper, never()).updateWaitingTip(any(), any());
+    }
+
+    @Test
+    void addressValidationRejectsSameInvalidAndOverDistanceEndpoints() {
+        Fixture fixture = new Fixture();
+        editableOrder(fixture);
+        FlashDeliveryAddressChangeRequest input = addressChange();
+        input.setDelivery(input.getPickup());
+        assertThrows(ServiceException.class, () -> fixture.service.quoteAddress(7L, 10L, input));
+        input.setDelivery(request().getDelivery());
+        input.getPickup().setPhone("");
+        assertThrows(ServiceException.class, () -> fixture.service.quoteAddress(7L, 10L, input));
+        when(fixture.routeService.calculate(any(), any())).thenReturn(new RouteDistance(40001, 1000, "GOOGLE"));
+        assertThrows(ServiceException.class, () -> fixture.service.quoteAddress(7L, 10L, addressChange()));
+        verify(fixture.orderMapper, never()).updateWaitingAddress(any(), any());
+    }
+
+    @Test
+    void riderAcceptanceBetweenReadAndAddressSaveRejectsWriteWithoutLog() {
+        Fixture fixture = new Fixture();
+        editableOrder(fixture);
+        FlashDeliveryAddressConfirmRequest input = confirmAddress(fixture);
+        when(fixture.orderMapper.updateWaitingAddress(any(), eq(2))).thenReturn(0);
+        ServiceException exception = assertThrows(ServiceException.class,
+                () -> fixture.service.updateAddress(7L, 10L, input));
+        assertEquals("flash.delivery.state.changed", exception.getMessage());
+        verify(fixture.logMapper, never()).insert(any(com.ruoyi.system.domain.flash.FlashDeliveryOrderLog.class));
+    }
+
+    @Test
+    void addingTipOnlyIncreasesTipAndTotalAndRejectsSameVersionRetry() {
+        Fixture fixture = new Fixture();
+        FlashDeliveryOrder order = editableOrder(fixture);
+        when(fixture.orderMapper.updateWaitingTip(any(), eq(2))).thenReturn(1);
+        var view = fixture.service.addTip(7L, 10L, tipRequest(20L, 2));
+        assertEquals(30L, view.getTipAmount());
+        assertEquals(132L, view.getAmount());
+        assertEquals(102L, view.getBaseDeliveryFee());
+        assertEquals(3200, view.getDistanceMeters());
+        assertEquals(3, view.getOrderVersion());
+        assertThrows(ServiceException.class, () -> fixture.service.addTip(7L, 10L, tipRequest(20L, 2)));
+        assertEquals(30L, order.getTipAmount());
+        verify(fixture.orderMapper).updateWaitingTip(any(), eq(2));
+        verify(fixture.routeService, never()).calculate(any(), any());
+        verify(fixture.pricingMapper, never()).selectAtTime(anyString());
+    }
+
+    @Test
+    void tipChangeInvalidatesEarlierAddressConfirmation() {
+        Fixture fixture = new Fixture();
+        editableOrder(fixture);
+        FlashDeliveryAddressConfirmRequest confirmation = confirmAddress(fixture);
+        when(fixture.orderMapper.updateWaitingTip(any(), eq(2))).thenReturn(1);
+        fixture.service.addTip(7L, 10L, tipRequest(20L, 2));
+        assertThrows(ServiceException.class, () -> fixture.service.updateAddress(7L, 10L, confirmation));
+        verify(fixture.orderMapper, never()).updateWaitingAddress(any(), any());
+    }
+
+    @Test
+    void additionalTipRejectsFractionalJsonBeforeWriting() throws Exception {
+        Fixture fixture = new Fixture();
+        editableOrder(fixture);
+        when(fixture.orderMapper.updateWaitingTip(any(), eq(2))).thenReturn(1);
+        FlashDeliveryTipAddRequest input = new com.fasterxml.jackson.databind.ObjectMapper().readValue(
+                "{\"orderVersion\":2,\"additionalTipAmount\":1.9}", FlashDeliveryTipAddRequest.class);
+        ServiceException exception = assertThrows(ServiceException.class,
+                () -> fixture.service.addTip(7L, 10L, input));
+        assertEquals("flash.delivery.tip.additional.invalid", exception.getMessage());
+        verify(fixture.orderMapper, never()).updateWaitingTip(any(), any());
+    }
+
+    @Test
+    void additionalTipRejectsJsonAmountBeyondLongRange() throws Exception {
+        Fixture fixture = new Fixture();
+        editableOrder(fixture);
+        FlashDeliveryTipAddRequest input = new com.fasterxml.jackson.databind.ObjectMapper().readValue(
+                "{\"orderVersion\":2,\"additionalTipAmount\":1e100}", FlashDeliveryTipAddRequest.class);
+        ServiceException exception = assertThrows(ServiceException.class,
+                () -> fixture.service.addTip(7L, 10L, input));
+        assertEquals("flash.delivery.tip.additional.invalid", exception.getMessage());
+        verify(fixture.orderMapper, never()).updateWaitingTip(any(), any());
+    }
+
+    @Test
+    void additionalTipRejectsZeroNegativeNullAndOverflow() {
+        Fixture fixture = new Fixture();
+        editableOrder(fixture);
+        for (Long value : java.util.Arrays.asList(null, 0L, -1L, Long.MAX_VALUE)) {
+            assertThrows(ServiceException.class,
+                    () -> fixture.service.addTip(7L, 10L, tipRequest(value, 2)));
+        }
+        verify(fixture.orderMapper, never()).updateWaitingTip(any(), any());
+    }
+
+    @Test
+    void riderAcceptanceBetweenReadAndTipSaveRejectsWriteWithoutLog() {
+        Fixture fixture = new Fixture();
+        editableOrder(fixture);
+        when(fixture.orderMapper.updateWaitingTip(any(), eq(2))).thenReturn(0);
+        assertThrows(ServiceException.class, () -> fixture.service.addTip(7L, 10L, tipRequest(20L, 2)));
+        verify(fixture.logMapper, never()).insert(any(com.ruoyi.system.domain.flash.FlashDeliveryOrderLog.class));
+    }
+
+    private FlashDeliveryOrder editableOrder(Fixture fixture) {
+        FlashDeliveryOrder order = order(10L, "WAITING_ACCEPTANCE");
+        order.setRiderId(null);
+        order.setVersion(2);
+        order.setPricingId(1L);
+        order.setPricingVersion(1);
+        order.setPricingStartTime("00:00");
+        order.setPricingEndTime("24:00");
+        order.setStartingDistance(new BigDecimal("3.00"));
+        order.setStartingFare(90L);
+        order.setDistance(new BigDecimal("1.00"));
+        order.setFreight(15L);
+        when(fixture.orderMapper.selectById(10L)).thenReturn(order);
+        when(fixture.routeService.calculate(any(), any())).thenReturn(new RouteDistance(4500, 900, "GOOGLE"));
+        return order;
+    }
+
+    private FlashDeliveryAddressChangeRequest addressChange() {
+        FlashDeliveryAddressChangeRequest input = new FlashDeliveryAddressChangeRequest();
+        input.setOrderVersion(2);
+        input.setPickup(request().getPickup());
+        input.setDelivery(request().getDelivery());
+        return input;
+    }
+
+    private FlashDeliveryAddressConfirmRequest confirmAddress(Fixture fixture) {
+        FlashDeliveryAddressChangeRequest change = addressChange();
+        FlashDeliveryQuoteView quote = fixture.service.quoteAddress(7L, 10L, change);
+        FlashDeliveryAddressConfirmRequest input = new FlashDeliveryAddressConfirmRequest();
+        input.setOrderVersion(quote.getOrderVersion());
+        input.setPickup(change.getPickup());
+        input.setDelivery(change.getDelivery());
+        input.setQuotedDistanceMeters(quote.getDistanceMeters());
+        input.setQuotedBaseDeliveryFee(quote.getBaseDeliveryFee());
+        input.setQuotedDistanceFee(quote.getDistanceFee());
+        input.setQuotedUrgentFee(quote.getUrgentFee());
+        input.setQuotedAmount(quote.getAmount());
+        return input;
+    }
+
+    private FlashDeliveryTipAddRequest tipRequest(Long amount, Integer version) {
+        FlashDeliveryTipAddRequest input = new FlashDeliveryTipAddRequest();
+        input.setAdditionalTipAmount(amount == null ? null : BigDecimal.valueOf(amount));
+        input.setOrderVersion(version);
+        return input;
+    }
+
     private static class Fixture {
         final FlashDeliveryOrderMapper orderMapper = mock(FlashDeliveryOrderMapper.class);
         final FlashDeliveryPricingMapper pricingMapper = mock(FlashDeliveryPricingMapper.class);

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

@@ -29,6 +29,8 @@ class FlashDeliveryI18nContractTest {
             "flash.delivery.pricing.changed",
             "flash.delivery.delivery.type.invalid", "flash.delivery.item.invalid",
             "flash.delivery.tip.invalid", "flash.delivery.quote.changed",
+            "flash.delivery.edit.not.allowed", "flash.delivery.order.version.invalid",
+            "flash.delivery.tip.additional.invalid",
             "flash.delivery.rider.exclusive.conflict",
             "address.access.denied", "address.data.invalid");
 

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

@@ -20,6 +20,14 @@ public interface FlashDeliveryOrderMapper extends BaseMapper<FlashDeliveryOrder>
      */
     int accept(@Param("id") Long id, @Param("riderId") Long riderId, @Param("now") Date now);
 
+    /** 待接单编辑:同时校验创建人、无骑手和版本,原子更新地址、收件人绑定及路线费用。 */
+    int updateWaitingAddress(@Param("order") FlashDeliveryOrder order,
+                             @Param("expectedVersion") Integer expectedVersion);
+
+    /** 待接单追加小费:版本保护避免并发覆盖与重复加款。 */
+    int updateWaitingTip(@Param("order") FlashDeliveryOrder order,
+                         @Param("expectedVersion") Integer expectedVersion);
+
     /** 按骑手归属和预期状态推进取件或送达,防止越权及并发重复操作。 */
     int transitionByRider(@Param("id") Long id, @Param("riderId") Long riderId,
                           @Param("expectedStatus") String expectedStatus,

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

@@ -83,6 +83,37 @@
           and (delivery_mode = 'NOW' or scheduled_pickup_start_at &lt;= #{now})
     </update>
 
+    <!-- 编辑与抢单竞争时,由同一订单行的原子条件更新决定谁先成功。 -->
+    <update id="updateWaitingAddress">
+        update flash_delivery_order
+        set pickup_name = #{order.pickupName}, pickup_phone = #{order.pickupPhone},
+            pickup_address = #{order.pickupAddress}, pickup_address_detail = #{order.pickupAddressDetail},
+            pickup_city = #{order.pickupCity}, pickup_area = #{order.pickupArea},
+            pickup_handoff_method = #{order.pickupHandoffMethod},
+            pickup_longitude = #{order.pickupLongitude}, pickup_latitude = #{order.pickupLatitude},
+            delivery_name = #{order.deliveryName}, delivery_phone = #{order.deliveryPhone},
+            delivery_address = #{order.deliveryAddress}, delivery_address_detail = #{order.deliveryAddressDetail},
+            delivery_city = #{order.deliveryCity}, delivery_area = #{order.deliveryArea},
+            delivery_handoff_method = #{order.deliveryHandoffMethod},
+            delivery_longitude = #{order.deliveryLongitude}, delivery_latitude = #{order.deliveryLatitude},
+            receiver_user_id = #{order.receiverUserId},
+            distance_meters = #{order.distanceMeters}, distance_source = #{order.distanceSource},
+            estimated_duration_seconds = #{order.estimatedDurationSeconds},
+            distance_fee = #{order.distanceFee}, base_delivery_fee = #{order.baseDeliveryFee},
+            urgent_fee = #{order.urgentFee}, amount = #{order.amount},
+            update_time = #{order.updateTime}, version = version + 1
+        where id = #{order.id} and user_id = #{order.userId}
+          and status = 'WAITING_ACCEPTANCE' and rider_id is null and version = #{expectedVersion}
+    </update>
+
+    <update id="updateWaitingTip">
+        update flash_delivery_order
+        set tip_amount = #{order.tipAmount}, amount = #{order.amount},
+            update_time = #{order.updateTime}, version = version + 1
+        where id = #{order.id} and user_id = #{order.userId}
+          and status = 'WAITING_ACCEPTANCE' and rider_id is null and version = #{expectedVersion}
+    </update>
+
     <!-- 骑手履约同时校验订单归属和预期状态,只有中单骑手能按顺序推进。 -->
     <update id="transitionByRider">
         update flash_delivery_order

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

@@ -18,6 +18,41 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
 
 class FlashDeliveryMapperXmlTest {
 
+    @Test
+    void waitingOrderEditsGuardOwnerStatusRiderAndVersionInOneUpdate() 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();
+        }
+        for (String name : java.util.List.of("updateWaitingAddress", "updateWaitingTip")) {
+            String statement = "com.ruoyi.system.mapper.flash.FlashDeliveryOrderMapper." + name;
+            assertTrue(configuration.hasStatement(statement), "Missing atomic update: " + name);
+            var sql = configuration.getMappedStatement(statement)
+                    .getBoundSql(java.util.Map.of("order", new FlashDeliveryOrder(), "expectedVersion", 2));
+            String text = sql.getSql().replaceAll("\\s+", " ").toLowerCase();
+            String where = text.substring(text.indexOf("where"));
+            assertTrue(where.contains("id = ?"));
+            assertTrue(where.contains("user_id = ?"));
+            assertTrue(where.contains("status = 'waiting_acceptance'"));
+            assertTrue(where.contains("rider_id is null"));
+            assertTrue(where.contains("version = ?"));
+            assertTrue(text.contains("version = version + 1"));
+            var bindings = sql.getParameterMappings().stream().map(item -> item.getProperty()).toList();
+            assertTrue(bindings.containsAll(java.util.List.of("order.id", "order.userId", "expectedVersion")));
+            if (name.equals("updateWaitingAddress")) {
+                assertTrue(text.contains("receiver_user_id = ?"));
+                assertTrue(bindings.containsAll(java.util.List.of("order.pickupPhone", "order.deliveryPhone",
+                        "order.pickupLongitude", "order.deliveryLatitude", "order.distanceMeters",
+                        "order.baseDeliveryFee", "order.urgentFee", "order.amount")));
+            } else {
+                assertFalse(text.contains("pickup_"));
+                assertFalse(text.contains("base_delivery_fee ="));
+            }
+        }
+    }
+
     @Test
     void receiverLookupRequiresUniqueEligibleNormalizedPhoneCandidate() throws Exception {
         String resource = "mapper/infouser/InfoUserMapper.xml";

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

@@ -79,3 +79,82 @@
 本期没有支付、退款、取消费、退回费、结算、骑手收入、代购/垫付、商品金额、违禁品电子确认、骑手放弃或自动派单字段和接口。所有闪送金额保持整数 TWD;创建订单后直接待接单,不存在待支付状态。`deliveryType=URGENT` 为 1 对 1 独占配送,相关外卖与闪送接单入口必须执行跨订单独占检查。骑手小费仅为订单费用明细,不代表已付款。
 
 运价时段使用业务当地时间和 `HH:mm` 格式,采用开始时间包含、结束时间不包含的匹配语义;`24:00` 只允许作为结束时间,跨午夜时段必须拆成两个配置。全部时间段不得重叠。起送距离和计价距离必须为正数,起送价格和计价金额必须为正整数,加急比例和最低加急费必须为非负数;立即订单按当前时刻、预约订单按预约开始时刻匹配,目标时刻没有匹配时段时报“对应取件时段暂无可用运价”。
+
+## 2026-09-09:待接单订单修改取送信息与追加小费
+
+以下接口均要求 header `token`,仅订单创建人可调用。订单必须为 `WAITING_ACCEPTANCE` 且无骑手。用户详情新增 `orderVersion`;这是修改时必须回传的订单版本,与运价版本 `pricingVersion` 不同。
+
+| 方法 | 路径 | 用途 |
+|---|---|---|
+| POST | `/system/flashDelivery/orders/{id}/address/quote` | 修改地址预报价,不更新订单 |
+| POST | `/system/flashDelivery/orders/address` | 用户确认报价后保存地址、路线和费用 |
+| POST | `/system/flashDelivery/orders/tip` | 追加小费并更新总金额 |
+
+### 地址预报价
+
+请求 DTO:`FlashDeliveryAddressChangeRequest`。必须提交两端完整地址;只改发件或收件一端时,另一端按订单详情原样回传。地址簿数据不随之更新。
+
+```json
+{
+  "orderVersion": 2,
+  "pickup": {
+    "name": "寄件人", "phone": "0911111111", "address": "台北市中山區取件地址",
+    "addressDetail": "3樓", "city": "台北市", "area": "中山區", "handoffMethod": "到門口取件",
+    "latitude": 25.0100, "longitude": 121.5100
+  },
+  "delivery": {
+    "name": "收件人", "phone": "0922222222", "address": "台北市大安區新收件地址",
+    "addressDetail": "交前台", "city": "台北市", "area": "大安區", "handoffMethod": "交前台",
+    "latitude": 25.0200, "longitude": 121.5200
+  }
+}
+```
+
+`name/phone/address/latitude/longitude` 必填,其余地址字段可空;取送主地址加详细地址不能完全相同,坐标不能相同,路线不得超过 40 公里。
+
+成功 `data` 为既有 `FlashDeliveryQuoteView` 加 `orderVersion`,包括 `distanceMeters/distanceSource/estimatedDurationSeconds/baseDeliveryFee/distanceFee/urgentFee/tipAmount/amount` 及原订单计价快照。即时单和预约单都沿用原订单运价,不因后台调价或跨时段切换价格;已有小费保留。此接口不更改原订单和状态日志。
+
+### 确认保存地址
+
+请求 DTO:`FlashDeliveryAddressConfirmRequest`。订单 ID 改为请求体必填的正整数 `orderId`,不放 URL;提交上述完整地址、同一 `orderVersion`,并回传本次报价字段:
+
+```json
+{
+  "orderId": 101,
+  "orderVersion": 2,
+  "pickup": {"name":"寄件人","phone":"0911111111","address":"取件地址","latitude":25.0100,"longitude":121.5100},
+  "delivery": {"name":"收件人","phone":"0922222222","address":"新收件地址","latitude":25.0200,"longitude":121.5200},
+  "quotedDistanceMeters": 4500,
+  "quotedBaseDeliveryFee": 113,
+  "quotedDistanceFee": 23,
+  "quotedUrgentFee": 0,
+  "quotedAmount": 123
+}
+```
+
+金额仅为示例,客户端必须使用实际报价;`distanceFee` 已包含于 `baseDeliveryFee`,总金额为 `baseDeliveryFee + urgentFee + tipAmount`。
+
+保存前服务端重新计算路线和价格。距离或任一确认金额不同(包括漏传)时,返回非成功 AjaxResult,`msg` 为国际化 `flash.delivery.quote.changed`,`data` 为最新完整报价,订单不变。客户端展示最新报价,经用户再次确认后重新提交,不能自动按新报价扣改金额。
+
+成功返回直接订单详情 DTO(包括新地址、路线、费用和递增后的 `orderVersion`)。修改收件电话后重新匹配收件账号,无法唯一匹配时清空旧账号绑定,原收件人随即失去该订单的查询与签收权限。保持订单 ID、编号、状态、原运价、物品、预约和已有小费。
+
+### 追加小费
+
+请求 DTO:`FlashDeliveryTipAddRequest`:
+
+```json
+{"orderId": 101, "orderVersion": 2, "additionalTipAmount": 20}
+```
+
+`orderId` 必填,为用户订单详情的正整数 `id`,不放 URL。
+
+`additionalTipAmount` 表示本次增量,必须为正整数 TWD,不是小费最终总额。小费由 10 变为 30 时传 20;总金额同时增加 20,基础配送费、加急费和路线不变。零、负数、空值及金额溢出拒绝。成功返回更新后的直接订单详情和递增版本。此操作仍属于非支付订单金额调整,不产生支付或结算。
+
+### 状态、并发与重试
+
+- 创建人以外的用户(包括已绑定收件人)请求返回订单不存在;状态不是待接单或已有骑手返回 `flash.delivery.edit.not.allowed`。
+- 缺失或负数版本返回 `flash.delivery.order.version.invalid`;旧版本或最终条件 UPDATE 未命中返回 `flash.delivery.state.changed`。
+- 最终 UPDATE 同时匹配订单 ID、创建人、待接单、无骑手和版本;接单、取消、另一次地址修改或追加小费先成功时,本次不得覆盖。
+- 同一小费请求成功后重发旧版本会被拒绝,不会重复加款。网络超时应刷新详情核对小费和总金额,不应自动换新版本重复追加。
+- 地址报价后如追加过小费,旧地址确认失效;刷新详情、重新报价并让用户确认。
+- 当前取消规则、支付和退款范围不变;无新增数据库字段或迁移 SQL。

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

@@ -683,3 +683,21 @@ The staged list must not contain `.claude/homunculus/observations.jsonl`、`.tmp
 - 报价和创建订单仍要求目标时刻只匹配一个时段,异常多条匹配时明确失败,不使用 `LIMIT 1` 隐藏数据问题。
 - 业务接受低频后台并发新增极低概率同时通过重叠检查;不为该风险保留专用数据库表。
 - 在 `updatesql/sql.md` 追加删表语句,仅供开发者手动执行,不连接数据库。
+
+## 2026-09-09:待接单订单编辑实施计划
+
+**目标**:为已创建、未被骑手接走的订单提供地址重报价、确认保存及追加小费。
+**实现**:在既有 FlashDeliveryApplicationService 和 FlashDeliveryUserController 增量实现;复用订单计价快照、路线服务、价格计算器、收件人匹配与日志。新增明确 DTO;不改创建、接单与取消业务规则。
+**约束**:JDK 21;token 请求头;显式 RequestBody;服务端业务校验与多语言错误;保留文件编码换行;不执行数据库操作。
+
+1. 补 Controller 端点契约及 Mapper 原子条件测试,运行 JDK 21 定向测试确认缺失能力。
+2. 新增 AddressChangeRequest、AddressConfirmRequest、TipAddRequest ,地址报价复用 FlashDeliveryQuoteView。订单用户详情返回 orderVersion。
+3. 新增 POST /orders/{id}/address/quote、POST /orders/address、POST /orders/tip。地址报价/保存接收完整 pickup/delivery;保存额外校验 quotedDistanceMeters、quotedBaseDeliveryFee、quotedDistanceFee、quotedUrgentFee、quotedAmount。报价响应含 orderVersion。
+4. 地址重算直接使用原订单 pricingId/pricingVersion、起送、里程、加急规则快照与 tipAmount;不重验已创建订单的预约时间,不读取当前后台运价。收件电话变化后重绑 receiverUserId。
+5. Mapper 新增 updateWaitingAddress(order, expectedVersion) 与 updateWaitingTip(order, expectedVersion),WHERE 校验 id/userId/status/rider_id/version。SQL 显式赋值可空地址字段及 receiver_user_id。小费用 BigDecimal 接收 JSON 原数值,在 Service 经 longValueExact 校验正整数后按原值加增量,Math.addExact 防溢出;版本条件阻止重复加款。
+6. 补服务测试:报价不落库、单端/双端地址、收件人重绑/清空、费用确认、距离上限、跨用户、已接走及状态竞争、版本冲突、重复请求、小费非法与溢出。补 API 成功与报价变化响应、SQL 绑定及六套现有语言资源契约。
+7. 更新 contracts/api.md 与 tasks.md,运行定向测试、受影响模块编译和后端回归;检查 diff。真实地图/数据库/App 联调单列未验证。
+
+### 2026-09-09:编辑操作订单 ID 改为请求体
+
+按用户要求,仅调整确认修改地址和追加小费:在 FlashDeliveryAddressConfirmRequest、FlashDeliveryTipAddRequest 添加 orderId;Controller 两个方法只接 token 与 RequestBody DTO,通过 DTO 将订单 ID 传给既有 Service。requireEditableOrder 在查询前拒绝空/非正订单 ID。同步端点契约、JSON 绑定/身份传递测试、App 文档及定向验证,原有费用和并发业务规则保持不变。

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

@@ -9,3 +9,16 @@
 7. 发布成功页使用静态文案“发布后等待附近骑手接单”,不展示动态附近骑手数或承诺接单时间。闪送不接入支付,金额保持整数 TWD。
 8. 用户 `/confirmReceipt` 完成;送达满 24 小时未操作则自动完成。
 9. 完整字段见 [API 契约](contracts/api.md)。
+
+## 待接单编辑冒烟(2026-09-09)
+
+1. 以创建人 token 获取一笔待接单订单详情及 orderVersion;修改发件或收件地址,调用 POST /system/flashDelivery/orders/{id}/address/quote。核对新路线距离与费用,重新查询订单确认尚未改动。
+2. 在请求体提交 orderId,回传相同地址、orderVersion 和报价距离/费用到 POST /system/flashDelivery/orders/address;核对两端信息、收件账号归属、路线和总额同步更新,版本加一;清空可选详细地址时数据库也应为空。
+3. 故意篡改 quotedAmount 或 quotedDistanceMeters,确认返回最新报价且订单无修改;用户再次确认后按最新值保存。
+4. 用详情最新版本调用 POST /system/flashDelivery/orders/tip,请求体提交 orderId 和 additionalTipAmount=20;核对小费及总金额均增加 20,基础/加急费不变;原请求重放不得再次增加。
+5. 在地址报价后由另一请求追加小费,再提交旧地址确认;验证版本冲突,不覆盖新小费。
+6. 分别让骑手抢单、用户取消发生在编辑前或报价与保存之间;验证地址保存与追加小费均不能更新已接单/已取消订单。并发请求压测确认数据库条件更新仅按可用版本生效。
+7. 收件人及无关用户请求三个端点均应被拒绝;缺失版本、非法地址、同址、超 40 公里、零/负小费及超大金额均应拒绝。
+8. 后台调价后编辑既有订单,核对使用原订单运价快照;预约时间已到但仍待接单的订单也可编辑。
+
+以上涉及真实数据库、地图与 App 的接口冒烟需在开发联调环境执行,自动化测试使用模拟外部依赖,不代替实际联调。

+ 11 - 0
specs/024-flash-delivery/spec.md

@@ -284,3 +284,14 @@
 - 不实现自动派单、抢单推送筛选、骑手在线状态计算或接单时间预测。
 - 不修改餐饮订单 `pos_order` 或打车订单 `taxi_order` 的业务语义。
 - 不在本功能中实现用户端、骑手端或商家端页面;本轮前端增量仅实现平台管理端。
+
+## 2026-09-09:待接单订单修改地址与追加小费
+
+- FR-073:仅订单创建人可在 WAITING_ACCEPTANCE 且 rider_id 为空时修改当前订单的发件和收件信息(联系人、电话、地址、详细地址、交接方式和坐标);两端完整地址一并提交,允许只改变其中一端。共享地址簿不随订单修改。
+- FR-074:地址修改先获取专属报价,服务端沿用订单计价快照与已有小费重新计算路线、距离、预计时长及费用;报价不写订单。确认时回传订单版本、报价距离及各项费用,服务端复算,一致后才原子保存;变化时返回最新报价供再次确认。
+- FR-075:修改收件电话后重新匹配已注册收件人,未匹配时清空旧绑定;同步更新取送地址、路线与费用,保留订单编号、原计价配置、物品及预约信息。
+- FR-076:追加小费提交正整数 TWD additionalTipAmount 和 orderVersion;仅增加小费及总金额,不重算基础配送费、加急费或路线。溢出拒绝。
+- FR-077:保存地址和追加小费必须在单条 UPDATE 中同时校验创建人、待接单、无骑手及订单版本,成功后版本递增。被接走、被取消、并发编辑或重复提交不得覆盖或重复加小费。
+- 本次只新增以上能力;沿用现有取消规则、非支付边界。复用现有字段与 version,无数据库迁移。
+
+- FR-078(2026-09-09 入参调整):确认修改地址与追加小费使用 POST /orders/address 和 POST /orders/tip;订单 ID 通过 JSON 请求体 orderId 传入,必须是有效正整数,token 仍放请求头。

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

@@ -131,3 +131,32 @@
 - [x] T077 将请求、订单实体、MyBatis 映射和用户/骑手/详情响应统一改为 `weightRange`,删除精确重量和三档尺寸派生逻辑
 - [x] T078 更新人工迁移 SQL、现有规格和 App 接入文档,完整说明报价与创建订单入参
 - [x] T079 使用 JDK 21 运行定向测试(50 项)、模块编译、完整回归(401 项)和差异检查,全部通过;迁移 SQL 未执行
+
+## Phase 16:待接单订单修改地址与追加小费
+
+- [x] T080 记录已确认范围、接口和原子保存方案,沿用订单计价快照
+- [x] T081 补端点及条件更新测试并确认旧实现红灯
+- [x] T082 实现地址报价/确认保存、追加小费、订单版本及六语言错误
+- [x] T083 补归属、收件人绑定、费用确认、并发接单/修改/重复请求和金额边界测试
+- [x] T084 更新 App 接口契约,执行 JDK 21 定向测试、模块编译、后端回归和差异检查
+
+### Phase 16 验证记录(2026-09-09)
+
+- 首轮契约红灯:新增 Controller 和 Mapper 断言均因缺失接口/条件更新而失败;为一次收集两模块预期失败,红灯命令使用 maven.test.failure.ignore=true,已核对实际失败报告。
+- 定向测试:JDK 21 运行 FlashDeliveryApplicationServiceTest、FlashDeliveryControllerContractTest、FlashDeliveryMapperXmlTest、FlashDeliveryI18nContractTest、FlashDeliveryOrderViewTest,75 项通过。
+- 金额审查修复:新增 JSON additionalTipAmount=1.9 测试,确认 Long 绑定曾被截断且写入成功;改用 BigDecimal + longValueExact 后拒绝小数及 1e100,均覆盖不写库断言。
+- 最终回归:JDK 21 执行 `mvn -B -pl ruoyi-admin -am test`,ruoyi-system 54 项、ruoyi-admin 420 项,共 474 项通过,0 失败/错误/跳过,所有参与模块 BUILD SUCCESS。
+- 只读代码审查通过;git diff --check 通过,未发现无关修改或整文件换行重写。
+- 未执行真实数据库、地图及 App 接口联调;本次无需数据库迁移,未执行 SQL。提交范围为本功能代码、测试及文档。
+
+## Phase 17:编辑操作订单 ID 改为 JSON 入参
+
+- [x] T085 将确认修改地址和追加小费改为固定 URL + 请求体 orderId,更新契约/边界测试并确认旧路径红灯
+- [x] T086 同步 App 文档、规格、接口示例,执行 JDK 21 定向测试与差异检查
+
+### Phase 17 验证记录(2026-09-09)
+
+- 新端点契约测试先确认旧路径红灯;随后固定 URL 和 JSON orderId 绑定、无效 ID 拦截测试通过。
+- JDK 21 执行 `mvn -B -pl ruoyi-admin -am -Dtest=FlashDeliveryControllerContractTest,FlashDeliveryApplicationServiceTest -Dsurefire.failIfNoSpecifiedTests=false test`:72 项通过,所有参与模块编译成功。
+- `docs/flash-delivery-app-api.md` 已同步,JSON 示例解析及差异检查通过,保留 CRLF。
+- 本轮 URL/入参调整后未重跑完整回归,前一阶段完整回归为 474 项通过;真实地图、数据库及 App 联调仍未执行。