Sfoglia il codice sorgente

Merge branch 'test' into test-202609v2

冲突解决:i18n 五个语言文件与 sql.md 取两边追加内容并集;
PosOrderQsOprateControllerTest 保留 requireRider 注释并补 setDeliveryType("FOOD");
observations.jsonl 两边日志行并集。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
qmj 42 minuti da adesso
parent
commit
95d915723d
76 ha cambiato i file con 4667 aggiunte e 344 eliminazioni
  1. 32 0
      .claude/homunculus/observations.jsonl
  2. 399 64
      docs/flash-delivery-app-api.md
  3. 409 0
      docs/superpowers/plans/2026-09-07-backend-thai-i18n.md
  4. 109 0
      docs/superpowers/plans/2026-09-07-remove-flash-pricing-lock.md
  5. 72 0
      docs/superpowers/specs/2026-09-07-backend-thai-i18n-design.md
  6. 33 0
      docs/superpowers/specs/2026-09-07-remove-flash-pricing-lock-design.md
  7. 11 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/controller/FlashDeliveryAdminController.java
  8. 10 1
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/controller/FlashDeliveryRiderController.java
  9. 48 4
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/controller/FlashDeliveryUserController.java
  10. 12 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryAddressChangeRequest.java
  11. 17 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryAddressConfirmRequest.java
  12. 17 1
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryAddressRequest.java
  13. 28 7
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryCreateRequest.java
  14. 1 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryDeliverRequest.java
  15. 2 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryHomeView.java
  16. 3 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryOrderDetailView.java
  17. 8 1
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryOrderView.java
  18. 3 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryPriceBreakdown.java
  19. 20 2
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryPricingRequest.java
  20. 4 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryProofRequest.java
  21. 56 2
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryQuoteRequest.java
  22. 12 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryQuoteView.java
  23. 1 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryReasonRequest.java
  24. 8 1
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryRiderOrderListView.java
  25. 2 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryServiceView.java
  26. 16 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryTipAddRequest.java
  27. 8 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryUserOrderListView.java
  28. 23 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/InfoAddressRequest.java
  29. 18 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/exception/FlashDeliveryQuoteChangedException.java
  30. 311 64
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryApplicationService.java
  31. 22 3
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryPricingCalculator.java
  32. 19 3
      ruoyi-admin/src/main/java/com/ruoyi/app/order/InfoAddressController.java
  33. 27 2
      ruoyi-admin/src/main/java/com/ruoyi/app/order/OrderLifecycleService.java
  34. 61 0
      ruoyi-admin/src/main/java/com/ruoyi/app/order/RiderDeliveryExclusivityService.java
  35. 70 0
      ruoyi-admin/src/main/java/com/ruoyi/app/order/RiderDeliveryLockService.java
  36. 8 0
      ruoyi-admin/src/main/resources/i18n/messages.properties
  37. 9 1
      ruoyi-admin/src/main/resources/i18n/messages_en_US.properties
  38. 341 0
      ruoyi-admin/src/main/resources/i18n/messages_th_TH.properties
  39. 10 2
      ruoyi-admin/src/main/resources/i18n/messages_vi.properties
  40. 8 0
      ruoyi-admin/src/main/resources/i18n/messages_zh_CN.properties
  41. 8 0
      ruoyi-admin/src/main/resources/i18n/messages_zh_TW.properties
  42. 83 1
      ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/controller/FlashDeliveryControllerContractTest.java
  43. 4 2
      ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryOrderViewTest.java
  44. 7 4
      ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryRiderOrderListViewTest.java
  45. 619 32
      ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryApplicationServiceTest.java
  46. 7 1
      ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryI18nContractTest.java
  47. 31 0
      ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryPricingCalculatorTest.java
  48. 42 1
      ruoyi-admin/src/test/java/com/ruoyi/app/order/OrderLifecycleServiceTest.java
  49. 3 0
      ruoyi-admin/src/test/java/com/ruoyi/app/order/PosOrderQsOprateControllerTest.java
  50. 126 0
      ruoyi-admin/src/test/java/com/ruoyi/app/order/RiderDeliveryExclusivityServiceTest.java
  51. 109 0
      ruoyi-admin/src/test/java/com/ruoyi/app/order/RiderDeliveryLockServiceTest.java
  52. 68 0
      ruoyi-admin/src/test/java/com/ruoyi/common/utils/BackendI18nBundleContractTest.java
  53. 31 0
      ruoyi-admin/src/test/java/com/ruoyi/common/utils/LocaleUtilsTest.java
  54. 40 0
      ruoyi-admin/src/test/java/com/ruoyi/framework/config/BackendI18nConfigTest.java
  55. 19 28
      ruoyi-common/src/main/java/com/ruoyi/common/utils/LocaleUtils.java
  56. 10 2
      ruoyi-framework/src/main/java/com/ruoyi/framework/config/I18nConfig.java
  57. 12 2
      ruoyi-system/src/main/java/com/ruoyi/system/domain/flash/FlashDeliveryOrder.java
  58. 2 1
      ruoyi-system/src/main/java/com/ruoyi/system/domain/flash/FlashDeliveryPricing.java
  59. 3 0
      ruoyi-system/src/main/java/com/ruoyi/system/mapper/InfoUserMapper.java
  60. 11 3
      ruoyi-system/src/main/java/com/ruoyi/system/mapper/flash/FlashDeliveryOrderMapper.java
  61. 8 18
      ruoyi-system/src/main/java/com/ruoyi/system/mapper/flash/FlashDeliveryPricingMapper.java
  62. 43 3
      ruoyi-system/src/main/resources/mapper/flash/FlashDeliveryOrderMapper.xml
  63. 11 0
      ruoyi-system/src/main/resources/mapper/infouser/InfoUserMapper.xml
  64. 72 1
      ruoyi-system/src/test/java/com/ruoyi/system/mapper/flash/FlashDeliveryMapperXmlTest.java
  65. 6 13
      ruoyi-system/src/test/java/com/ruoyi/system/mapper/flash/FlashDeliveryPricingMapperContractTest.java
  66. 98 16
      specs/024-flash-delivery/contracts/api.md
  67. 3 7
      specs/024-flash-delivery/data-model.md
  68. 60 10
      specs/024-flash-delivery/design.md
  69. 254 0
      specs/024-flash-delivery/implementation-plan-2026-09-04.md
  70. 185 3
      specs/024-flash-delivery/plan.md
  71. 21 5
      specs/024-flash-delivery/quickstart.md
  72. 81 33
      specs/024-flash-delivery/spec.md
  73. 66 0
      specs/024-flash-delivery/tasks.md
  74. 59 0
      specs/024-flash-delivery/weight-range-design.md
  75. 105 0
      specs/024-flash-delivery/weight-range-implementation-plan.md
  76. 92 0
      updatesql/sql.md

File diff suppressed because it is too large
+ 32 - 0
.claude/homunculus/observations.jsonl


+ 399 - 64
docs/flash-delivery-app-api.md

@@ -2,13 +2,28 @@
 
 本文档供用户端 App 和骑手端 App 接入闪送功能使用,以当前后端实现为准。文档只描述接口契约和业务流程,不包含 App 前端实现代码。
 
+> 更新时间: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. 功能范围
 
 当前闪送支持:
 
-- 帮送、帮取、加急送三种服务。
+- 帮送、帮取两种业务场景,以及普通配送、1 对 1 加急两种配送等级。
+- 物品类别、数量、设计稿四档重量范围和体积/规格。
+- 基础配送费、距离费、加急费、骑手小费和订单总金额明细。
 - 共享地址簿、路线报价、立即配送和预约配送。
-- 用户发布订单、查询订单、取消订单和确认收货。
+- 待接单订单修改发件/收件地址、联系人和电话,重新报价并确认保存;追加小费并同步更新总金额。
+- 用户发布订单,并按“我发的/我收的”查询订单;发件人可取消,发件人或当前绑定的收件人可确认收货。
 - 骑手查看待抢订单、抢单、确认取件和确认送达。
 - 可选的四位交付 PIN。
 - 寄件、取件和送达图片凭证。
@@ -16,7 +31,7 @@
 
 当前不包含支付、退款、骑手收入、结算、代购垫付、自动派单和骑手放弃订单。
 
-设计原型中的支付方式选择、已付款状态、附近骑手上线数量、预计接单时间和骑手收益金额均为静态示意,接口不提供这些数据,App 不应展示或请求它们。
+设计原型中的支付方式选择、已付款状态、附近骑手上线数量、预计接单时间和骑手收益金额均为静态示意,接口不提供这些数据,App 不应展示或请求它们。发布成功页统一使用静态文案“发布后等待附近骑手接单”。
 
 ## 2. 通用约定
 
@@ -104,7 +119,7 @@ App 必须以响应体 `code` 判断业务是否成功,不能只依赖 HTTP 
 
 - 请求中的预约时间使用 ISO 日期时间字符串,例如 `2026-09-02T10:00:00+08:00`。
 - 返回时间以服务端实际 JSON 时间格式为准,App 应按日期时间解析,不应依赖固定展示格式。
-- 金额单位为新台币,`currency` 固定为 `TWD`;`startingFare`、`freight`、`distanceFee` 和 `amount` 都是整数,不带小数。
+- 金额单位为新台币,`currency` 固定为 `TWD`;`startingFare`、`freight`、`distanceFee`、`baseDeliveryFee`、`minimumUrgentFee`、`urgentFee`、`tipAmount` 和 `amount` 都是整数,不带小数。
 - `distanceMeters` 单位为米。
 - 运价配置中的 `startingDistance`、`distance` 以及报价中的 `billableDistance` 单位为公里,可保留两位小数。
 - `estimatedDurationSeconds` 单位为秒,路线服务降级时可能为 `null`。
@@ -118,11 +133,17 @@ App 必须以响应体 `code` 判断业务是否成功,不能只依赖 HTTP 
 |---|---|
 | `HELP_SEND` | 帮送 |
 | `HELP_PICKUP` | 帮取 |
-| `URGENT` | 加急送 |
 
-是否可下单以首页接口当前实际返回的服务为准。运价没有启停状态;只有当前时间命中已配置运价时段的服务才会返回,不要在 App 中假定三种服务始终全部可用。
+`serviceType` 只表达业务场景,不再表达是否加急。帮送和帮取共享同一套运价时段。
+
+### 3.2 配送等级 `deliveryType`
 
-### 3.2 包裹类型 `packageType`
+| 值 | 含义 |
+|---|---|
+| `NORMAL` | 普通配送,加急费固定为 0 |
+| `URGENT` | 1 对 1 加急配送,收取加急费并启用骑手独占规则 |
+
+### 3.3 包裹类型 `packageType`
 
 | 值 | 含义 |
 |---|---|
@@ -136,17 +157,20 @@ App 必须以响应体 `code` 判断业务是否成功,不能只依赖 HTTP 
 | `SMALL_APPLIANCE` | 小家电 |
 | `OTHER` | 其他 |
 
-### 3.3 包裹重量档 `packageSize`
+### 3.4 物品重量范围 `weightRange`
 
-| 值 | 含义 |
-|---|---|
-| `SMALL` | 不超过 5kg |
-| `MEDIUM` | 不超过 12kg |
-| `LARGE` | 不超过 20kg |
+| 接口值 | 连续重量区间 | UI 文案 |
+|---|---|---|
+| `UP_TO_5_KG` | 大于 0 且不超过 5 公斤 | 5 公斤以内 |
+| `OVER_5_TO_10_KG` | 超过 5 且不超过 10 公斤 | 6–10 公斤 |
+| `OVER_10_TO_15_KG` | 超过 10 且不超过 15 公斤 | 11–15 公斤 |
+| `OVER_15_TO_20_KG` | 超过 15 且不超过 20 公斤 | 16–20 公斤 |
+
+App 必须直接提交上述四个枚举之一,不再提交精确重量 `totalWeightKg`,也不再提交旧三档字段 `packageSize`。API 使用连续区间定义,UI 按设计稿显示整数范围;例如实际估重 5.5 公斤应选择 `OVER_5_TO_10_KG`。
 
-接口只提交重量档,不提交精确重量。
+`weightRange` 仅用于物品申报、订单展示和骑手判断是否适合承运,当前不参与基础配送费、距离费、加急费或总金额计算;选择不同重量范围不会改变报价
 
-### 3.4 配送方式 `deliveryMode`
+### 3.5 配送方式 `deliveryMode`
 
 | 值 | 含义 |
 |---|---|
@@ -162,20 +186,20 @@ App 必须以响应体 `code` 判断业务是否成功,不能只依赖 HTTP 
 
 立即配送时不得传 `scheduledPickupStartAt` 和 `scheduledPickupEndAt`。
 
-### 3.5 订单状态 `status`
+### 3.6 订单状态 `status`
 
 ```text
 WAITING_ACCEPTANCE -> ACCEPTED -> PICKED_UP -> DELIVERED -> COMPLETED
           |               |
-          +---- 用户可取消 +---- 用户可取消
+          +--- 发件人可取消 +--- 发件人可取消
 ```
 
 | 值 | 含义 | 下一步 |
 |---|---|---|
-| `WAITING_ACCEPTANCE` | 待骑手接单 | 骑手抢单,或用户取消 |
-| `ACCEPTED` | 骑手已接单 | 骑手确认取件,或用户取消 |
+| `WAITING_ACCEPTANCE` | 待骑手接单 | 骑手抢单;发件人可修改取送信息、追加小费或取消 |
+| `ACCEPTED` | 骑手已接单 | 骑手确认取件,或发件人取消 |
 | `PICKED_UP` | 骑手已取件 | 骑手确认送达 |
-| `DELIVERED` | 骑手已送达 | 用户确认收货;超过 24 小时可由系统自动完成 |
+| `DELIVERED` | 骑手已送达 | 发件人或当前绑定的收件人确认收货;超过 24 小时可由系统自动完成 |
 | `COMPLETED` | 已完成 | 终态 |
 | `CANCELLED` | 已取消 | 终态 |
 
@@ -187,8 +211,9 @@ WAITING_ACCEPTANCE -> ACCEPTED -> PICKED_UP -> DELIVERED -> COMPLETED
 2. 从共享地址簿选择地址,或填写取件和收件信息。
 3. 调用报价接口,展示服务端返回的路线和费用。
 4. 用户确认后调用创建订单接口。创建时服务端会重新计算路线和金额,最终以创建响应为准。
-5. 通过订单列表或详情刷新状态。当前闪送模块没有 App 实时推送接口。
-6. 在 `WAITING_ACCEPTANCE` 或 `ACCEPTED` 状态可取消;在 `DELIVERED` 状态可确认收货。
+5. 使用 `role=sender` 查看“我发的”,使用 `role=receiver` 查看“我收的”,并通过列表或详情刷新状态。当前闪送模块没有 App 实时推送接口。
+6. 在“我发的”待接单详情页,使用最新 `orderVersion` 调用地址预报价与确认保存接口,或调用追加小费接口;每次成功后使用返回的完整详情刷新页面及版本。
+7. 仅发件人可在 `WAITING_ACCEPTANCE` 或 `ACCEPTED` 状态取消;发件人和当前绑定的收件人都可在 `DELIVERED` 状态确认收货。
 
 ### 4.2 骑手端
 
@@ -237,7 +262,7 @@ https://api.example.com/profile/upload/2026/09/01/example.jpg
 
 ### 6.1 下单地址对象
 
-报价和创建订单中的 `pickup`、`delivery` 使用以下结构:
+报价、创建订单及修改订单地址中的 `pickup`、`delivery` 使用以下结构:
 
 ```json
 {
@@ -273,12 +298,16 @@ App 完整详情使用以下订单字段,响应的 `data` 直接返回这些
 
 | 字段 | 类型 | 说明 |
 |---|---|---|
-| `id` | number | 订单主键,后续接口路径使用此值 |
+| `id` | number | 订单主键;确认修改地址和追加小费时,将此值放入请求体 `orderId` |
+| `orderVersion` | integer | 用户详情新增的订单版本;修改地址及追加小费必传,成功后递增。用户列表和骑手响应不返回 |
 | `orderNo` | string | 闪送订单号 |
-| `serviceType` | string | 服务类型 |
+| `serviceType` | string | 业务场景:`HELP_SEND` 或 `HELP_PICKUP` |
+| `deliveryType` | string | 配送等级:`NORMAL` 或 `URGENT` |
 | `status` | string | 当前状态 |
 | `packageType` | string | 包裹类型 |
-| `packageSize` | string | 包裹重量档 |
+| `quantity` | integer | 物品数量,正整数 |
+| `weightRange` | string | 用户选择的四档重量范围,取值见 3.4 |
+| `specification` | string/null | 体积或规格说明,最长 255 字符 |
 | `deliveryMode` | string | `NOW` 或 `SCHEDULED` |
 | `scheduledPickupStartAt` | datetime/null | 预约开始时间 |
 | `scheduledPickupEndAt` | datetime/null | 预约结束时间 |
@@ -288,6 +317,10 @@ App 完整详情使用以下订单字段,响应的 `data` 直接返回这些
 | `distanceMeters` | number | 配送距离,单位米 |
 | `distanceSource` | string | `ROUTE` 或 `STRAIGHT_LINE` |
 | `estimatedDurationSeconds` | number/null | 预计时长,单位秒 |
+| `baseDeliveryFee` | integer | 基础配送费,即起送价加距离费 |
+| `distanceFee` | integer | 超出起送距离后的距离费 |
+| `urgentFee` | integer | 加急费;普通配送为 0 |
+| `tipAmount` | integer | 用户填写的骑手小费 |
 | `amount` | integer | 订单金额,整数 TWD |
 | `currency` | string | `TWD` |
 | `userNote` | string/null | 用户备注 |
@@ -300,9 +333,9 @@ App 完整详情使用以下订单字段,响应的 `data` 直接返回这些
 | `createTime` | datetime | 创建时间 |
 | `updateTime` | datetime | 更新时间 |
 
-用户列表摘要固定字段为:`id,orderNo,serviceType,status,packageType,deliveryMode,scheduledPickupStartAt,scheduledPickupEndAt,pickupAddress,pickupDetailAddress,deliveryAddress,deliveryDetailAddress,estimatedDurationSeconds,amount,currency,deliveredAt,createTime`。用户列表不返回联系人、电话、精确坐标、照片、日志或计价审计字段。
+用户列表摘要固定字段为:`id,orderNo,serviceType,deliveryType,status,packageType,quantity,weightRange,deliveryMode,scheduledPickupStartAt,scheduledPickupEndAt,pickupAddress,pickupDetailAddress,deliveryAddress,deliveryDetailAddress,estimatedDurationSeconds,baseDeliveryFee,urgentFee,tipAmount,amount,currency,deliveredAt,createTime`。用户列表不返回联系人、电话、精确坐标、照片、日志或内部计价审计字段。
 
-骑手列表摘要固定字段为:`id,orderNo,serviceType,status,packageType,packageSize,deliveryMode,scheduledPickupStartAt,scheduledPickupEndAt,pinRequired,pickupAddress,pickupDetailAddress,deliveryAddress,deliveryDetailAddress,pickupDistanceMeters,distanceMeters,estimatedDurationSeconds,amount,currency,createTime`。`newTask` 分页响应额外返回 `nearbyTaskCount` 和 `highestOrderAmount`,不返回尖峰倍率或骑手收入字段。
+骑手列表摘要固定字段为:`id,orderNo,serviceType,deliveryType,status,packageType,quantity,weightRange,specification,deliveryMode,scheduledPickupStartAt,scheduledPickupEndAt,pinRequired,pickupAddress,pickupDetailAddress,deliveryAddress,deliveryDetailAddress,pickupDistanceMeters,distanceMeters,estimatedDurationSeconds,baseDeliveryFee,distanceFee,urgentFee,tipAmount,amount,currency,createTime`。`newTask` 分页响应额外返回 `nearbyTaskCount` 和 `highestOrderAmount`,不返回内部加急比例、最低加急费或骑手收入字段。
 
 ### 6.3 App 完整详情对象
 
@@ -312,10 +345,13 @@ App 完整详情使用以下订单字段,响应的 `data` 直接返回这些
 {
   "id": 101,
   "orderNo": "FD1234567890",
-  "serviceType": "URGENT",
+  "serviceType": "HELP_SEND",
+  "deliveryType": "URGENT",
   "status": "ACCEPTED",
   "packageType": "DOCUMENT",
-  "packageSize": "SMALL",
+  "quantity": 1,
+  "weightRange": "UP_TO_5_KG",
+  "specification": "30 x 20 x 10 cm",
   "deliveryMode": "NOW",
   "scheduledPickupStartAt": null,
   "scheduledPickupEndAt": null,
@@ -325,7 +361,11 @@ App 完整详情使用以下订单字段,响应的 `data` 直接返回这些
   "distanceMeters": 4200,
   "distanceSource": "ROUTE",
   "estimatedDurationSeconds": 900,
-  "amount": 90,
+  "baseDeliveryFee": 72,
+  "distanceFee": 12,
+  "urgentFee": 15,
+  "tipAmount": 5,
+  "amount": 92,
   "currency": "TWD",
   "userNote": null,
   "acceptedAt": "2026-09-01T10:20:00+08:00",
@@ -353,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`。
 
@@ -375,6 +416,8 @@ GET /system/flashDelivery/home
 
 ```json
 {
+  "serviceTypes": ["HELP_SEND", "HELP_PICKUP"],
+  "deliveryTypes": ["NORMAL", "URGENT"],
   "services": [
     {
       "serviceType": "HELP_SEND",
@@ -385,6 +428,22 @@ GET /system/flashDelivery/home
       "startingFare": 60,
       "distance": 1.00,
       "freight": 10,
+      "urgentRate": 20.00,
+      "minimumUrgentFee": 15,
+      "pricingVersion": 1,
+      "currency": "TWD"
+    },
+    {
+      "serviceType": "HELP_PICKUP",
+      "pricingId": 1,
+      "startTime": "00:00",
+      "endTime": "08:00",
+      "startingDistance": 3.00,
+      "startingFare": 60,
+      "distance": 1.00,
+      "freight": 10,
+      "urgentRate": 20.00,
+      "minimumUrgentFee": 15,
       "pricingVersion": 1,
       "currency": "TWD"
     }
@@ -392,7 +451,7 @@ GET /system/flashDelivery/home
 }
 ```
 
-只返回当前时间命中运价时段的服务。`startTime` 包含、`endTime` 不包含,均为业务当地时间的 `HH:mm`;`24:00` 只会作为结束时间。`services` 为空表示当前时段没有可下单的闪送服务
+帮送和帮取会返回两条业务场景数据,但两条数据引用同一个 `pricingId`、`pricingVersion` 和费用配置。`startTime` 包含、`endTime` 不包含,均为台北时区的 `HH:mm`;`24:00` 只会作为结束时间。`services` 为空表示当前时刻没有匹配的统一运价
 
 ### 7.2 获取实时报价
 
@@ -405,8 +464,15 @@ POST /system/flashDelivery/quote
 ```json
 {
   "serviceType": "HELP_SEND",
+  "deliveryType": "URGENT",
+  "deliveryMode": "SCHEDULED",
+  "scheduledPickupStartAt": "2026-09-08T16:30:00+08:00",
+  "scheduledPickupEndAt": "2026-09-08T17:00:00+08:00",
   "packageType": "DOCUMENT",
-  "packageSize": "SMALL",
+  "quantity": 1,
+  "weightRange": "UP_TO_5_KG",
+  "specification": "30 x 20 x 10 cm",
+  "tipAmount": 5,
   "pickup": {
     "name": "王小明",
     "phone": "0912345678",
@@ -432,14 +498,37 @@ POST /system/flashDelivery/quote
 }
 ```
 
+报价请求字段:
+
+| 字段 | 类型 | 必填 | 规则 |
+|---|---|---:|---|
+| `serviceType` | string | 是 | `HELP_SEND` 或 `HELP_PICKUP` |
+| `deliveryType` | string | 是 | `NORMAL` 或 `URGENT` |
+| `deliveryMode` | string | 否 | `NOW` 或 `SCHEDULED`,不传按 `NOW` 处理 |
+| `scheduledPickupStartAt` | datetime | 预约时是 | `SCHEDULED` 时为未来三天内的预约开始时间;`NOW` 时不得传 |
+| `scheduledPickupEndAt` | datetime | 预约时是 | 必须比开始时间晚 30 分钟;`NOW` 时不得传 |
+| `packageType` | string | 是 | 取值见 3.3 |
+| `quantity` | integer | 是 | 大于 0 的整数 |
+| `weightRange` | string | 是 | 取值见 3.4,只能提交四个重量范围枚举之一 |
+| `specification` | string | 否 | 体积或规格说明,去除首尾空格后最长 255 字符 |
+| `tipAmount` | integer | 是 | 骑手小费,非负整数 TWD;没有小费时传 `0` |
+| `pickup` | object | 是 | 完整取件信息,结构和规则见 6.1 |
+| `delivery` | object | 是 | 完整收件信息,结构和规则见 6.1 |
+
+只要业务场景、配送等级、取件时间、物品信息、小费或取送地址发生变化,App 都必须重新调用报价接口,不能继续使用变化前的报价。
+
 响应 `data`:
 
 ```json
 {
   "serviceType": "HELP_SEND",
+  "deliveryType": "URGENT",
+  "deliveryMode": "SCHEDULED",
+  "scheduledPickupStartAt": "2026-09-08T16:30:00+08:00",
+  "scheduledPickupEndAt": "2026-09-08T17:00:00+08:00",
   "pricingId": 1,
-  "startTime": "00:00",
-  "endTime": "08:00",
+  "startTime": "16:00",
+  "endTime": "18:00",
   "distanceMeters": 4200,
   "distanceSource": "ROUTE",
   "estimatedDurationSeconds": 900,
@@ -447,17 +536,24 @@ POST /system/flashDelivery/quote
   "startingFare": 60,
   "distance": 1.00,
   "freight": 10,
+  "urgentRate": 20.00,
+  "minimumUrgentFee": 15,
   "billableDistance": 1.20,
   "distanceFee": 12,
-  "amount": 72,
+  "baseDeliveryFee": 72,
+  "urgentFee": 15,
+  "tipAmount": 5,
+  "amount": 92,
   "currency": "TWD",
   "pricingVersion": 1
 }
 ```
 
-计价规则与现有外卖订单一致:先用路线公里数减去 `startingDistance`;超出不足 `0.5` 公里时 `billableDistance` 为 `0`,达到 `0.5` 但不足 `1` 公里时按 `1` 公里,达到 `1` 公里后按实际超出距离。里程费用按 `billableDistance / distance * freight` 计算并四舍五入为整数 TWD,最终 `amount = startingFare + distanceFee`。预计时长不参与计价,也没有最低价、每分钟价格或尖峰倍率。
+距离规则与现有外卖订单一致:先用路线公里数减去 `startingDistance`;超出不足 `0.5` 公里时 `billableDistance` 为 `0`,达到 `0.5` 但不足 `1` 公里时按 `1` 公里,达到 `1` 公里后按实际超出距离。`distanceFee = roundHalfUp(billableDistance / distance × freight)`,`baseDeliveryFee = startingFare + distanceFee`。普通配送的 `urgentFee=0`;加急配送的 `urgentFee=max(minimumUrgentFee, roundHalfUp(baseDeliveryFee × urgentRate / 100))`;`amount=baseDeliveryFee+urgentFee+tipAmount`。小费不参与加急费计算,预计时长也不参与计价。
+
+注意:`baseDeliveryFee` 是已经包含 `distanceFee` 的基础配送费小计。费用合计时不要再次把 `distanceFee` 加入总金额;`distanceFee` 仅用于向用户解释基础配送费中有多少是距离附加费。
 
-报价仅用于展示。创建订单时后端会重新匹配当前运价时段并计算最新路线和金额,因此最终金额可能与先前报价不同。当前时间没有匹配时段时,接口返回“当前时段暂无可用运价”。
+立即配送按报价时刻匹配运价,预约配送按 `scheduledPickupStartAt` 的台北时间匹配运价。创建订单必须回传本次报价;后端会重新计算并逐项精确校验,任何变化都不会创建订单,而是返回最新报价供用户重新确认
 
 ### 7.3 创建闪送订单
 
@@ -465,19 +561,49 @@ POST /system/flashDelivery/quote
 POST /system/flashDelivery/orders
 ```
 
-请求体在报价请求字段基础上增加:
+创建请求必须在同一个 JSON 对象中完整提交 7.2 的所有报价入参,并增加报价回传、幂等号、PIN、图片和备注字段。`weightRange` 与报价接口的字段名及枚举规则完全一致,不能改传 `totalWeightKg` 或 `packageSize`。
 
 ```json
 {
   "serviceType": "HELP_SEND",
-  "packageType": "DOCUMENT",
-  "packageSize": "SMALL",
-  "pickup": {},
-  "delivery": {},
-  "clientRequestId": "FD-20260901-USER1001-0001",
+  "deliveryType": "URGENT",
   "deliveryMode": "SCHEDULED",
-  "scheduledPickupStartAt": "2026-09-02T10:00:00+08:00",
-  "scheduledPickupEndAt": "2026-09-02T10:30:00+08:00",
+  "scheduledPickupStartAt": "2026-09-08T16:30:00+08:00",
+  "scheduledPickupEndAt": "2026-09-08T17:00:00+08:00",
+  "packageType": "DOCUMENT",
+  "quantity": 1,
+  "weightRange": "UP_TO_5_KG",
+  "specification": "30 x 20 x 10 cm",
+  "tipAmount": 5,
+  "pickup": {
+    "name": "王小明",
+    "phone": "0912345678",
+    "address": "台北市信义区市府路1号",
+    "addressDetail": "3楼A室",
+    "city": "台北市",
+    "area": "信义区",
+    "handoffMethod": "请电话联系",
+    "longitude": 121.5645,
+    "latitude": 25.033
+  },
+  "delivery": {
+    "name": "李小华",
+    "phone": "0987654321",
+    "address": "台北市大安区信义路三段56号",
+    "addressDetail": "1楼",
+    "city": "台北市",
+    "area": "大安区",
+    "handoffMethod": "交给本人",
+    "longitude": 121.538,
+    "latitude": 25.0335
+  },
+  "clientRequestId": "FD-20260908-USER1001-0001",
+  "pricingId": 1,
+  "pricingVersion": 1,
+  "quotedBaseDeliveryFee": 72,
+  "quotedDistanceFee": 12,
+  "quotedUrgentFee": 15,
+  "quotedAmount": 92,
   "pinRequired": true,
   "senderImageUrls": [
     "https://api.example.com/profile/upload/sender-1.jpg"
@@ -486,12 +612,17 @@ POST /system/flashDelivery/orders
 }
 ```
 
+其中 `serviceType` 至 `delivery` 的字段定义与 7.2 报价请求表一致;以下是创建订单额外增加的字段:
+
 | 新增字段 | 类型 | 必填 | 说明 |
 |---|---|---:|---|
 | `clientRequestId` | string | 是 | 客户端幂等请求号,去除首尾空格后最长 64 字符 |
-| `deliveryMode` | string | 否 | 不传时默认为 `NOW` |
-| `scheduledPickupStartAt` | datetime | 预约时必填 | 预约开始时间 |
-| `scheduledPickupEndAt` | datetime | 预约时必填 | 必须比开始时间晚 30 分钟 |
+| `pricingId` | integer | 是 | 报价响应的运价配置 ID |
+| `pricingVersion` | integer | 是 | 报价响应的配置版本 |
+| `quotedBaseDeliveryFee` | integer | 是 | 报价响应的基础配送费 |
+| `quotedDistanceFee` | integer | 是 | 报价响应的距离费 |
+| `quotedUrgentFee` | integer | 是 | 报价响应的加急费 |
+| `quotedAmount` | integer | 是 | 报价响应的总金额 |
 | `pinRequired` | boolean | 否 | 不传时默认为 `true` |
 | `senderImageUrls` | string[] | 否 | 寄件图片,最多 9 张 |
 | `userNote` | string | 否 | 最长 500 字符 |
@@ -500,10 +631,50 @@ POST /system/flashDelivery/orders
 
 成功响应 `data` 为完整详情对象,初始状态为 `WAITING_ACCEPTANCE`。创建响应中的 `amount` 是最终整数订单金额。
 
+若运价版本、路线或费用发生变化,响应 `code=500`、`msg=报价已变化,请重新确认最新费用`,并在 `data` 返回与报价接口相同的完整最新报价。App 必须更新费用明细并要求用户再次确认,不能自动用旧报价重试。
+
+报价变化响应示例:
+
+```json
+{
+  "code": 500,
+  "msg": "报价已变化,请重新确认最新费用",
+  "data": {
+    "serviceType": "HELP_SEND",
+    "deliveryType": "URGENT",
+    "deliveryMode": "SCHEDULED",
+    "scheduledPickupStartAt": "2026-09-08T16:30:00+08:00",
+    "scheduledPickupEndAt": "2026-09-08T17:00:00+08:00",
+    "pricingId": 2,
+    "startTime": "16:00",
+    "endTime": "18:00",
+    "distanceMeters": 4200,
+    "distanceSource": "ROUTE",
+    "estimatedDurationSeconds": 900,
+    "startingDistance": 3.00,
+    "startingFare": 60,
+    "distance": 1.00,
+    "freight": 10,
+    "urgentRate": 25.00,
+    "minimumUrgentFee": 15,
+    "billableDistance": 1.20,
+    "distanceFee": 12,
+    "baseDeliveryFee": 72,
+    "urgentFee": 18,
+    "tipAmount": 5,
+    "amount": 95,
+    "currency": "TWD",
+    "pricingVersion": 2
+  }
+}
+```
+
+创建时,后端会移除收件手机号中的空白、`+`、`-`、`(`、`)` 后,匹配有效且未删除的普通用户账号。仅在恰好匹配一个账号时把该账号固化为收件人;无匹配或重复匹配均不绑定。此绑定只执行一次,历史订单不会因收件人之后注册或修改手机号而自动认领。
+
 ### 7.4 查询用户订单列表
 
 ```http
-GET /system/flashDelivery/orders?page=1&size=10
+GET /system/flashDelivery/orders?page=1&size=10&role=sender
 ```
 
 查询参数:
@@ -512,8 +683,11 @@ GET /system/flashDelivery/orders?page=1&size=10
 |---|---:|---|
 | `page` | 否 | 默认 `1`,小于 `1` 时按 `1` 处理 |
 | `size` | 否 | 默认 `10`,范围为 `1` 至 `100` |
+| `role` | 否 | `sender` 查询“我发的”,`receiver` 查询“我收的”;默认 `sender` |
+
+仅查询当前用户作为指定角色的订单,按创建时间倒序返回全部状态的订单摘要。接口不接受 `status`、`scene` 或 `serviceType`;响应 `data.records` 中每项为用户列表摘要对象。
 
-仅查询当前用户,按创建时间倒序返回全部状态的订单摘要。接口不接受 `status`、`scene` 或 `serviceType`;响应 `data.records` 中每项为用户列表摘要对象。
+`role` 只接受 `sender` 或 `receiver`,不区分大小写;传空值按 `sender` 处理,传其他值返回业务错误。“我收的”依据订单当前绑定的收件账号;创建时匹配,确认修改取送信息时重新匹配收件电话,查询时不会重新按手机号匹配
 
 设计原型中的“待接单、配送中、已完成、已取消”筛选页签当前没有对应查询参数。第一版建议只展示“全部”列表;如需页签,只能基于当前页数据本地过滤,且不应依赖它得到准确的分页总数。
 
@@ -523,7 +697,7 @@ GET /system/flashDelivery/orders?page=1&size=10
 GET /system/flashDelivery/orders/{id}
 ```
 
-仅订单所属用户可访问。响应 `data` 直接为 App 完整订单字段,包括订单快照、三类图片数组、骑手摘要和可选的交付 PIN,不返回原始状态日志。响应不包含支付状态或支付方式字段,详情页不要展示“已付款”等支付信息。
+订单发件人和当前绑定的收件人均可访问。响应 `data` 直接为 App 完整订单字段,包括订单快照、三类图片数组、骑手摘要和可选的交付 PIN,不返回原始状态日志。响应不包含支付状态或支付方式字段,详情页不要展示“已付款”等支付信息。
 
 ### 7.6 用户取消订单
 
@@ -540,6 +714,7 @@ POST /system/flashDelivery/orders/{id}/cancel
 ```
 
 - `reason` 必填,去除首尾空格后不能为空,最长 500 字符。
+- 仅订单发件人可以取消;收件人不能取消。
 - 仅 `WAITING_ACCEPTANCE` 和 `ACCEPTED` 状态允许用户取消。
 - `PICKED_UP` 之后需要平台介入,用户端不能直接取消。
 - 设计原型中“骑士接单前可免费取消”的文案比接口口径更严格:接口允许骑手已接单但尚未取件时取消,且当前没有支付,取消不产生任何费用;接入支付后取消规则需重新对齐。
@@ -550,12 +725,150 @@ 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. 骑手端接口
 
 所有接口均要求骑手登录 `token`,且账号 `userType` 必须为 `2`。
 
+接单使用共享 Redis 锁 `lock:delivery:rider:{riderId}` 串行执行跨外卖、闪送的资格检查和订单条件更新,并在事务完成后释放。该锁只覆盖接单事务,不会一直持有到配送完成;持续独占由每次接新单时查询骑手进行中的订单保证,Redis 锁用于防止两个并发接单请求同时绕过查询。
+
+`deliveryType=URGENT` 的急送要求骑手没有任何进行中的外卖或闪送;骑手已有进行中的急送时,也不能再接普通闪送或外卖。普通闪送不会阻止骑手承接外卖,外卖也不会阻止骑手承接普通闪送。Redis 不可用或 3 秒内未取得锁时采用失败关闭策略,本次接单直接失败,不更新订单。
+
 ### 8.1 查询骑手订单列表
 
 ```http
@@ -586,7 +899,7 @@ GET /system/flashDelivery/rider/orders?page=1&size=10&tab=newTask&longitude=121.
 
 `newTask` 的 `longitude` 和 `latitude` 仅用于附近范围筛选、计算取件点距离和排序;其他页签忽略坐标。列表按页签和骑手范围完成服务端过滤后再分页,不由 App 拉取完整集合后过滤。
 
-`newTask` 分页响应的 `data` 除 `records`、`total`、`current`、`size` 外,还返回 `nearbyTaskCount` 和 `highestOrderAmount`。不返回尖峰倍率或骑手收入字段。`amount` 是用户支付的订单金额,页面文案不得表述为“预估收益”或“收益”;`nearbyTaskCount` 是附近待抢订单数,不是上线骑手数。
+`newTask` 分页响应的 `data` 除 `records`、`total`、`current`、`size` 外,还返回 `nearbyTaskCount` 和 `highestOrderAmount`。不返回尖峰倍率或骑手收入字段。`amount` 是服务端计算的订单报价金额,当前不会发起支付或扣款,页面文案不得表述为“预估收益”或“收益”;`nearbyTaskCount` 是附近待抢订单数,不是上线骑手数。
 
 所有页签的 `data.records` 使用骑手列表摘要字段:
 
@@ -594,10 +907,13 @@ GET /system/flashDelivery/rider/orders?page=1&size=10&tab=newTask&longitude=121.
 {
   "id": 101,
   "orderNo": "FD1234567890",
-  "serviceType": "URGENT",
+  "serviceType": "HELP_SEND",
+  "deliveryType": "URGENT",
   "status": "WAITING_ACCEPTANCE",
   "packageType": "DOCUMENT",
-  "packageSize": "SMALL",
+  "quantity": 1,
+  "weightRange": "UP_TO_5_KG",
+  "specification": "30 x 20 x 10 cm",
   "deliveryMode": "NOW",
   "scheduledPickupStartAt": null,
   "scheduledPickupEndAt": null,
@@ -609,7 +925,11 @@ GET /system/flashDelivery/rider/orders?page=1&size=10&tab=newTask&longitude=121.
   "pickupDistanceMeters": 420,
   "distanceMeters": 4200,
   "estimatedDurationSeconds": 900,
-  "amount": 90,
+  "baseDeliveryFee": 72,
+  "distanceFee": 12,
+  "urgentFee": 15,
+  "tipAmount": 5,
+  "amount": 92,
   "currency": "TWD",
   "createTime": "2026-09-01T09:00:00+08:00"
 }
@@ -631,7 +951,7 @@ GET /system/flashDelivery/rider/orders/{id}
 POST /system/flashDelivery/rider/orders/{id}/accept
 ```
 
-无请求体。抢单成功后状态变为 `ACCEPTED`,响应 `data` 直接返回完整订单字段;订单已被其他骑手抢走时返回“订单已被其他骑手接走”。
+无请求体。骑手账号必须已开通闪送配送。抢单成功后状态变为 `ACCEPTED`,响应 `data` 直接返回完整订单字段;订单已被其他骑手抢走时返回“订单已被其他骑手接走”。违反急送独占规则时返回统一的独占冲突提示,App 应保留在列表页并刷新可接任务。
 
 ### 8.4 确认取件
 
@@ -761,12 +1081,20 @@ POST /system/address/{id}/top
 | `msg` 含义 | 常见原因 | App 处理建议 |
 |---|---|---|
 | token 已过期 | 登录态失效 | 清理登录态并重新登录 |
-| 当前时段暂无可用运价 | 当前时间没有匹配所选服务的运价时段 | 刷新首页服务列表或稍后重试 |
+| 对应取件时段暂无可用运价 | 立即时刻或预约开始时刻没有匹配统一运价 | 重新选择取件时间或稍后重试 |
+| 报价已变化,请重新确认最新费用 | 创建订单报价不一致,或修改地址的确认距离/费用与复算结果不一致 | 用响应 `data` 刷新费用并让用户再次确认 |
+| 物品数量、重量范围或规格信息不正确 | 数量非正整数、`weightRange` 缺失或不属于四个合法枚举、规格过长 | 返回物品信息页修正 |
 | 取件地址和收件地址不能相同 | 地址文本相同或经纬度完全相同 | 要求用户修改地址 |
 | 配送距离不能超过 40 公里 | 服务端路线距离超限 | 提示当前路线不可下单 |
 | 预约取件时段无效 | 时间已过、超过三天或不是 30 分钟 | 重新选择预约时段 |
 | 订单已被其他骑手接走 | 并发抢单失败 | 刷新待抢列表 |
+| 骑手存在进行中的独占配送任务 | 当前订单或骑手已有急送冲突 | 保留当前页并刷新可接任务 |
+| 系统繁忙,请稍后重试 | Redis 不可用或 3 秒内未取得骑手锁 | 不重复提交,稍后刷新并重试 |
 | 当前订单状态不允许此操作 | 页面状态已过期或重复操作 | 重新拉取订单详情 |
+| 仅待接单且未分配骑手的订单可以修改 | 修改地址/追加小费时已接单、已取消或已有骑手 | 刷新详情并关闭编辑入口 |
+| 请提供有效的订单版本 | 缺失或负数 `orderVersion` | 读取用户详情取得版本 |
+| 订单状态已变化,请刷新后重试 | 编辑使用旧版本,或最终保存时发生接单/取消/其他编辑 | 刷新详情;地址重新报价;小费先核对是否已追加 |
+| 追加小费必须为正整数且不能超出金额范围 | 小数、零、负数、空值或金额溢出 | 修正本次追加金额 |
 | 交付 PIN 不正确 | 骑手提交 PIN 错误 | 保留页面并重新输入 PIN |
 | 闪送订单不存在或无权访问 | ID 不存在或不属于当前账号 | 返回列表并刷新数据 |
 | 凭证图片地址无效 | URL 不是完整 HTTP(S) 地址 | 检查上传结果并补全资源域名 |
@@ -774,17 +1102,24 @@ POST /system/address/{id}/top
 ## 11. 接入验收清单
 
 - 首页只展示 `/home` 返回的当前时段可用服务,不在 App 中维护启停状态或默认价格。
-- 报价和创建订单都提交完整取件、收件坐标,且不使用客户端自行计算的金额。
-- 金额按整数 TWD 展示,不显示小数、时长费、最低价或尖峰费用。
+- 报价和创建订单都提交 `serviceType`、`deliveryType`、配送方式、`packageType`、`quantity`、`weightRange`、规格、骑手小费及取送坐标。
+- 创建订单原样回传报价的配置 ID、版本、基础配送费、距离费、加急费和总金额;报价变化时展示最新明细并重新确认。
+- 金额按整数 TWD 展示;`baseDeliveryFee` 已包含 `distanceFee`,距离费只作为基础配送费的明细展示,总金额只按 `baseDeliveryFee + urgentFee + tipAmount` 计算;不显示支付成功、骑手收入、时长费或尖峰费用。
 - 创建订单具备稳定的 `clientRequestId` 重试策略。
+- 待接单详情的“改地址电话”“加小费”仅向订单创建人开放;三个新增接口均携带最新 `orderVersion`。
+- 修改地址先调用 7.8 展示新路线和费用,用户确认后调用 7.9;报价变化必须再次确认,不提前更新本地订单。
+- 追加小费传本次增量,成功后同步展示返回的小费及总额;超时先查询,不自动更换版本重复追加。
+- 报价后接单、取消或追加小费导致版本过期时,旧地址确认必须失败;编辑成功后用返回的版本替换本地版本。
 - 预约时间满足未来三天内、固定 30 分钟的约束。
 - App 使用响应体 `code` 判断成功或失败。
 - 分页列表从 `data.records` 和 `data.total` 取值。
 - 图片先上传,再向闪送接口提交完整 HTTP(S) URL。
-- 用户只在待接单或已接单状态显示取消入口,只在已送达状态显示确认收货入口。
+- “我发的”只在待接单或已接单状态显示取消入口;“我发的”和“我收的”都只在已送达状态显示确认收货入口。
 - 骑手抢单前可展示完整文字地址但不使用坐标导航;抢单成功后再读取联系人、电话、精确坐标和履约图片。
 - 骑手严格按 `ACCEPTED -> PICKED_UP -> DELIVERED` 顺序操作。
 - 启用 PIN 时,骑手送达必须提交用户端详情显示的四位 PIN。
 - 对抢单冲突、状态变化和重复点击,均以刷新后的服务端订单状态为准。
-- 用户订单列表第一版只展示“全部”;如实现状态页签,仅基于当前页数据本地过滤。
+- 用户订单列表使用 `role=sender/receiver` 切换“我发的”和“我收的”;状态页签如需展示,仅基于当前页数据本地过滤。
+- “我收的”按订单当前绑定的账号展示;创建或确认修改取送信息时匹配收件电话,清空或更换绑定后旧收件人不再有访问权;查询时不动态认领历史订单。
+- 发布成功页显示“发布后等待附近骑手接单”,不显示虚构的骑手数量或预计接单时长。
 - 骑手端金额文案统一为“订单金额”,不使用“收益”;联系骑手/用户通过 IM,不提供通话。

+ 409 - 0
docs/superpowers/plans/2026-09-07-backend-thai-i18n.md

@@ -0,0 +1,409 @@
+# 后端泰语提示支持实施计划
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** 为所有后端系统提示增加完整泰语资源和安全的泰语 Locale 解析,同时保持台湾繁体中文为默认语言。
+
+**Architecture:** Spring MessageSource 继续从 `i18n/messages` 读取资源,新增 `messages_th_TH.properties` 提供完整泰语值;同步请求继续由 `lang` 参数切换 Locale,异步消息通过修复后的 `LocaleUtils.parseLocale(String)` 解析用户语言。业务数据的数字语言映射保持不变,避免把尚不存在的泰语商品或门店数据纳入本阶段。
+
+**Tech Stack:** Java 21、Spring Boot MessageSource、Spring MVC LocaleResolver、JUnit 5、Maven、UTF-8 properties。
+
+## Global Constraints
+
+- 默认 Locale 必须继续是台湾繁体中文 `zh_TW`。
+- 泰语标准代码为 `th_TH`,并兼容 `th-TH` 和 `th`。
+- `messages_th_TH.properties` 必须与现有四个本地化语言包拥有完全一致且非空的消息 key。
+- 占位符集合必须逐 key 保持一致,例如 `{0}`、`{1}` 不得遗漏。
+- 不修改 `toLangCode()`、`getUserLanguageCode()`、数据库字段或业务数据语言映射。
+- 不修改平台管理端、商家管理端和 App 前端语言资源。
+- 现有工作区中的闪送请求 DTO/Controller 注释修改属于另一项工作,不得在泰语功能提交中丢失或误暂存。
+- 按用户要求,先完成全部生产代码和测试源码,再统一运行测试与构建;中途不执行 Maven。
+- Git 提交标题和正文使用中文,不添加英文 Conventional Commits 前缀。
+
+---
+
+### Task 1: 定义 Locale 解析契约并修复解析器
+
+**Files:**
+- Create: `ruoyi-admin/src/test/java/com/ruoyi/common/utils/LocaleUtilsTest.java`
+- Modify: `ruoyi-common/src/main/java/com/ruoyi/common/utils/LocaleUtils.java`
+
+**Interfaces:**
+- Consumes: Redis 中已保存的字符串语言代码,以及直接调用 `LocaleUtils.parseLocale(String)` 的代码。
+- Produces: `public static Locale parseLocale(String language)`;支持白名单语言代码并对空值、未知值回退 `zh_TW`。
+
+- [ ] **Step 1: 编写 Locale 白名单解析测试源码**
+
+创建参数化测试,锁定所有受支持别名与默认回退:
+
+```java
+package com.ruoyi.common.utils;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
+
+import java.util.Locale;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+class LocaleUtilsTest {
+    @ParameterizedTest
+    @CsvSource({
+            "zh_CN,zh,CN", "zh-CN,zh,CN", "zh,zh,CN",
+            "zh_TW,zh,TW", "zh-TW,zh,TW", "tw,zh,TW",
+            "en_US,en,US", "en-US,en,US", "en,en,US",
+            "vi_VN,vi,VN", "vi-VN,vi,VN", "vi,vi,VN",
+            "th_TH,th,TH", "th-TH,th,TH", "th,th,TH"
+    })
+    void parsesSupportedLanguageAliases(String input, String language, String country) {
+        assertEquals(new Locale(language, country), LocaleUtils.parseLocale(input));
+    }
+
+    @Test
+    void fallsBackToTraditionalChineseForMissingOrUnknownLanguage() {
+        Locale expected = new Locale("zh", "TW");
+        assertEquals(expected, LocaleUtils.parseLocale(null));
+        assertEquals(expected, LocaleUtils.parseLocale("  "));
+        assertEquals(expected, LocaleUtils.parseLocale("fr-FR"));
+    }
+}
+```
+
+- [ ] **Step 2: 实现白名单 Locale 解析**
+
+保留 `DEFAULT_LOCALE`,新增明确的 Locale 常量并只修改 `parseLocale`。先 `trim()`,再把 `-` 转为 `_` 并使用 `Locale.ROOT` 转小写,最后使用 switch 返回白名单 Locale:
+
+```java
+private static final Locale SIMPLIFIED_CHINESE = new Locale("zh", "CN");
+private static final Locale ENGLISH = new Locale("en", "US");
+private static final Locale VIETNAMESE = new Locale("vi", "VN");
+private static final Locale THAI = new Locale("th", "TH");
+
+public static Locale parseLocale(String language)
+{
+    if (StringUtils.isEmpty(language) || language.trim().isEmpty())
+    {
+        return DEFAULT_LOCALE;
+    }
+    String normalized = language.trim().replace('-', '_').toLowerCase(Locale.ROOT);
+    return switch (normalized)
+    {
+        case "zh", "zh_cn" -> SIMPLIFIED_CHINESE;
+        case "zh_tw", "tw" -> DEFAULT_LOCALE;
+        case "en", "en_us" -> ENGLISH;
+        case "vi", "vi_vn" -> VIETNAMESE;
+        case "th", "th_th" -> THAI;
+        default -> DEFAULT_LOCALE;
+    };
+}
+```
+
+不得修改 `toLangCode()` 和 `getUserLanguageCode()`。
+
+- [ ] **Step 3: 静态检查本任务差异,暂不运行 Maven**
+
+运行:
+
+```powershell
+git diff --check -- ruoyi-common/src/main/java/com/ruoyi/common/utils/LocaleUtils.java ruoyi-admin/src/test/java/com/ruoyi/common/utils/LocaleUtilsTest.java
+```
+
+预期:无错误输出。按全局约束,将测试执行延后到 Task 4。
+
+- [ ] **Step 4: 提交 Locale 解析改动**
+
+```powershell
+git add -- ruoyi-common/src/main/java/com/ruoyi/common/utils/LocaleUtils.java ruoyi-admin/src/test/java/com/ruoyi/common/utils/LocaleUtilsTest.java
+git diff --cached --name-only
+git diff --cached --check
+git commit -m "支持后端泰语语言解析" -m "兼容 th_TH、th-TH 和 th,并为现有受支持语言建立白名单解析;空值和未知语言继续回退台湾繁体。"
+```
+
+### Task 2: 新增完整泰语消息资源和语言包契约
+
+**Files:**
+- Create: `ruoyi-admin/src/main/resources/i18n/messages_th_TH.properties`
+- Create: `ruoyi-admin/src/test/java/com/ruoyi/common/utils/BackendI18nBundleContractTest.java`
+- Modify: `ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryI18nContractTest.java`
+
+**Interfaces:**
+- Consumes: `ResourceBundleMessageSource` 的 basename `i18n/messages` 和现有 319 个本地化消息 key。
+- Produces: 完整的泰语资源包 `messages_th_TH.properties`,由 `Locale("th", "TH")` 自动加载。
+
+- [ ] **Step 1: 编写语言包完整性和占位符契约测试源码**
+
+创建 `BackendI18nBundleContractTest`,使用 UTF-8 Reader 加载资源;以繁体中文包作为 key 与占位符参考,逐一比较英文、简中、越南语和泰语:
+
+```java
+package com.ruoyi.common.utils;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.context.support.ResourceBundleMessageSource;
+
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+import java.util.Properties;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+
+class BackendI18nBundleContractTest {
+    private static final List<String> LOCALIZED_BUNDLES = List.of(
+            "messages_zh_CN.properties", "messages_zh_TW.properties",
+            "messages_en_US.properties", "messages_vi.properties",
+            "messages_th_TH.properties");
+    private static final Pattern PLACEHOLDER = Pattern.compile("\\{\\d+}");
+
+    @Test
+    void everyLocalizedBundleHasTheSameNonBlankKeysAndPlaceholders() throws Exception {
+        Properties reference = load("messages_zh_TW.properties");
+        for (String bundle : LOCALIZED_BUNDLES) {
+            Properties candidate = load(bundle);
+            assertEquals(reference.stringPropertyNames(), candidate.stringPropertyNames(), bundle);
+            for (String key : reference.stringPropertyNames()) {
+                assertFalse(candidate.getProperty(key).isBlank(), bundle + " blank " + key);
+                assertEquals(placeholders(reference.getProperty(key)),
+                        placeholders(candidate.getProperty(key)), bundle + " placeholders " + key);
+            }
+        }
+    }
+
+    @Test
+    void messageSourceLoadsThaiMessagesAndFormatsArguments() {
+        ResourceBundleMessageSource source = new ResourceBundleMessageSource();
+        source.setBasename("i18n/messages");
+        source.setDefaultEncoding(StandardCharsets.UTF_8.name());
+        Locale thai = new Locale("th", "TH");
+        assertEquals("ราคาเสนอมีการเปลี่ยนแปลง โปรดยืนยันค่าบริการล่าสุดอีกครั้ง",
+                source.getMessage("flash.delivery.quote.changed", null, thai));
+        assertEquals("ป้อนรหัสผ่านผิด 3 ครั้ง บัญชีถูกล็อก 10 นาที",
+                source.getMessage("user.password.retry.limit.exceed", new Object[]{3, 10}, thai));
+    }
+
+    private Properties load(String file) throws Exception {
+        Properties properties = new Properties();
+        try (InputStream input = getClass().getClassLoader().getResourceAsStream("i18n/" + file)) {
+            assertNotNull(input, file);
+            properties.load(new InputStreamReader(input, StandardCharsets.UTF_8));
+        }
+        return properties;
+    }
+
+    private List<String> placeholders(String value) {
+        List<String> result = new ArrayList<>();
+        Matcher matcher = PLACEHOLDER.matcher(value);
+        while (matcher.find()) result.add(matcher.group());
+        return result;
+    }
+}
+```
+
+- [ ] **Step 2: 将泰语文件纳入闪送消息契约**
+
+在 `FlashDeliveryI18nContractTest.FILES` 末尾增加:
+
+```java
+"messages_th_TH.properties"
+```
+
+保留现有全部闪送与地址消息 key。
+
+- [ ] **Step 3: 创建完整泰语语言包**
+
+以 `messages_zh_TW.properties` 的 key 和顺序为基准创建 `messages_th_TH.properties`:
+
+- 复制全部 319 个 key,不复制繁体中文值。
+- 将每个值翻译为自然、简洁的泰语系统提示。
+- 保持 `{0}`、`{1}`、`{min}`、`{max}` 等占位符原样。
+- 保持 URL、HTTP(S)、PIN、TWD、LINE、Google、Apple、OMG、ATM、CVS 等技术或品牌标识原样。
+- 文件使用 UTF-8,不使用 `\uXXXX` 转义泰文。
+- 关键值必须精确包含:
+
+```properties
+not.null=* จำเป็นต้องกรอก
+user.login.success=เข้าสู่ระบบสำเร็จ
+no.action.success=ดำเนินการสำเร็จ
+flash.delivery.item.invalid=ข้อมูลจำนวน น้ำหนักรวม หรือรายละเอียดสิ่งของไม่ถูกต้อง
+flash.delivery.quote.changed=ราคาเสนอมีการเปลี่ยนแปลง โปรดยืนยันค่าบริการล่าสุดอีกครั้ง
+user.password.retry.limit.exceed=ป้อนรหัสผ่านผิด {0} ครั้ง บัญชีถูกล็อก {1} นาที
+```
+
+- [ ] **Step 4: 静态检查资源和测试差异,暂不运行 Maven**
+
+运行:
+
+```powershell
+git diff --check -- ruoyi-admin/src/main/resources/i18n/messages_th_TH.properties ruoyi-admin/src/test/java/com/ruoyi/common/utils/BackendI18nBundleContractTest.java ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryI18nContractTest.java
+```
+
+预期:无错误输出。按全局约束,将测试执行延后到 Task 4。
+
+- [ ] **Step 5: 提交泰语消息资源**
+
+```powershell
+$paths=@(
+  'ruoyi-admin/src/main/resources/i18n/messages_th_TH.properties',
+  'ruoyi-admin/src/test/java/com/ruoyi/common/utils/BackendI18nBundleContractTest.java',
+  'ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryI18nContractTest.java'
+)
+git add -- $paths
+git diff --cached --name-only
+git diff --cached --check
+git commit -m "新增完整后端泰语提示" -m "补齐全部后端消息的泰语翻译,并通过键集合、占位符和典型消息契约防止语言包缺失或格式错误。"
+```
+
+### Task 3: 锁定台湾繁体默认语言和 HTTP 泰语切换
+
+**Files:**
+- Create: `ruoyi-admin/src/test/java/com/ruoyi/framework/config/BackendI18nConfigTest.java`
+- Modify: `ruoyi-framework/src/main/java/com/ruoyi/framework/config/I18nConfig.java`
+
+**Interfaces:**
+- Consumes: `I18nConfig.localeResolver()` 和 `I18nConfig.localeChangeInterceptor()`。
+- Produces: 统一使用 `LocaleUtils.parseLocale(String)` 的 HTTP 语言切换,确保无 `lang` 时为 `zh_TW`,三种泰语代码都切换为 `th_TH`。
+
+- [ ] **Step 1: 编写默认语言与 HTTP 切换测试源码**
+
+```java
+package com.ruoyi.framework.config;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.mock.web.MockHttpServletResponse;
+import org.springframework.web.servlet.DispatcherServlet;
+import org.springframework.web.servlet.LocaleResolver;
+import org.springframework.web.servlet.i18n.LocaleChangeInterceptor;
+
+import java.util.Locale;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+class BackendI18nConfigTest {
+    @Test
+    void defaultsToTraditionalChinese() {
+        LocaleResolver resolver = new I18nConfig().localeResolver();
+        assertEquals(new Locale("zh", "TW"),
+                resolver.resolveLocale(new MockHttpServletRequest()));
+    }
+
+    @Test
+    void switchesAllThaiAliasesThroughExistingLangParameter() throws Exception {
+        for (String language : new String[]{"th_TH", "th-TH", "th"}) {
+            I18nConfig config = new I18nConfig();
+            LocaleResolver resolver = config.localeResolver();
+            LocaleChangeInterceptor interceptor = config.localeChangeInterceptor();
+            MockHttpServletRequest request = new MockHttpServletRequest();
+            request.setAttribute(DispatcherServlet.LOCALE_RESOLVER_ATTRIBUTE, resolver);
+            request.setParameter("lang", language);
+            interceptor.preHandle(request, new MockHttpServletResponse(), new Object());
+            assertEquals(new Locale("th", "TH"), resolver.resolveLocale(request), language);
+        }
+    }
+}
+```
+
+- [ ] **Step 2: 让 HTTP 拦截器复用白名单解析器**
+
+在 `I18nConfig` 导入 `LocaleUtils`,并只覆盖拦截器的 Locale 值解析;参数名继续使用 `lang`:
+
+```java
+import com.ruoyi.common.utils.LocaleUtils;
+
+@Bean
+public LocaleChangeInterceptor localeChangeInterceptor()
+{
+    LocaleChangeInterceptor lci = new LocaleChangeInterceptor()
+    {
+        @Override
+        protected Locale parseLocaleValue(String localeValue)
+        {
+            return LocaleUtils.parseLocale(localeValue);
+        }
+    };
+    lci.setParamName("lang");
+    return lci;
+}
+```
+
+`localeResolver()` 保持 `slr.setDefaultLocale(Locale.TRADITIONAL_CHINESE)`,不得修改默认语言。
+
+- [ ] **Step 3: 静态检查配置和测试,暂不运行 Maven**
+
+```powershell
+git diff --check -- ruoyi-framework/src/main/java/com/ruoyi/framework/config/I18nConfig.java ruoyi-admin/src/test/java/com/ruoyi/framework/config/BackendI18nConfigTest.java
+```
+
+预期:无错误输出。
+
+- [ ] **Step 4: 提交默认语言和 HTTP 切换契约**
+
+```powershell
+git add -- ruoyi-framework/src/main/java/com/ruoyi/framework/config/I18nConfig.java ruoyi-admin/src/test/java/com/ruoyi/framework/config/BackendI18nConfigTest.java
+git diff --cached --name-only
+git diff --cached --check
+git commit -m "锁定后端默认语言与泰语切换" -m "让 lang 参数复用受支持语言白名单解析,兼容三种泰语代码,并增加台湾繁体默认语言回归契约。"
+```
+
+### Task 4: 统一验证和交付检查
+
+**Files:**
+- Verify: `ruoyi-common/src/main/java/com/ruoyi/common/utils/LocaleUtils.java`
+- Verify: `ruoyi-framework/src/main/java/com/ruoyi/framework/config/I18nConfig.java`
+- Verify: `ruoyi-admin/src/main/resources/i18n/messages_th_TH.properties`
+- Verify: `ruoyi-admin/src/test/java/com/ruoyi/common/utils/LocaleUtilsTest.java`
+- Verify: `ruoyi-admin/src/test/java/com/ruoyi/common/utils/BackendI18nBundleContractTest.java`
+- Verify: `ruoyi-admin/src/test/java/com/ruoyi/framework/config/BackendI18nConfigTest.java`
+- Verify: `ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryI18nContractTest.java`
+
+**Interfaces:**
+- Consumes: Tasks 1 至 3 的全部生产代码、资源和测试源码。
+- Produces: JDK 21 下可验证的完整后端泰语提示支持。
+
+- [ ] **Step 1: 使用 JDK 21 运行全部相关定向测试**
+
+```powershell
+$env:JAVA_HOME='C:\Users\qmj\.jdks\graalvm-jdk-21.0.7'
+$env:PATH="$env:JAVA_HOME\bin;$env:PATH"
+mvn -pl ruoyi-admin -am "-Dtest=LocaleUtilsTest,BackendI18nBundleContractTest,BackendI18nConfigTest,FlashDeliveryI18nContractTest" "-Dsurefire.failIfNoSpecifiedTests=false" test
+```
+
+预期:四个测试类全部通过,Maven 输出 `BUILD SUCCESS`。
+
+- [ ] **Step 2: 使用 JDK 21 执行后端模块构建**
+
+```powershell
+$env:JAVA_HOME='C:\Users\qmj\.jdks\graalvm-jdk-21.0.7'
+$env:PATH="$env:JAVA_HOME\bin;$env:PATH"
+mvn -pl ruoyi-admin -am -DskipTests package
+```
+
+预期:`ruoyi-common`、`ruoyi-system`、`ruoyi-framework`、`ruoyi-quartz`、`ruoyi-generator` 和 `ruoyi-admin` 均为 `SUCCESS`。
+
+- [ ] **Step 3: 检查最终差异和未混入文件**
+
+```powershell
+git diff --check
+git status --short
+git log --oneline -4
+```
+
+确认泰语功能提交没有包含 `.claude/homunculus/observations.jsonl`,也没有包含此前未提交的闪送请求 DTO/Controller 注释修改。
+
+- [ ] **Step 4: 报告验证结果和提交 SHA**
+
+交付内容必须列出:
+
+- Locale 解析提交 SHA。
+- 泰语语言包提交 SHA。
+- 默认语言回归测试提交 SHA。
+- 实际执行的测试数量和 Maven 构建结果。
+- 明确说明默认语言仍是 `zh_TW`。
+- 明确说明未修改前端和业务数据多语言映射。

+ 109 - 0
docs/superpowers/plans/2026-09-07-remove-flash-pricing-lock.md

@@ -0,0 +1,109 @@
+# 移除闪送运价专用锁表实施计划
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** 删除闪送运价专用数据库锁表及调用,同时保留事务、重叠校验、配置版本校验和读取侧异常保护。
+
+**Architecture:** 平台运价增删改直接在现有 Spring 事务中执行,新增和修改先调用 `countOverlapping(...)` 校验;修改继续以 `config_version` 为条件更新。数据库迁移仅追加删表 SQL,不直接执行。
+
+**Tech Stack:** JDK 21、Spring Boot、MyBatis-Plus/MyBatis、MySQL、JUnit 5、Mockito。
+
+## Global Constraints
+
+- 默认分支为 `test`,提交标题和正文使用中文,不使用英文 Conventional Commits 前缀。
+- 只修改闪送运价锁相关代码、测试、规格和 SQL,不暂存工作区原有注释及 observations 文件。
+- 不直接执行数据库 SQL;所有迁移仅追加到 `updatesql/sql.md`。
+- 保留 `@Transactional`、`countOverlapping(...)`、`config_version` 更新条件和读取侧多时段异常检查。
+
+---
+
+### Task 1: 删除闪送运价专用数据库锁
+
+**Files:**
+- Modify: `ruoyi-system/src/test/java/com/ruoyi/system/mapper/flash/FlashDeliveryPricingMapperContractTest.java`
+- Modify: `ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryApplicationServiceTest.java`
+- Modify: `ruoyi-system/src/main/java/com/ruoyi/system/mapper/flash/FlashDeliveryPricingMapper.java`
+- Modify: `ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryApplicationService.java`
+- Modify: `specs/024-flash-delivery/spec.md`
+- Modify: `specs/024-flash-delivery/design.md`
+- Modify: `specs/024-flash-delivery/data-model.md`
+- Modify: `specs/024-flash-delivery/plan.md`
+- Modify: `specs/024-flash-delivery/tasks.md`
+- Modify: `updatesql/sql.md`
+
+**Interfaces:**
+- Consumes: `FlashDeliveryPricingMapper.countOverlapping(String, String, Long)`、`BaseMapper` CRUD 和 `FlashDeliveryPricing.configVersion`。
+- Produces: 不再暴露 `FlashDeliveryPricingMapper.lockPricing()`;运价接口和请求响应契约保持不变。
+
+- [x] **Step 1: 先让新增运价不依赖专用锁行的行为测试红灯**
+
+删除 `FlashDeliveryApplicationServiceTest.Fixture` 中的以下默认模拟,让既有 `createPricingPersistsImmediatelyActiveTimePeriod` 测试表达“数据库没有专用锁行时仍可新增运价”的目标行为:
+
+```java
+when(pricingMapper.lockPricing()).thenReturn("GLOBAL");
+```
+
+- [x] **Step 2: 运行测试确认旧实现红灯**
+
+```powershell
+$env:JAVA_HOME='C:\Users\qmj\.jdks\graalvm-jdk-21.0.7'
+$env:PATH="$env:JAVA_HOME\bin;$env:PATH"
+mvn -pl ruoyi-admin -am "-Dtest=FlashDeliveryApplicationServiceTest#createPricingPersistsImmediatelyActiveTimePeriod" "-Dsurefire.failIfNoSpecifiedTests=false" test
+```
+
+预期:`createPricingPersistsImmediatelyActiveTimePeriod` 因旧实现读取不到 `GLOBAL` 锁行而抛出 `ServiceException`。
+
+- [x] **Step 3: 删除最小生产锁逻辑并修正既有测试**
+
+从 Mapper 删除:
+
+```java
+@Select("""
+        select lock_key
+        from flash_delivery_pricing_lock
+        where lock_key = 'GLOBAL'
+        for update
+        """)
+String lockPricing();
+```
+
+从 `createPricing`、`updatePricing`、`deletePricing` 删除 `lockPricing();`,并删除服务层私有 `lockPricing()` 方法。Mapper 契约测试删除仅验证专用锁 SQL 的测试;应用服务测试删除 `lockPricing()` 的调用顺序断言,但继续验证重叠查询、插入、版本更新和删除行为。
+
+- [x] **Step 4: 同步规格和迁移 SQL**
+
+删除当前数据模型中的 `flash_delivery_pricing_lock`,在规格中明确低频管理场景接受并发新增的极低概率重叠风险;修改仍使用版本校验,读取仍拒绝多条匹配。在 `updatesql/sql.md` 末尾追加:
+
+```markdown
+## 2026-09-07 移除闪送运价专用锁表
+
+\`\`\`sql
+DROP TABLE IF EXISTS flash_delivery_pricing_lock;
+\`\`\`
+```
+
+更新 `tasks.md` 增加并完成本次规格、红灯、实现、验证任务。
+
+- [x] **Step 5: 运行定向测试和模块构建**
+
+```powershell
+$env:JAVA_HOME='C:\Users\qmj\.jdks\graalvm-jdk-21.0.7'
+$env:PATH="$env:JAVA_HOME\bin;$env:PATH"
+mvn -pl ruoyi-admin -am "-Dtest=FlashDeliveryPricingMapperContractTest,FlashDeliveryApplicationServiceTest,FlashDeliveryControllerContractTest" "-Dsurefire.failIfNoSpecifiedTests=false" test
+mvn -pl ruoyi-admin -am -DskipTests package
+```
+
+预期:定向测试与七模块构建均为 `BUILD SUCCESS`。
+
+- [x] **Step 6: 核对范围、中文提交并推送**
+
+```powershell
+git diff --check
+git status --short
+git add -- docs/superpowers/plans/2026-09-07-remove-flash-pricing-lock.md ruoyi-system/src/test/java/com/ruoyi/system/mapper/flash/FlashDeliveryPricingMapperContractTest.java ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryApplicationServiceTest.java ruoyi-system/src/main/java/com/ruoyi/system/mapper/flash/FlashDeliveryPricingMapper.java ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryApplicationService.java specs/024-flash-delivery/spec.md specs/024-flash-delivery/design.md specs/024-flash-delivery/data-model.md specs/024-flash-delivery/plan.md specs/024-flash-delivery/tasks.md updatesql/sql.md
+git diff --cached --name-only
+git diff --cached --check
+git commit -m "移除闪送运价专用锁表" -m "保留事务、时段重叠和版本校验,删除低频后台配置不需要的全局数据库行锁及迁移表。"
+git push origin test
+```
+
+预期:暂存清单不包含工作区原有注释文件和 `.claude/homunculus/observations.jsonl`。

+ 72 - 0
docs/superpowers/specs/2026-09-07-backend-thai-i18n-design.md

@@ -0,0 +1,72 @@
+# 后端泰语提示支持设计
+
+## 目标
+
+在不改变现有默认语言的前提下,为后端系统提示增加完整泰语支持。默认语言继续使用台湾繁体中文 `zh_TW`;只有请求或用户语言明确选择泰语时才返回泰语提示。
+
+本阶段仅处理后端系统消息,包括同步接口错误/成功提示,以及通过用户语言生成的推送和异步通知。平台管理端、商家管理端、App 页面文案,以及商品和门店等业务数据的泰语字段不在本阶段范围内。
+
+## 语言代码
+
+- 后端标准 Locale:`th_TH`。
+- 同时接受客户端常见写法:`th_TH`、`th-TH`、`th`。
+- 上述三种写法统一解析为泰国地区泰语 Locale。
+- 默认 Locale 保持 `zh_TW`,未知、空白或非法语言代码仍回退台湾繁体中文。
+- 现有 `zh_CN`、`zh_TW`、`en_US` 和 `vi` 行为必须保持兼容。
+
+## 语言资源
+
+新增 `ruoyi-admin/src/main/resources/i18n/messages_th_TH.properties`,以现有完整语言包为键集合,翻译所有后端提示,而不是只翻译闪送模块。
+
+要求:
+
+- 泰语包与 `messages_zh_CN.properties`、`messages_zh_TW.properties`、`messages_en_US.properties`、`messages_vi.properties` 的消息 key 完全一致。
+- 占位符名称、数量和顺序保持一致,例如 `{0}`、`{1}` 不得在翻译中丢失或调换语义。
+- 技术标识、订单号、HTTP、URL、PIN、TWD 等不可翻译的内容保持原义。
+- 缺失 key 仍由 Spring 的基础资源包回退,但契约测试必须阻止正常提交出现缺失 key。
+
+## Locale 解析和消息流
+
+### 同步 HTTP 请求
+
+保留现有 `SessionLocaleResolver` 和 `LocaleChangeInterceptor`,参数名继续使用 `lang`。拦截器通过同一个白名单解析器规范化语言代码,因此客户端使用 `?lang=th_TH`、`?lang=th-TH` 或 `?lang=th` 都会得到泰国地区泰语 Locale。默认 Locale 继续设置为台湾繁体中文。
+
+### 用户语言和异步消息
+
+修复 `LocaleUtils.parseLocale()` 当前无条件返回台湾繁体 Locale 的问题,改为白名单解析:
+
+- 简体中文:`zh_CN`、`zh-CN`、`zh`
+- 台湾繁体:`zh_TW`、`zh-TW`、`tw`
+- 英文:`en_US`、`en-US`、`en`
+- 越南语:`vi_VN`、`vi-VN`、`vi`
+- 泰语:`th_TH`、`th-TH`、`th`
+
+无法识别的值返回台湾繁体默认 Locale,不允许任意 Locale 绕过受支持语言范围。`getUserLocale()` 继续从现有 Redis 用户语言值读取并调用该解析器,使推送和异步通知可以使用泰语。
+
+本阶段不修改 `toLangCode()` 和 `getUserLanguageCode()` 的数字语言映射,因为它们用于商品、门店等业务数据语言选择,而泰语业务数据尚未纳入范围。这样可以避免把泰语错误映射到现有数据列。
+
+## 错误处理与兼容性
+
+- 请求未指定语言:返回台湾繁体提示。
+- 请求指定支持的泰语代码:返回泰语提示。
+- 用户保存泰语偏好:异步通知使用泰语提示。
+- 请求传入未知语言:回退台湾繁体提示,不抛出语言解析异常。
+- 泰语资源中缺少消息 key:Spring 可回退基础资源,但语言包契约测试应在交付前发现该问题。
+
+## 验证
+
+新增或扩展测试,至少覆盖:
+
+1. 五个受支持的本地化语言包(`zh_CN`、`zh_TW`、`en_US`、`vi`、`th_TH`)key 集合一致,泰语包包含全部现有后端消息 key。
+2. `th_TH`、`th-TH`、`th` 都解析为泰国地区泰语。
+3. 空值、未知语言仍解析为 `zh_TW`。
+4. 现有简中、繁中、英文、越南语解析保持正确。
+5. 从 Spring MessageSource 读取典型通用提示和闪送提示时返回泰语,并正确替换参数占位符。
+6. 使用 JDK 21 运行受影响模块的定向测试和编译。
+
+## 非目标
+
+- 不新增数据库泰语字段或数字语言代码。
+- 不翻译商品、分类、门店、活动等业务数据。
+- 不修改平台管理端、商家管理端或 App 的语言选择界面。
+- 不改变默认语言;默认始终为台湾繁体中文。

+ 33 - 0
docs/superpowers/specs/2026-09-07-remove-flash-pricing-lock-design.md

@@ -0,0 +1,33 @@
+# 移除闪送运价专用锁表设计
+
+## 背景与目标
+
+`flash_delivery_pricing_lock` 当前通过固定 `GLOBAL` 行和 `SELECT ... FOR UPDATE` 串行化平台运价增删改。闪送运价属于低频管理员配置,业务接受双击、重试或多页面同时操作时极低概率的并发新增冲突,因此移除专用锁表及锁代码,降低数据库结构和维护复杂度。
+
+## 保留行为
+
+- `flash_delivery_pricing` 继续保存全局统一、互不重叠的分时段运价。
+- 新增、修改和删除仍在 Spring 事务内执行。
+- 新增和修改仍通过 `countOverlapping(startTime, endTime, excludedId)` 检查时段重叠。
+- 修改仍以 `config_version` 作为更新条件,拒绝旧页面覆盖新配置。
+- 报价和创建订单继续要求目标时间只匹配一个运价时段;异常重叠数据仍显式报错。
+- 运价字段、计价公式、加急费、小费和订单价格快照均不改变。
+
+## 删除内容
+
+- 删除 `FlashDeliveryPricingMapper.lockPricing()` 及其 `SELECT ... FOR UPDATE`。
+- 删除运价新增、修改、删除入口对 `lockPricing()` 的调用和服务层辅助方法。
+- 删除要求锁表及 `FOR UPDATE` 存在的 Mapper 契约测试。
+- 在 `updatesql/sql.md` 追加 `DROP TABLE IF EXISTS flash_delivery_pricing_lock;`,仅记录,不直接执行数据库变更。
+- 更新闪送规格、设计、数据模型、计划和任务说明,不再将专用锁表作为当前架构组成。
+
+## 并发边界
+
+移除锁表后,两个并发新增请求可能同时通过重叠检查并各自写入。该风险由业务明确接受。修改请求仍受 `config_version` 乐观锁保护;读取侧继续检查匹配结果数量,因此即使出现异常重叠,也不会静默选择其中一条价格。
+
+## 验证
+
+- 先删除应用服务测试夹具对固定 `GLOBAL` 锁行的模拟,验证管理员新增运价不应依赖专用锁行;旧实现会因此失败。
+- 删除生产锁逻辑后,移除只验证锁方法调用顺序的内部实现断言,并运行 Mapper、应用服务和 Controller 闪送定向测试。
+- 使用 JDK 21 执行 `ruoyi-admin` 相关模块测试和构建。
+- 提交前检查暂存清单,不能包含工作区原有注释修改或 `.claude/homunculus/observations.jsonl`。

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

@@ -59,6 +59,17 @@ public class FlashDeliveryAdminController extends BaseController {
         return success();
     }
 
+    /**
+     * 分页查询平台闪送订单。
+     *
+     * @param pageNum 页码,从 1 开始,默认 1
+     * @param pageSize 每页数量,默认 10,服务端限制为 1 至 100
+     * @param status 订单状态,选填,精确匹配
+     * @param serviceType 业务场景,选填,仅支持 HELP_SEND 或 HELP_PICKUP
+     * @param orderNo 订单号关键字,选填,模糊匹配
+     * @param userId 发件用户 ID,选填,精确匹配
+     * @param riderId 骑手用户 ID,选填,精确匹配
+     */
     @PreAuthorize("@ss.hasPermi('flash:order:list')")
     @GetMapping("/orders")
     public AjaxResult orders(@RequestParam(defaultValue = "1") int pageNum,

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

@@ -20,7 +20,16 @@ public class FlashDeliveryRiderController extends BaseController {
     private final FlashDeliveryApplicationService service;
     public FlashDeliveryRiderController(FlashDeliveryApplicationService service) { this.service = service; }
 
-    /** 按外卖订单同名页签查询可抢任务或当前骑手自己的任务。 */
+    /**
+     * 按页签查询可抢任务或当前骑手自己的任务。
+     *
+     * @param token 登录令牌,用于解析当前骑手 ID
+     * @param page 页码,从 1 开始,默认 1
+     * @param size 每页数量,默认 10,服务端限制为 1 至 100
+     * @param tab 骑手任务页签:newTask、toPickup、delivering、completed 或 cancelled
+     * @param longitude 骑手当前经度;newTask 页签选填,传入时必须与纬度同时提供
+     * @param latitude 骑手当前纬度;newTask 页签选填,传入时必须与经度同时提供
+     */
     @GetMapping("/orders")
     @Auth
     public AjaxResult orders(@RequestHeader String token,

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

@@ -1,10 +1,12 @@
 package com.ruoyi.app.flashdelivery.controller;
 
 import com.ruoyi.app.flashdelivery.dto.*;
+import com.ruoyi.app.flashdelivery.exception.FlashDeliveryQuoteChangedException;
 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.utils.MessageUtils;
 import com.ruoyi.system.utils.Auth;
 import com.ruoyi.system.utils.JwtUtil;
 import org.springframework.web.bind.annotation.*;
@@ -34,16 +36,29 @@ public class FlashDeliveryUserController extends BaseController {
     @PostMapping("/orders")
     @Auth
     public AjaxResult create(@RequestHeader String token, @RequestBody FlashDeliveryCreateRequest request) {
-        return success(service.create(userId(token), request));
+        try {
+            return success(service.create(userId(token), request));
+        } catch (FlashDeliveryQuoteChangedException exception) {
+            return AjaxResult.error(MessageUtils.message("flash.delivery.quote.changed"),
+                    exception.getLatestQuote());
+        }
     }
 
-    /** 分页查询当前用户自己的闪送订单。 */
+    /**
+     * 分页查询当前用户参与的闪送订单。
+     *
+     * @param token 登录令牌,用于解析当前用户 ID
+     * @param page 页码,从 1 开始,默认 1
+     * @param size 每页数量,默认 10,服务端限制为 1 至 100
+     * @param role 订单角色;sender 表示我发的,receiver 表示我收的,不传按 sender 处理
+     */
     @GetMapping("/orders")
     @Auth
     public AjaxResult orders(@RequestHeader String token,
                              @RequestParam(defaultValue = "1") int page,
-                             @RequestParam(defaultValue = "10") int size) {
-        return success(service.userOrders(userId(token), page, size));
+                             @RequestParam(defaultValue = "10") int size,
+                             @RequestParam(required = false) String role) {
+        return success(service.userOrders(userId(token), page, size, role));
     }
 
     /** 查询本人订单详情,直接返回订单字段和按类型分组的履约图片。 */
@@ -53,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;
+}

+ 17 - 1
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryAddressRequest.java

@@ -7,14 +7,30 @@ import java.math.BigDecimal;
 /** 下单或报价使用的联系人和地理坐标快照。 */
 @Data
 public class FlashDeliveryAddressRequest {
+    /** 联系人姓名,必填,去除首尾空格后最长 64 个字符。 */
     private String name;
+
+    /** 联系电话,必填,去除首尾空格后最长 32 个字符。 */
     private String phone;
+
+    /** 完整主地址,必填,去除首尾空格后最长 255 个字符。 */
     private String address;
+
+    /** 门牌、楼层等详细地址,选填,去除首尾空格后最长 255 个字符。 */
     private String addressDetail;
-    /** 市/区仅用于安全区域展示,不得替代完整地址校验。 */
+
+    /** 城市,选填,最长 64 个字符;用于骑手接单前的安全区域展示。 */
     private String city;
+
+    /** 行政区,选填,最长 64 个字符;用于骑手接单前的安全区域展示。 */
     private String area;
+
+    /** 交接方式或联系说明,选填,最长 40 个字符。 */
     private String handoffMethod;
+
+    /** 地址经度,必填,取值范围为 -180 至 180。 */
     private BigDecimal longitude;
+
+    /** 地址纬度,必填,取值范围为 -90 至 90。 */
     private BigDecimal latitude;
 }

+ 28 - 7
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryCreateRequest.java

@@ -3,21 +3,42 @@ package com.ruoyi.app.flashdelivery.dto;
 import lombok.Data;
 import lombok.EqualsAndHashCode;
 
-import java.util.Date;
 import java.util.List;
 
 /** 创建闪送订单请求;服务端会重新计算父类中的路线和价格。 */
 @Data
 @EqualsAndHashCode(callSuper = true)
 public class FlashDeliveryCreateRequest extends FlashDeliveryQuoteRequest {
-    /** 客户端生成的幂等请求号,同一用户重复提交时返回原订单。 */
+    /**
+     * 客户端生成的幂等请求号,必填,去除首尾空格后最长 64 个字符。
+     * 同一用户使用相同请求号重复提交时,服务端直接返回第一次创建的订单。
+     */
     private String clientRequestId;
-    /** 立即送或预约送;为空时兼容为 NOW。 */
-    private String deliveryMode;
-    private Date scheduledPickupStartAt;
-    private Date scheduledPickupEndAt;
-    /** 为空时按补充原型默认启用 PIN。 */
+
+    /** 报价响应中的运价配置 ID,必填,创建时必须原样回传。 */
+    private Long pricingId;
+
+    /** 报价响应中的运价版本,必填,创建时必须原样回传。 */
+    private Integer pricingVersion;
+
+    /** 报价响应中的基础配送费,必填,整数 TWD,已包含距离费。 */
+    private Long quotedBaseDeliveryFee;
+
+    /** 报价响应中的距离附加费,必填,整数 TWD,已包含在基础配送费中。 */
+    private Long quotedDistanceFee;
+
+    /** 报价响应中的加急费,必填,整数 TWD;普通配送时为 0。 */
+    private Long quotedUrgentFee;
+
+    /** 报价响应中的订单总金额,必填,整数 TWD。 */
+    private Long quotedAmount;
+
+    /** 是否启用四位交付 PIN,选填;不传时默认为 true。 */
     private Boolean pinRequired;
+
+    /** 寄件图片地址,选填,最多 9 张;每项最长 1000 个字符,允许相对路径。 */
     private List<String> senderImageUrls;
+
+    /** 用户对本次闪送的备注,选填,最长 500 个字符。 */
     private String userNote;
 }

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

@@ -7,5 +7,6 @@ import lombok.EqualsAndHashCode;
 @Data
 @EqualsAndHashCode(callSuper = true)
 public class FlashDeliveryDeliverRequest extends FlashDeliveryProofRequest {
+    /** 四位交付 PIN;订单启用 PIN 时必填,未启用时可不传。 */
     private String pinCode;
 }

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

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

+ 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();

+ 8 - 1
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryOrderView.java

@@ -10,9 +10,12 @@ public class FlashDeliveryOrderView {
     private Long id;
     private String orderNo;
     private String serviceType;
+    private String deliveryType;
     private String status;
     private String packageType;
-    private String packageSize;
+    private Integer quantity;
+    private String weightRange;
+    private String specification;
     private String deliveryMode;
     private Date scheduledPickupStartAt;
     private Date scheduledPickupEndAt;
@@ -22,6 +25,10 @@ public class FlashDeliveryOrderView {
     private Integer distanceMeters;
     private String distanceSource;
     private Integer estimatedDurationSeconds;
+    private Long baseDeliveryFee;
+    private Long distanceFee;
+    private Long urgentFee;
+    private Long tipAmount;
     private Long amount;
     private String currency;
     private String userNote;

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

@@ -9,5 +9,8 @@ import java.math.BigDecimal;
 public class FlashDeliveryPriceBreakdown {
     private BigDecimal billableDistance;
     private Long distanceFee;
+    private Long baseDeliveryFee;
+    private Long urgentFee;
+    private Long tipAmount;
     private Long amount;
 }

+ 20 - 2
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryPricingRequest.java

@@ -4,15 +4,33 @@ import lombok.Data;
 
 import java.math.BigDecimal;
 
-/** 平台新增或修改闪送时段运价的请求。 */
+/** 平台新增或修改闪送时段运价的请求;开始时间必须早于结束时间,且各运价时段不能重叠。 */
 @Data
 public class FlashDeliveryPricingRequest {
-    private String serviceType;
+    /** 运价时段开始时间,必填,格式为 HH:mm,取值范围 00:00 至 23:59,包含该时刻。 */
     private String startTime;
+
+    /** 运价时段结束时间,必填,格式为 HH:mm,取值范围 00:01 至 24:00,不包含该时刻。 */
     private String endTime;
+
+    /** 起步距离,必填,单位为公里,必须大于 0 且不超过 999999.99;服务端按两位小数保存。 */
     private BigDecimal startingDistance;
+
+    /** 起步价,必填,单位为整数 TWD,必须大于 0。 */
     private Long startingFare;
+
+    /** 距离计价步长,必填,单位为公里,必须大于 0 且不超过 999999.99;服务端按两位小数保存。 */
     private BigDecimal distance;
+
+    /** 每个距离计价步长收取的运费,必填,单位为整数 TWD,必须大于 0。 */
     private Long freight;
+
+    /** 加急费比例,必填,单位为百分比,取值范围为 0 至 999999.99;服务端按两位小数保存。 */
+    private BigDecimal urgentRate;
+
+    /** 最低加急费,必填,单位为整数 TWD,必须大于或等于 0。 */
+    private Long minimumUrgentFee;
+
+    /** 当前运价配置版本;新增时不传,修改时必填,用于防止并发覆盖。 */
     private Integer configVersion;
 }

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

@@ -7,5 +7,9 @@ import java.util.List;
 /** 骑手取件或送达时提交的图片凭证,业务层要求 1 至 9 张有效 URL。 */
 @Data
 public class FlashDeliveryProofRequest {
+    /**
+     * 履约凭证图片 URL,必填,数量为 1 至 9 张。
+     * 每项最长 1000 个字符,允许相对路径。
+     */
     private List<String> imageUrls;
 }

+ 56 - 2
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryQuoteRequest.java

@@ -2,12 +2,66 @@ package com.ruoyi.app.flashdelivery.dto;
 
 import lombok.Data;
 
-/** 闪送报价请求,支持帮送、帮取和加急送。 */
+import java.util.Date;
+
+/** 闪送报价请求;帮送/帮取与普通/加急为两个独立维度。 */
 @Data
 public class FlashDeliveryQuoteRequest {
+    /**
+     * 业务场景,必填。
+     * HELP_SEND 表示帮送,HELP_PICKUP 表示帮取;该字段不表示是否加急。
+     */
     private String serviceType;
+
+    /**
+     * 配送等级,必填。
+     * NORMAL 表示普通配送,URGENT 表示一对一加急配送。
+     */
+    private String deliveryType;
+
+    /**
+     * 物品类别,必填。
+     * 可选值:DOCUMENT、GIFT、CLOTHING、BEAUTY、DAILY_NECESSITIES、
+     * FOOD_INGREDIENTS、ELECTRONICS、SMALL_APPLIANCE、OTHER。
+     */
     private String packageType;
-    private String packageSize;
+
+    /** 物品总数量,必填,必须为大于 0 的整数。 */
+    private Integer quantity;
+
+    /**
+     * 物品重量范围,必填。
+     * 可选值:UP_TO_5_KG、OVER_5_TO_10_KG、OVER_10_TO_15_KG、OVER_15_TO_20_KG。
+     */
+    private String weightRange;
+
+    /** 物品体积、尺寸或其他规格说明,选填,去除首尾空格后最长 255 个字符。 */
+    private String specification;
+
+    /** 骑手小费,必填,单位为整数新台币;没有小费时传 0,不参与加急费计算。 */
+    private Long tipAmount;
+
+    /**
+     * 取件方式,选填,默认 NOW。
+     * NOW 表示立即取件,SCHEDULED 表示预约取件。
+     */
+    private String deliveryMode;
+
+    /**
+     * 预约取件开始时间。
+     * deliveryMode=SCHEDULED 时必填,必须为当前时间之后且不超过未来三天;NOW 时不得传。
+     */
+    private Date scheduledPickupStartAt;
+
+    /**
+     * 预约取件结束时间。
+     * deliveryMode=SCHEDULED 时必填,必须比开始时间晚 30 分钟;NOW 时不得传。
+     */
+    private Date scheduledPickupEndAt;
+
+    /** 取件联系人、电话、地址、交接方式和经纬度,必填。 */
     private FlashDeliveryAddressRequest pickup;
+
+    /** 收件联系人、电话、地址、交接方式和经纬度,必填。 */
     private FlashDeliveryAddressRequest delivery;
 }

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

@@ -7,7 +7,14 @@ 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;
+    private java.util.Date scheduledPickupStartAt;
+    private java.util.Date scheduledPickupEndAt;
     private Long pricingId;
     private String startTime;
     private String endTime;
@@ -20,6 +27,11 @@ public class FlashDeliveryQuoteView {
     private Long freight;
     private BigDecimal billableDistance;
     private Long distanceFee;
+    private Long baseDeliveryFee;
+    private BigDecimal urgentRate;
+    private Long minimumUrgentFee;
+    private Long urgentFee;
+    private Long tipAmount;
     private Long amount;
     private String currency;
     private Integer pricingVersion;

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

@@ -5,5 +5,6 @@ import lombok.Data;
 /** 用户或平台取消闪送订单时提交的原因。 */
 @Data
 public class FlashDeliveryReasonRequest {
+    /** 取消原因,必填,去除首尾空格后不能为空且最长 500 个字符。 */
     private String reason;
 }

+ 8 - 1
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryRiderOrderListView.java

@@ -10,9 +10,12 @@ public class FlashDeliveryRiderOrderListView {
     private Long id;
     private String orderNo;
     private String serviceType;
+    private String deliveryType;
     private String status;
     private String packageType;
-    private String packageSize;
+    private Integer quantity;
+    private String weightRange;
+    private String specification;
     private String deliveryMode;
     private Date scheduledPickupStartAt;
     private Date scheduledPickupEndAt;
@@ -24,6 +27,10 @@ public class FlashDeliveryRiderOrderListView {
     private Integer pickupDistanceMeters;
     private Integer distanceMeters;
     private Integer estimatedDurationSeconds;
+    private Long baseDeliveryFee;
+    private Long distanceFee;
+    private Long urgentFee;
+    private Long tipAmount;
     private Long amount;
     private String currency;
     private Date createTime;

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

@@ -15,6 +15,8 @@ public class FlashDeliveryServiceView {
     private Long startingFare;
     private BigDecimal distance;
     private Long freight;
+    private BigDecimal urgentRate;
+    private Long minimumUrgentFee;
     private Integer pricingVersion;
     private String currency;
 }

+ 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;
+}

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

@@ -10,8 +10,13 @@ public class FlashDeliveryUserOrderListView {
     private Long id;
     private String orderNo;
     private String serviceType;
+    private String deliveryType;
     private String status;
+    /** 用户编辑待接单订单使用的乐观锁版本。 */
+    private Integer orderVersion;
     private String packageType;
+    private Integer quantity;
+    private String weightRange;
     private String deliveryMode;
     private Date scheduledPickupStartAt;
     private Date scheduledPickupEndAt;
@@ -20,6 +25,9 @@ public class FlashDeliveryUserOrderListView {
     private String deliveryAddress;
     private String deliveryDetailAddress;
     private Integer estimatedDurationSeconds;
+    private Long baseDeliveryFee;
+    private Long urgentFee;
+    private Long tipAmount;
     private Long amount;
     private String currency;
     private Date deliveredAt;

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

@@ -6,16 +6,39 @@ import java.math.BigDecimal;
 /** 用户地址簿新增/修改请求;userId 永远由 token 决定,不对客户端开放。 */
 @Data
 public class InfoAddressRequest {
+    /** 地址记录 ID;新增时不传,修改时必填且必须属于当前 token 用户。 */
     private Long id;
+
+    /** 联系人姓名,必填,去除首尾空格后最长 64 个字符。 */
     private String name;
+
+    /** 联系电话,必填,去除首尾空格后最长 32 个字符。 */
     private String phone;
+
+    /** 完整主地址,必填,去除首尾空格后最长 255 个字符。 */
     private String address;
+
+    /** 门牌、楼层等详细地址,选填,去除首尾空格后最长 255 个字符。 */
     private String addressDetail;
+
+    /** 地址经度,必填,取值范围为 -180 至 180。 */
     private BigDecimal longitude;
+
+    /** 地址纬度,必填,取值范围为 -90 至 90。 */
     private BigDecimal latitude;
+
+    /** 国家或地区,选填,去除首尾空格后最长 64 个字符。 */
     private String country;
+
+    /** 省、州或一级行政区,选填,去除首尾空格后最长 64 个字符。 */
     private String province;
+
+    /** 城市,选填,去除首尾空格后最长 64 个字符。 */
     private String city;
+
+    /** 区、县或二级行政区,选填,去除首尾空格后最长 64 个字符。 */
     private String area;
+
+    /** 地址附加信息,选填,去除首尾空格后最长 1000 个字符。 */
     private String annexes;
 }

+ 18 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/exception/FlashDeliveryQuoteChangedException.java

@@ -0,0 +1,18 @@
+package com.ruoyi.app.flashdelivery.exception;
+
+import com.ruoyi.app.flashdelivery.dto.FlashDeliveryQuoteView;
+
+/** 创建订单时客户端报价已过期,携带服务端最新报价供用户重新确认。 */
+public class FlashDeliveryQuoteChangedException extends RuntimeException {
+    private static final long serialVersionUID = 1L;
+
+    private final FlashDeliveryQuoteView latestQuote;
+
+    public FlashDeliveryQuoteChangedException(FlashDeliveryQuoteView latestQuote) {
+        this.latestQuote = latestQuote;
+    }
+
+    public FlashDeliveryQuoteView getLatestQuote() {
+        return latestQuote;
+    }
+}

+ 311 - 64
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryApplicationService.java

@@ -5,9 +5,12 @@ import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
 import com.baomidou.mybatisplus.core.metadata.IPage;
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.ruoyi.app.flashdelivery.dto.*;
+import com.ruoyi.app.flashdelivery.exception.FlashDeliveryQuoteChangedException;
 import com.ruoyi.app.flashdelivery.route.FlashDeliveryRouteService;
 import com.ruoyi.app.flashdelivery.route.GeoPoint;
 import com.ruoyi.app.flashdelivery.route.RouteDistance;
+import com.ruoyi.app.order.RiderDeliveryExclusivityService;
+import com.ruoyi.app.order.RiderDeliveryLockService;
 import com.ruoyi.common.core.domain.entity.SysDictData;
 import com.ruoyi.common.exception.ServiceException;
 import com.ruoyi.common.utils.DictUtils;
@@ -25,6 +28,9 @@ import java.net.URI;
 import java.math.BigDecimal;
 import java.math.RoundingMode;
 import java.security.SecureRandom;
+import java.time.Instant;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
 import java.util.*;
 import java.util.function.Function;
 
@@ -38,11 +44,15 @@ import static com.ruoyi.system.domain.flash.FlashDeliveryStatus.*;
  */
 @Service
 public class FlashDeliveryApplicationService {
-    private static final List<String> SERVICE_TYPES = List.of("HELP_SEND", "HELP_PICKUP", "URGENT");
+    private static final List<String> SERVICE_TYPES = List.of("HELP_SEND", "HELP_PICKUP");
+    private static final Set<String> DELIVERY_TYPES = Set.of("NORMAL", "URGENT");
     private static final Set<String> PACKAGE_TYPES = Set.of("DOCUMENT", "GIFT", "CLOTHING", "BEAUTY",
             "DAILY_NECESSITIES", "FOOD_INGREDIENTS", "ELECTRONICS", "SMALL_APPLIANCE", "OTHER");
-    private static final Set<String> PACKAGE_SIZES = Set.of("SMALL", "MEDIUM", "LARGE");
+    private static final Set<String> WEIGHT_RANGES = Set.of("UP_TO_5_KG", "OVER_5_TO_10_KG",
+            "OVER_10_TO_15_KG", "OVER_15_TO_20_KG");
     private static final Set<String> DELIVERY_MODES = Set.of("NOW", "SCHEDULED");
+    private static final DateTimeFormatter PRICING_TIME_FORMAT = DateTimeFormatter.ofPattern("HH:mm");
+    private static final ZoneId TAIPEI = ZoneId.of("Asia/Taipei");
     private static final int MAX_DISTANCE_METERS = 40_000;
     private static final BigDecimal MAX_PRICING_DISTANCE = new BigDecimal("999999.99");
     private static final long SCHEDULE_SLOT_MILLIS = 30L * 60L * 1000L;
@@ -55,6 +65,8 @@ public class FlashDeliveryApplicationService {
     private final InfoUserMapper userMapper;
     private final FlashDeliveryRouteService routeService;
     private final FlashDeliveryPricingCalculator pricingCalculator;
+    private final RiderDeliveryLockService riderDeliveryLockService;
+    private final RiderDeliveryExclusivityService riderDeliveryExclusivityService;
 
     public FlashDeliveryApplicationService(FlashDeliveryOrderMapper orderMapper,
                                            FlashDeliveryPricingMapper pricingMapper,
@@ -62,7 +74,9 @@ public class FlashDeliveryApplicationService {
                                            FlashDeliveryOrderLogMapper logMapper,
                                            InfoUserMapper userMapper,
                                            FlashDeliveryRouteService routeService,
-                                           FlashDeliveryPricingCalculator pricingCalculator) {
+                                           FlashDeliveryPricingCalculator pricingCalculator,
+                                           RiderDeliveryLockService riderDeliveryLockService,
+                                           RiderDeliveryExclusivityService riderDeliveryExclusivityService) {
         this.orderMapper = orderMapper;
         this.pricingMapper = pricingMapper;
         this.imageMapper = imageMapper;
@@ -70,23 +84,33 @@ public class FlashDeliveryApplicationService {
         this.userMapper = userMapper;
         this.routeService = routeService;
         this.pricingCalculator = pricingCalculator;
+        this.riderDeliveryLockService = riderDeliveryLockService;
+        this.riderDeliveryExclusivityService = riderDeliveryExclusivityService;
     }
 
     public FlashDeliveryHomeView home() {
         FlashDeliveryHomeView view = new FlashDeliveryHomeView();
-        view.setServices(SERVICE_TYPES.stream()
-                .map(this::findCurrentPricingOrNull)
-                .filter(Objects::nonNull)
-                .map(this::serviceView)
-                .toList());
+        view.setServiceTypes(SERVICE_TYPES);
+        view.setDeliveryTypes(List.of("NORMAL", "URGENT"));
+        FlashDeliveryPricing pricing = findPricingAtOrNull(pricingTargetTime(new Date()));
+        view.setServices(pricing == null ? List.of() : SERVICE_TYPES.stream()
+                .map(serviceType -> serviceView(pricing, serviceType)).toList());
         return view;
     }
 
     public FlashDeliveryQuoteView quote(FlashDeliveryQuoteRequest request) {
         // 报价只使用服务端路线距离和当前启用的计价配置,避免客户端篡改金额。
         QuoteContext context = calculateQuote(request);
+        return quoteView(request, context);
+    }
+
+    private FlashDeliveryQuoteView quoteView(FlashDeliveryQuoteRequest request, QuoteContext context) {
         FlashDeliveryQuoteView view = new FlashDeliveryQuoteView();
-        view.setServiceType(context.pricing().getServiceType());
+        view.setServiceType(request.getServiceType());
+        view.setDeliveryType(request.getDeliveryType());
+        view.setDeliveryMode(normalizeDeliveryMode(request.getDeliveryMode()));
+        view.setScheduledPickupStartAt(request.getScheduledPickupStartAt());
+        view.setScheduledPickupEndAt(request.getScheduledPickupEndAt());
         view.setPricingId(context.pricing().getId());
         view.setStartTime(context.pricing().getStartTime());
         view.setEndTime(context.pricing().getEndTime());
@@ -97,6 +121,8 @@ public class FlashDeliveryApplicationService {
         view.setStartingFare(context.pricing().getStartingFare());
         view.setDistance(context.pricing().getDistance());
         view.setFreight(context.pricing().getFreight());
+        view.setUrgentRate(context.pricing().getUrgentRate());
+        view.setMinimumUrgentFee(context.pricing().getMinimumUrgentFee());
         applyBreakdown(view, context.breakdown());
         view.setCurrency("TWD");
         view.setPricingVersion(context.pricing().getConfigVersion());
@@ -118,6 +144,10 @@ public class FlashDeliveryApplicationService {
             throw fail("flash.delivery.note.too.long");
         }
         QuoteContext quote = calculateQuote(request);
+        FlashDeliveryQuoteView latestQuote = quoteView(request, quote);
+        if (!matchesQuotedPrice(request, latestQuote)) {
+            throw new FlashDeliveryQuoteChangedException(latestQuote);
+        }
         Date now = new Date();
         String deliveryMode = normalizeDeliveryMode(request.getDeliveryMode());
         validateSchedule(deliveryMode, request.getScheduledPickupStartAt(), request.getScheduledPickupEndAt(), now);
@@ -128,9 +158,12 @@ public class FlashDeliveryApplicationService {
         order.setClientRequestId(requestId);
         order.setUserId(userId);
         order.setServiceType(request.getServiceType());
+        order.setDeliveryType(request.getDeliveryType());
         order.setStatus(WAITING_ACCEPTANCE);
         order.setPackageType(request.getPackageType());
-        order.setPackageSize(request.getPackageSize());
+        order.setQuantity(request.getQuantity());
+        order.setWeightRange(request.getWeightRange());
+        order.setSpecification(trimToNull(request.getSpecification()));
         order.setDeliveryMode(deliveryMode);
         order.setScheduledPickupStartAt("SCHEDULED".equals(deliveryMode) ? request.getScheduledPickupStartAt() : null);
         order.setScheduledPickupEndAt("SCHEDULED".equals(deliveryMode) ? request.getScheduledPickupEndAt() : null);
@@ -138,6 +171,7 @@ public class FlashDeliveryApplicationService {
         order.setDeliveryPinCode(pinRequired ? String.format(Locale.ROOT, "%04d", PIN_RANDOM.nextInt(10_000)) : null);
         copyAddress(request.getPickup(), order, true);
         copyAddress(request.getDelivery(), order, false);
+        order.setReceiverUserId(resolveReceiverUserId(order.getDeliveryPhone()));
         order.setDistanceMeters(quote.route().distanceMeters());
         order.setDistanceSource(quote.route().source());
         order.setEstimatedDurationSeconds(quote.route().durationSeconds());
@@ -153,6 +187,11 @@ public class FlashDeliveryApplicationService {
         order.setFreight(quote.pricing().getFreight());
         // 保存计价快照,后台调价只影响新订单,不追溯修改历史订单金额。
         order.setDistanceFee(quote.breakdown().getDistanceFee());
+        order.setBaseDeliveryFee(quote.breakdown().getBaseDeliveryFee());
+        order.setUrgentRate(quote.pricing().getUrgentRate());
+        order.setMinimumUrgentFee(quote.pricing().getMinimumUrgentFee());
+        order.setUrgentFee(quote.breakdown().getUrgentFee());
+        order.setTipAmount(quote.breakdown().getTipAmount());
         order.setUserNote(trimToNull(request.getUserNote()));
         order.setVersion(0);
         order.setCreateTime(now);
@@ -170,18 +209,150 @@ public class FlashDeliveryApplicationService {
         return participantDetail(order, true, false);
     }
 
-    public IPage<FlashDeliveryUserOrderListView> userOrders(Long userId, int pageNum, int pageSize) {
+    public IPage<FlashDeliveryUserOrderListView> userOrders(Long userId, int pageNum, int pageSize, String role) {
+        String selectedRole = hasText(role) ? role.trim().toLowerCase(Locale.ROOT) : "sender";
+        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("user_id", userId).orderByDesc("create_time");
+                .eq(participantColumn, userId).orderByDesc("create_time");
         return mapPage(orderMapper.selectPage(page(pageNum, pageSize), query), this::userOrderListView);
     }
 
     public FlashDeliveryOrderDetailView userDetail(Long userId, Long orderId) {
         FlashDeliveryOrder order = requireOrder(orderId);
-        if (!Objects.equals(userId, order.getUserId())) throw fail("flash.delivery.order.not.found");
+        if (!isParticipant(userId, order)) throw fail("flash.delivery.order.not.found");
         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);
@@ -198,9 +369,9 @@ public class FlashDeliveryApplicationService {
     @Transactional
     public void confirmReceipt(Long userId, Long orderId) {
         FlashDeliveryOrder order = requireOrder(orderId);
-        if (!Objects.equals(userId, order.getUserId())) throw fail("flash.delivery.order.not.found");
+        if (!isParticipant(userId, order)) throw fail("flash.delivery.order.not.found");
         Date now = new Date();
-        if (orderMapper.transitionByUser(orderId, userId, DELIVERED, COMPLETED, now) != 1) {
+        if (orderMapper.transitionByParticipant(orderId, userId, DELIVERED, COMPLETED, now) != 1) {
             throw fail("flash.delivery.complete.not.allowed");
         }
         writeLog(orderId, DELIVERED, COMPLETED, "USER", userId, null, now);
@@ -258,11 +429,17 @@ public class FlashDeliveryApplicationService {
         InfoUser rider = requireRider(riderId);
         // 抢单属于获取新任务,要求骑手承接闪送配送。
         if (!rider.supportsDeliveryType("FLASH")) throw fail("flash.delivery.rider.type.not.enabled");
-        Date now = new Date();
-        // Mapper 使用“待接单且 rider_id 为空”的条件更新,受影响行数为 1 才表示抢单成功。
-        if (orderMapper.accept(orderId, riderId, now) != 1) throw fail("flash.delivery.order.already.accepted");
-        writeLog(orderId, WAITING_ACCEPTANCE, ACCEPTED, "RIDER", riderId, null, now);
-        return participantDetail(requireOrder(orderId), false, false);
+        return riderDeliveryLockService.withLock(riderId, () -> {
+            FlashDeliveryOrder target = requireOrder(orderId);
+            riderDeliveryExclusivityService.assertCanAcceptFlash(riderId, target.getDeliveryType());
+            Date now = new Date();
+            // Mapper 使用“待接单且 rider_id 为空”的条件更新,受影响行数为 1 才表示抢单成功。
+            if (orderMapper.accept(orderId, riderId, now) != 1) {
+                throw fail("flash.delivery.order.already.accepted");
+            }
+            writeLog(orderId, WAITING_ACCEPTANCE, ACCEPTED, "RIDER", riderId, null, now);
+            return participantDetail(requireOrder(orderId), false, false);
+        });
     }
 
     @Transactional
@@ -286,13 +463,12 @@ public class FlashDeliveryApplicationService {
 
     public List<FlashDeliveryPricing> adminPricing() {
         return pricingMapper.selectList(new QueryWrapper<FlashDeliveryPricing>()
-                .orderByAsc("service_type", "start_time", "id"));
+                .orderByAsc("start_time", "id"));
     }
 
     @Transactional
     public FlashDeliveryPricing createPricing(Long adminId, FlashDeliveryPricingRequest request) {
         validatePricingRequest(request, false);
-        lockPricingServices(request.getServiceType());
         ensureNoPricingOverlap(request, null);
         Date now = new Date();
         FlashDeliveryPricing pricing = pricingFrom(request);
@@ -310,35 +486,33 @@ public class FlashDeliveryApplicationService {
         validatePricingRequest(request, true);
         FlashDeliveryPricing preliminary = pricingId == null ? null : pricingMapper.selectById(pricingId);
         if (preliminary == null) throw fail("flash.delivery.pricing.not.found");
-        lockPricingServices(preliminary.getServiceType(), request.getServiceType());
         FlashDeliveryPricing pricing = pricingMapper.selectById(pricingId);
         if (pricing == null) throw fail("flash.delivery.pricing.not.found");
-        if (!Objects.equals(preliminary.getServiceType(), pricing.getServiceType())) {
-            throw fail("flash.delivery.pricing.changed");
-        }
         ensureNoPricingOverlap(request, pricingId);
         Date now = new Date();
         UpdateWrapper<FlashDeliveryPricing> update = new UpdateWrapper<FlashDeliveryPricing>()
                 .eq("id", pricingId).eq("config_version", request.getConfigVersion())
-                .set("service_type", request.getServiceType())
                 .set("start_time", request.getStartTime())
                 .set("end_time", request.getEndTime())
                 .set("starting_distance", scaleDistance(request.getStartingDistance()))
                 .set("starting_fare", request.getStartingFare())
                 .set("distance", scaleDistance(request.getDistance()))
                 .set("freight", request.getFreight())
+                .set("urgent_rate", normalizeRate(request.getUrgentRate()))
+                .set("minimum_urgent_fee", request.getMinimumUrgentFee())
                 .set("updated_by", adminId)
                 .set("update_time", now)
                 .setSql("config_version = config_version + 1");
         // 版本条件防止两个管理员同时保存时后提交者静默覆盖先提交者。
         if (pricingMapper.update(null, update) != 1) throw fail("flash.delivery.pricing.changed");
-        pricing.setServiceType(request.getServiceType());
         pricing.setStartTime(request.getStartTime());
         pricing.setEndTime(request.getEndTime());
         pricing.setStartingDistance(scaleDistance(request.getStartingDistance()));
         pricing.setStartingFare(request.getStartingFare());
         pricing.setDistance(scaleDistance(request.getDistance()));
         pricing.setFreight(request.getFreight());
+        pricing.setUrgentRate(normalizeRate(request.getUrgentRate()));
+        pricing.setMinimumUrgentFee(request.getMinimumUrgentFee());
         pricing.setConfigVersion(request.getConfigVersion() + 1);
         pricing.setUpdatedBy(adminId);
         pricing.setUpdateTime(now);
@@ -351,12 +525,8 @@ public class FlashDeliveryApplicationService {
         if (preliminary == null) {
             throw fail("flash.delivery.pricing.not.found");
         }
-        lockPricingServices(preliminary.getServiceType());
         FlashDeliveryPricing pricing = pricingMapper.selectById(pricingId);
         if (pricing == null) throw fail("flash.delivery.pricing.not.found");
-        if (!Objects.equals(preliminary.getServiceType(), pricing.getServiceType())) {
-            throw fail("flash.delivery.pricing.changed");
-        }
         if (pricingMapper.deleteById(pricingId) != 1) throw fail("flash.delivery.pricing.not.found");
     }
 
@@ -411,12 +581,14 @@ public class FlashDeliveryApplicationService {
     private QuoteContext calculateQuote(FlashDeliveryQuoteRequest request) {
         if (request == null) throw fail("flash.delivery.request.required");
         validateServiceType(request.getServiceType());
+        validateDeliveryType(request.getDeliveryType());
         if (!hasText(request.getPackageType()) || !PACKAGE_TYPES.contains(request.getPackageType())) {
             throw fail("flash.delivery.package.type.invalid");
         }
-        if (!hasText(request.getPackageSize()) || !PACKAGE_SIZES.contains(request.getPackageSize())) {
-            throw fail("flash.delivery.package.size.invalid");
-        }
+        validateItem(request);
+        Date now = new Date();
+        String deliveryMode = normalizeDeliveryMode(request.getDeliveryMode());
+        validateSchedule(deliveryMode, request.getScheduledPickupStartAt(), request.getScheduledPickupEndAt(), now);
         validateAddress(request.getPickup());
         validateAddress(request.getDelivery());
         if (sameAddressText(request.getPickup(), request.getDelivery())
@@ -424,25 +596,26 @@ public class FlashDeliveryApplicationService {
                 && request.getPickup().getLongitude().compareTo(request.getDelivery().getLongitude()) == 0) {
             throw fail("flash.delivery.address.same");
         }
-        FlashDeliveryPricing pricing = findCurrentPricing(request.getServiceType());
+        Date pricingDate = "SCHEDULED".equals(deliveryMode) ? request.getScheduledPickupStartAt() : now;
+        FlashDeliveryPricing pricing = findPricingAt(pricingTargetTime(pricingDate));
         // 路线服务优先使用 Google Routes;不可用时在服务内部降级为球面直线距离。
         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");
         FlashDeliveryPriceBreakdown breakdown = pricingCalculator.calculateBreakdown(pricing,
-                route.distanceMeters());
+                route.distanceMeters(), request.getDeliveryType(), request.getTipAmount());
         return new QuoteContext(pricing, route, breakdown);
     }
 
-    private FlashDeliveryPricing findCurrentPricing(String serviceType) {
-        FlashDeliveryPricing pricing = findCurrentPricingOrNull(serviceType);
+    private FlashDeliveryPricing findPricingAt(String targetTime) {
+        FlashDeliveryPricing pricing = findPricingAtOrNull(targetTime);
         if (pricing == null) throw fail("flash.delivery.pricing.not.available");
         return pricing;
     }
 
-    private FlashDeliveryPricing findCurrentPricingOrNull(String serviceType) {
-        List<FlashDeliveryPricing> pricing = pricingMapper.selectCurrent(serviceType);
+    private FlashDeliveryPricing findPricingAtOrNull(String targetTime) {
+        List<FlashDeliveryPricing> pricing = pricingMapper.selectAtTime(targetTime);
         if (pricing == null || pricing.isEmpty()) return null;
         if (pricing.size() > 1) throw fail("flash.delivery.pricing.overlap");
         return pricing.get(0);
@@ -450,9 +623,9 @@ public class FlashDeliveryApplicationService {
 
     private void validatePricingRequest(FlashDeliveryPricingRequest request, boolean requireVersion) {
         if (request == null) throw fail("flash.delivery.pricing.invalid");
-        validateServiceType(request.getServiceType());
         BigDecimal startingDistance = normalizeDistance(request.getStartingDistance());
         BigDecimal distance = normalizeDistance(request.getDistance());
+        BigDecimal urgentRate = normalizeRate(request.getUrgentRate());
         if (!validStartTime(request.getStartTime()) || !validEndTime(request.getEndTime())
                 || toMinute(request.getStartTime()) >= toMinute(request.getEndTime())
                 || startingDistance == null || startingDistance.signum() <= 0
@@ -461,39 +634,30 @@ public class FlashDeliveryApplicationService {
                 || distance == null || distance.signum() <= 0
                 || distance.compareTo(MAX_PRICING_DISTANCE) > 0
                 || request.getFreight() == null || request.getFreight() <= 0
+                || urgentRate == null || urgentRate.signum() < 0
+                || urgentRate.compareTo(MAX_PRICING_DISTANCE) > 0
+                || request.getMinimumUrgentFee() == null || request.getMinimumUrgentFee() < 0
                 || requireVersion && (request.getConfigVersion() == null || request.getConfigVersion() <= 0)) {
             throw fail("flash.delivery.pricing.invalid");
         }
     }
 
     private void ensureNoPricingOverlap(FlashDeliveryPricingRequest request, Long excludedId) {
-        if (pricingMapper.countOverlapping(request.getServiceType(), request.getStartTime(),
-                request.getEndTime(), excludedId) > 0) {
+        if (pricingMapper.countOverlapping(request.getStartTime(), request.getEndTime(), excludedId) > 0) {
             throw fail("flash.delivery.pricing.overlap");
         }
     }
 
-    private void lockPricingServices(String... serviceTypes) {
-        Arrays.stream(serviceTypes)
-                .filter(Objects::nonNull)
-                .distinct()
-                .sorted()
-                .forEach(serviceType -> {
-                    if (!Objects.equals(serviceType, pricingMapper.lockServiceType(serviceType))) {
-                        throw fail("flash.delivery.pricing.invalid");
-                    }
-                });
-    }
-
     private FlashDeliveryPricing pricingFrom(FlashDeliveryPricingRequest request) {
         FlashDeliveryPricing pricing = new FlashDeliveryPricing();
-        pricing.setServiceType(request.getServiceType());
         pricing.setStartTime(request.getStartTime());
         pricing.setEndTime(request.getEndTime());
         pricing.setStartingDistance(scaleDistance(request.getStartingDistance()));
         pricing.setStartingFare(request.getStartingFare());
         pricing.setDistance(scaleDistance(request.getDistance()));
         pricing.setFreight(request.getFreight());
+        pricing.setUrgentRate(normalizeRate(request.getUrgentRate()));
+        pricing.setMinimumUrgentFee(request.getMinimumUrgentFee());
         return pricing;
     }
 
@@ -518,6 +682,10 @@ public class FlashDeliveryApplicationService {
         return value == null ? null : value.setScale(2, RoundingMode.HALF_UP);
     }
 
+    private BigDecimal normalizeRate(BigDecimal value) {
+        return value == null ? null : value.setScale(2, RoundingMode.HALF_UP);
+    }
+
     private void transitionWithProof(Long riderId, Long orderId, FlashDeliveryProofRequest request,
                                      String expected, String next, String proofType, String timeColumn) {
         List<String> urls = validateProofUrls(request == null ? null : request.getImageUrls(), true);
@@ -556,11 +724,10 @@ public class FlashDeliveryApplicationService {
         for (String value : values) {
             try {
                 String url = value == null ? null : value.trim();
-                URI uri = url == null ? null : URI.create(url);
-                if (uri == null || url.length() > 1000 || uri.getHost() == null
-                        || !("http".equalsIgnoreCase(uri.getScheme()) || "https".equalsIgnoreCase(uri.getScheme()))) {
+                if (url == null || url.isEmpty() || url.length() > 1000) {
                     throw new IllegalArgumentException();
                 }
+                URI.create(url);
                 urls.add(url);
             } catch (IllegalArgumentException exception) {
                 throw fail("flash.delivery.proof.url.invalid");
@@ -574,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));
         }
@@ -589,8 +757,12 @@ public class FlashDeliveryApplicationService {
         view.setId(order.getId());
         view.setOrderNo(order.getOrderNo());
         view.setServiceType(order.getServiceType());
+        view.setDeliveryType(order.getDeliveryType());
         view.setStatus(order.getStatus());
+        view.setOrderVersion(order.getVersion());
         view.setPackageType(order.getPackageType());
+        view.setQuantity(order.getQuantity());
+        view.setWeightRange(order.getWeightRange());
         view.setDeliveryMode(order.getDeliveryMode());
         view.setScheduledPickupStartAt(order.getScheduledPickupStartAt());
         view.setScheduledPickupEndAt(order.getScheduledPickupEndAt());
@@ -599,6 +771,9 @@ public class FlashDeliveryApplicationService {
         view.setDeliveryAddress(order.getDeliveryAddress());
         view.setDeliveryDetailAddress(order.getDeliveryAddressDetail());
         view.setEstimatedDurationSeconds(order.getEstimatedDurationSeconds());
+        view.setBaseDeliveryFee(order.getBaseDeliveryFee());
+        view.setUrgentFee(order.getUrgentFee());
+        view.setTipAmount(order.getTipAmount());
         view.setAmount(order.getAmount());
         view.setCurrency(order.getCurrency());
         view.setDeliveredAt(order.getDeliveredAt());
@@ -612,9 +787,12 @@ public class FlashDeliveryApplicationService {
         view.setId(order.getId());
         view.setOrderNo(order.getOrderNo());
         view.setServiceType(order.getServiceType());
+        view.setDeliveryType(order.getDeliveryType());
         view.setStatus(order.getStatus());
         view.setPackageType(order.getPackageType());
-        view.setPackageSize(order.getPackageSize());
+        view.setQuantity(order.getQuantity());
+        view.setWeightRange(order.getWeightRange());
+        view.setSpecification(order.getSpecification());
         view.setDeliveryMode(order.getDeliveryMode());
         view.setScheduledPickupStartAt(order.getScheduledPickupStartAt());
         view.setScheduledPickupEndAt(order.getScheduledPickupEndAt());
@@ -627,6 +805,10 @@ public class FlashDeliveryApplicationService {
                 order.getPickupLongitude(), order.getPickupLatitude()));
         view.setDistanceMeters(order.getDistanceMeters());
         view.setEstimatedDurationSeconds(order.getEstimatedDurationSeconds());
+        view.setBaseDeliveryFee(order.getBaseDeliveryFee());
+        view.setDistanceFee(order.getDistanceFee());
+        view.setUrgentFee(order.getUrgentFee());
+        view.setTipAmount(order.getTipAmount());
         view.setAmount(order.getAmount());
         view.setCurrency(order.getCurrency());
         view.setCreateTime(order.getCreateTime());
@@ -653,9 +835,9 @@ public class FlashDeliveryApplicationService {
         view.setDeliveryImageUrls(delivery);
     }
 
-    private FlashDeliveryServiceView serviceView(FlashDeliveryPricing pricing) {
+    private FlashDeliveryServiceView serviceView(FlashDeliveryPricing pricing, String serviceType) {
         FlashDeliveryServiceView view = new FlashDeliveryServiceView();
-        view.setServiceType(pricing.getServiceType());
+        view.setServiceType(serviceType);
         view.setPricingId(pricing.getId());
         view.setStartTime(pricing.getStartTime());
         view.setEndTime(pricing.getEndTime());
@@ -663,6 +845,8 @@ public class FlashDeliveryApplicationService {
         view.setStartingFare(pricing.getStartingFare());
         view.setDistance(pricing.getDistance());
         view.setFreight(pricing.getFreight());
+        view.setUrgentRate(pricing.getUrgentRate());
+        view.setMinimumUrgentFee(pricing.getMinimumUrgentFee());
         view.setPricingVersion(pricing.getConfigVersion());
         view.setCurrency("TWD");
         return view;
@@ -673,9 +857,12 @@ public class FlashDeliveryApplicationService {
         view.setId(order.getId());
         view.setOrderNo(order.getOrderNo());
         view.setServiceType(order.getServiceType());
+        view.setDeliveryType(order.getDeliveryType());
         view.setStatus(order.getStatus());
         view.setPackageType(order.getPackageType());
-        view.setPackageSize(order.getPackageSize());
+        view.setQuantity(order.getQuantity());
+        view.setWeightRange(order.getWeightRange());
+        view.setSpecification(order.getSpecification());
         view.setDeliveryMode(order.getDeliveryMode());
         view.setScheduledPickupStartAt(order.getScheduledPickupStartAt());
         view.setScheduledPickupEndAt(order.getScheduledPickupEndAt());
@@ -685,6 +872,10 @@ public class FlashDeliveryApplicationService {
         view.setDistanceMeters(order.getDistanceMeters());
         view.setDistanceSource(order.getDistanceSource());
         view.setEstimatedDurationSeconds(order.getEstimatedDurationSeconds());
+        view.setBaseDeliveryFee(order.getBaseDeliveryFee());
+        view.setDistanceFee(order.getDistanceFee());
+        view.setUrgentFee(order.getUrgentFee());
+        view.setTipAmount(order.getTipAmount());
         view.setAmount(order.getAmount());
         view.setCurrency(order.getCurrency());
         view.setUserNote(includeSensitiveFields ? order.getUserNote() : null);
@@ -740,6 +931,9 @@ public class FlashDeliveryApplicationService {
     private void applyBreakdown(FlashDeliveryQuoteView view, FlashDeliveryPriceBreakdown breakdown) {
         view.setBillableDistance(breakdown.getBillableDistance());
         view.setDistanceFee(breakdown.getDistanceFee());
+        view.setBaseDeliveryFee(breakdown.getBaseDeliveryFee());
+        view.setUrgentFee(breakdown.getUrgentFee());
+        view.setTipAmount(breakdown.getTipAmount());
         view.setAmount(breakdown.getAmount());
     }
 
@@ -775,6 +969,28 @@ public class FlashDeliveryApplicationService {
         }
     }
 
+    private Long resolveReceiverUserId(String phone) {
+        if (!hasText(phone)) return null;
+        StringBuilder normalized = new StringBuilder(phone.length());
+        for (int index = 0; index < phone.length(); index++) {
+            char value = phone.charAt(index);
+            if (Character.isWhitespace(value) || value == '+' || value == '-'
+                    || value == '(' || value == ')') {
+                continue;
+            }
+            if (value < '0' || value > '9') return null;
+            normalized.append(value);
+        }
+        if (normalized.isEmpty()) return null;
+        List<Long> matches = userMapper.selectOrdinaryUserIdsByNormalizedPhone(normalized.toString());
+        return matches != null && matches.size() == 1 ? matches.get(0) : null;
+    }
+
+    private boolean isParticipant(Long userId, FlashDeliveryOrder order) {
+        return Objects.equals(userId, order.getUserId())
+                || Objects.equals(userId, order.getReceiverUserId());
+    }
+
     private InfoUser requireRider(Long riderId) {
         InfoUser rider = riderId == null ? null : userMapper.selectInfoUserByUserId(riderId);
         if (rider == null || !"2".equals(rider.getUserType())) throw fail("flash.delivery.rider.required");
@@ -806,6 +1022,37 @@ public class FlashDeliveryApplicationService {
         if (!hasText(value) || !SERVICE_TYPES.contains(value)) throw fail("flash.delivery.service.type.invalid");
     }
 
+    private void validateDeliveryType(String value) {
+        if (!hasText(value) || !DELIVERY_TYPES.contains(value)) {
+            throw fail("flash.delivery.delivery.type.invalid");
+        }
+    }
+
+    private void validateItem(FlashDeliveryQuoteRequest request) {
+        if (request.getQuantity() == null || request.getQuantity() <= 0
+                || !hasText(request.getWeightRange()) || !WEIGHT_RANGES.contains(request.getWeightRange())
+                || tooLong(request.getSpecification(), 255)) {
+            throw fail("flash.delivery.item.invalid");
+        }
+        if (request.getTipAmount() == null || request.getTipAmount() < 0) {
+            throw fail("flash.delivery.tip.invalid");
+        }
+    }
+
+    private String pricingTargetTime(Date date) {
+        return PRICING_TIME_FORMAT.format(Instant.ofEpochMilli(date.getTime())
+                .atZone(TAIPEI).toLocalTime());
+    }
+
+    private boolean matchesQuotedPrice(FlashDeliveryCreateRequest request, FlashDeliveryQuoteView latest) {
+        return Objects.equals(request.getPricingId(), latest.getPricingId())
+                && Objects.equals(request.getPricingVersion(), latest.getPricingVersion())
+                && Objects.equals(request.getQuotedBaseDeliveryFee(), latest.getBaseDeliveryFee())
+                && Objects.equals(request.getQuotedDistanceFee(), latest.getDistanceFee())
+                && Objects.equals(request.getQuotedUrgentFee(), latest.getUrgentFee())
+                && Objects.equals(request.getQuotedAmount(), latest.getAmount());
+    }
+
     private void validateOptionalServiceType(String value) {
         if (hasText(value)) validateServiceType(value);
     }

+ 22 - 3
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryPricingCalculator.java

@@ -11,15 +11,24 @@ import java.math.RoundingMode;
 @Component
 public class FlashDeliveryPricingCalculator {
     public Long calculate(FlashDeliveryPricing pricing, int distanceMeters) {
-        return calculateBreakdown(pricing, distanceMeters).getAmount();
+        return calculateBreakdown(pricing, distanceMeters, "NORMAL", 0L).getAmount();
     }
 
     public FlashDeliveryPriceBreakdown calculateBreakdown(FlashDeliveryPricing pricing, int distanceMeters) {
+        return calculateBreakdown(pricing, distanceMeters, "NORMAL", 0L);
+    }
+
+    public FlashDeliveryPriceBreakdown calculateBreakdown(FlashDeliveryPricing pricing, int distanceMeters,
+                                                           String deliveryType, Long tipAmount) {
         if (pricing == null || distanceMeters <= 0
                 || pricing.getStartingDistance() == null || pricing.getStartingDistance().signum() <= 0
                 || pricing.getStartingFare() == null || pricing.getStartingFare() <= 0
                 || pricing.getDistance() == null || pricing.getDistance().signum() <= 0
-                || pricing.getFreight() == null || pricing.getFreight() <= 0) {
+                || pricing.getFreight() == null || pricing.getFreight() <= 0
+                || pricing.getUrgentRate() == null || pricing.getUrgentRate().signum() < 0
+                || pricing.getMinimumUrgentFee() == null || pricing.getMinimumUrgentFee() < 0
+                || !("NORMAL".equals(deliveryType) || "URGENT".equals(deliveryType))
+                || tipAmount == null || tipAmount < 0) {
             throw new IllegalArgumentException("invalid pricing or distance");
         }
 
@@ -43,7 +52,17 @@ public class FlashDeliveryPricingCalculator {
         FlashDeliveryPriceBreakdown result = new FlashDeliveryPriceBreakdown();
         result.setBillableDistance(chargeableDistance.setScale(2, RoundingMode.HALF_UP));
         result.setDistanceFee(distanceFee);
-        result.setAmount(Math.addExact(pricing.getStartingFare(), distanceFee));
+        long baseDeliveryFee = Math.addExact(pricing.getStartingFare(), distanceFee);
+        long urgentFee = "URGENT".equals(deliveryType)
+                ? Math.max(pricing.getMinimumUrgentFee(), BigDecimal.valueOf(baseDeliveryFee)
+                .multiply(pricing.getUrgentRate())
+                .divide(BigDecimal.valueOf(100), 0, RoundingMode.HALF_UP)
+                .longValueExact())
+                : 0L;
+        result.setBaseDeliveryFee(baseDeliveryFee);
+        result.setUrgentFee(urgentFee);
+        result.setTipAmount(tipAmount);
+        result.setAmount(Math.addExact(Math.addExact(baseDeliveryFee, urgentFee), tipAmount));
         return result;
     }
 }

+ 19 - 3
ruoyi-admin/src/main/java/com/ruoyi/app/order/InfoAddressController.java

@@ -45,14 +45,25 @@ public class InfoAddressController extends BaseController {
                 : error(MessageUtils.message("no.map.exception"));
     }
 
-    /** 查询当前用户拥有的地址详情。 */
+    /**
+     * 查询当前用户拥有的地址详情。
+     *
+     * @param token 登录令牌,用于限定地址所有人
+     * @param id 地址记录 ID
+     */
     @Anonymous @Auth
     @GetMapping("/getaddressxq")
     public AjaxResult getaddressxq(@RequestHeader String token, @RequestParam Long id) {
         return success(MessageUtils.message("no.obtained.success"), addressBookService.detail(userId(token), id));
     }
 
-    /** 查询当前用户地址簿中距离给定坐标最近的地址。 */
+    /**
+     * 查询当前用户地址簿中距离给定坐标最近的地址。
+     *
+     * @param token 登录令牌,用于限定地址所有人
+     * @param longitude 当前经度,取值范围 -180 至 180
+     * @param latitude 当前纬度,取值范围 -90 至 90
+     */
     @Anonymous @Auth
     @GetMapping("/getzuijinaddress")
     public AjaxResult getzuijinaddress(@RequestHeader String token, @RequestParam BigDecimal longitude,
@@ -61,7 +72,12 @@ public class InfoAddressController extends BaseController {
                 infoAddressMapper.getmyaddress(longitude, latitude, userId(token).intValue()));
     }
 
-    /** 搜索当前用户地址簿,置顶地址优先。 */
+    /**
+     * 搜索当前用户地址簿,置顶地址优先。
+     *
+     * @param token 登录令牌,用于限定地址所有人
+     * @param keyword 地址或联系人搜索关键字,选填;不传时返回全部地址
+     */
     @Anonymous @Auth
     @GetMapping("/getaddress")
     public AjaxResult getaddress(@RequestHeader String token,

+ 27 - 2
ruoyi-admin/src/main/java/com/ruoyi/app/order/OrderLifecycleService.java

@@ -51,6 +51,8 @@ public class OrderLifecycleService {
     private final IPointsTransactionService pointsTransactionService;
     private final IPosOrderLinePaymentService linePaymentService;
     private final IPosOrderLineRefundService lineRefundService;
+    private final RiderDeliveryLockService riderDeliveryLockService;
+    private final RiderDeliveryExclusivityService riderDeliveryExclusivityService;
 
     public OrderLifecycleService(IPosOrderService posOrderService,
                                  OrderService billingService,
@@ -58,10 +60,9 @@ public class OrderLifecycleService {
                                  IUserWalletService userWalletService,
                                  IPointsTransactionService pointsTransactionService) {
         this(posOrderService, billingService, orderLogHelper, userWalletService,
-                pointsTransactionService, null, null);
+                pointsTransactionService, null, null, null, null);
     }
 
-    @Autowired
     public OrderLifecycleService(IPosOrderService posOrderService,
                                  OrderService billingService,
                                  OrderLogHelper orderLogHelper,
@@ -69,6 +70,20 @@ public class OrderLifecycleService {
                                  IPointsTransactionService pointsTransactionService,
                                  IPosOrderLinePaymentService linePaymentService,
                                  IPosOrderLineRefundService lineRefundService) {
+        this(posOrderService, billingService, orderLogHelper, userWalletService,
+                pointsTransactionService, linePaymentService, lineRefundService, null, null);
+    }
+
+    @Autowired
+    public OrderLifecycleService(IPosOrderService posOrderService,
+                                 OrderService billingService,
+                                 OrderLogHelper orderLogHelper,
+                                 IUserWalletService userWalletService,
+                                 IPointsTransactionService pointsTransactionService,
+                                 IPosOrderLinePaymentService linePaymentService,
+                                 IPosOrderLineRefundService lineRefundService,
+                                 RiderDeliveryLockService riderDeliveryLockService,
+                                 RiderDeliveryExclusivityService riderDeliveryExclusivityService) {
         this.posOrderService = posOrderService;
         this.billingService = billingService;
         this.orderLogHelper = orderLogHelper;
@@ -76,6 +91,8 @@ public class OrderLifecycleService {
         this.pointsTransactionService = pointsTransactionService;
         this.linePaymentService = linePaymentService;
         this.lineRefundService = lineRefundService;
+        this.riderDeliveryLockService = riderDeliveryLockService;
+        this.riderDeliveryExclusivityService = riderDeliveryExclusivityService;
     }
 
     public AdminOrderStatusContext getStatusContext(Long id) {
@@ -141,6 +158,14 @@ public class OrderLifecycleService {
 
     @Transactional(rollbackFor = Exception.class)
     public void acceptDeliveryByRider(Long id, Long riderId) {
+        riderDeliveryLockService.withLock(riderId, () -> {
+            riderDeliveryExclusivityService.assertCanAcceptFood(riderId);
+            acceptDeliveryByRiderLocked(id, riderId);
+            return null;
+        });
+    }
+
+    private void acceptDeliveryByRiderLocked(Long id, Long riderId) {
         PosOrder order = requireOrder(id);
         if (!Long.valueOf(0L).equals(order.getType()) || !Long.valueOf(0L).equals(order.getState())
                 || !Long.valueOf(0L).equals(order.getDeliveryStatus())

+ 61 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/order/RiderDeliveryExclusivityService.java

@@ -0,0 +1,61 @@
+package com.ruoyi.app.order;
+
+import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.common.utils.MessageUtils;
+import com.ruoyi.system.domain.PosOrder;
+import com.ruoyi.system.domain.flash.FlashDeliveryOrder;
+import com.ruoyi.system.mapper.flash.FlashDeliveryOrderMapper;
+import com.ruoyi.system.service.IPosOrderService;
+import org.springframework.stereotype.Service;
+
+import static com.ruoyi.system.domain.flash.FlashDeliveryStatus.ACCEPTED;
+import static com.ruoyi.system.domain.flash.FlashDeliveryStatus.PICKED_UP;
+
+/** 统一判断急送订单与外卖、普通闪送之间的骑手独占规则。 */
+@Service
+public class RiderDeliveryExclusivityService {
+    private static final String URGENT = "URGENT";
+
+    private final IPosOrderService foodOrders;
+    private final FlashDeliveryOrderMapper flashOrders;
+
+    public RiderDeliveryExclusivityService(IPosOrderService foodOrders,
+                                           FlashDeliveryOrderMapper flashOrders) {
+        this.foodOrders = foodOrders;
+        this.flashOrders = flashOrders;
+    }
+
+    public void assertCanAcceptFood(Long riderId) {
+        if (hasActiveFlash(riderId, true)) throw conflict();
+    }
+
+    public void assertCanAcceptFlash(Long riderId, String deliveryType) {
+        if (URGENT.equals(deliveryType)) {
+            if (hasActiveFood(riderId) || hasActiveFlash(riderId, false)) throw conflict();
+            return;
+        }
+        if (hasActiveFlash(riderId, true)) throw conflict();
+    }
+
+    private boolean hasActiveFood(Long riderId) {
+        QueryWrapper<PosOrder> query = new QueryWrapper<PosOrder>()
+                .eq("qs_id", riderId)
+                .in("delivery_status", 1, 2)
+                .in("state", 0, 1, 2)
+                .eq("after_sale_status", 0);
+        return foodOrders.count(query) > 0;
+    }
+
+    private boolean hasActiveFlash(Long riderId, boolean urgentOnly) {
+        QueryWrapper<FlashDeliveryOrder> query = new QueryWrapper<FlashDeliveryOrder>()
+                .eq("rider_id", riderId)
+                .in("status", ACCEPTED, PICKED_UP);
+        if (urgentOnly) query.eq("delivery_type", URGENT);
+        return flashOrders.selectCount(query) > 0;
+    }
+
+    private ServiceException conflict() {
+        return new ServiceException(MessageUtils.message("flash.delivery.rider.exclusive.conflict"));
+    }
+}

+ 70 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/order/RiderDeliveryLockService.java

@@ -0,0 +1,70 @@
+package com.ruoyi.app.order;
+
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.common.utils.MessageUtils;
+import org.redisson.api.RLock;
+import org.redisson.api.RedissonClient;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.support.TransactionSynchronization;
+import org.springframework.transaction.support.TransactionSynchronizationManager;
+
+import java.util.concurrent.TimeUnit;
+
+/** 串行化同一骑手跨外卖和闪送的接单检查与订单更新。 */
+@Service
+public class RiderDeliveryLockService {
+    private static final Logger log = LoggerFactory.getLogger(RiderDeliveryLockService.class);
+    private static final String PREFIX = "lock:delivery:rider:";
+    private static final long LOCK_WAIT_SECONDS = 3L;
+
+    private final RedissonClient redissonClient;
+
+    public RiderDeliveryLockService(RedissonClient redissonClient) {
+        this.redissonClient = redissonClient;
+    }
+
+    public <T> T withLock(Long riderId, LockedCall<T> call) {
+        if (riderId == null) throw new IllegalArgumentException("riderId is required");
+        RLock lock;
+        boolean acquired;
+        try {
+            lock = redissonClient.getLock(PREFIX + riderId);
+            // 不指定 leaseTime,由 Redisson watchdog 续期到事务完成。
+            acquired = lock.tryLock(LOCK_WAIT_SECONDS, TimeUnit.SECONDS);
+        } catch (InterruptedException interrupted) {
+            Thread.currentThread().interrupt();
+            throw new ServiceException(MessageUtils.message("no.operation.interrupted.try.again"));
+        } catch (RuntimeException redisFailure) {
+            log.error("Failed to acquire rider delivery lock, riderId={}", riderId, redisFailure);
+            throw new ServiceException(MessageUtils.message("no.system.busy.try.again"));
+        }
+        if (!acquired) {
+            log.warn("Rider delivery lock not acquired within {}s, riderId={}", LOCK_WAIT_SECONDS, riderId);
+            throw new ServiceException(MessageUtils.message("no.system.busy.try.again"));
+        }
+
+        boolean deferredUnlock = false;
+        try {
+            T result = call.call();
+            if (TransactionSynchronizationManager.isSynchronizationActive()) {
+                TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
+                    @Override
+                    public void afterCompletion(int status) {
+                        if (lock.isHeldByCurrentThread()) lock.unlock();
+                    }
+                });
+                deferredUnlock = true;
+            }
+            return result;
+        } finally {
+            if (!deferredUnlock && lock.isHeldByCurrentThread()) lock.unlock();
+        }
+    }
+
+    @FunctionalInterface
+    public interface LockedCall<T> {
+        T call();
+    }
+}

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

@@ -330,6 +330,10 @@ flash.delivery.pin.invalid=Invalid delivery PIN
 flash.delivery.scene.invalid=Invalid order status group
 flash.delivery.pricing.not.found=Pricing configuration does not exist
 flash.delivery.pricing.changed=Pricing configuration has changed, please refresh and retry
+flash.delivery.delivery.type.invalid=Unsupported delivery level
+flash.delivery.item.invalid=Item quantity, weight range or specification is invalid
+flash.delivery.tip.invalid=Rider tip must be a non-negative integer TWD amount
+flash.delivery.quote.changed=The quote has changed, please review the latest price
 flash.delivery.tab.invalid=Invalid flash delivery list tab
 flash.delivery.coordinates.invalid=Latitude and longitude must be provided in pairs and within valid ranges
 address.access.denied=Address does not exist or is not accessible
@@ -346,3 +350,7 @@ no.message.push.merchant.delivery.complete=Cửa hàng đã xác nhận giao đ
 no.order.selfdelivery.unpaid=Đơn hàng chưa thanh toán, vui lòng xác nhận đã thu tiền trước khi xác nhận giao hàng
 no.message.push.merchant.accepted.content=Cửa hàng đã nhận đơn và đang chuẩn bị món của bạn
 no.order.not.found=Không tìm thấy đơn hàng
+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=追加小费必须为正整数且不能超出金额范围

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

@@ -61,7 +61,7 @@ no.mendian.not.exist=The store no longer exists
 no.mendian.not.set.business.hours=The current store has not set business hours
 no.mendian.is.closed=The store is closed
 no.goods.not.exist=No longer exists, please choose another product to order
-no.g oods.is.off.shelf=Off the shelf, please choose another product to order
+no.goods.is.off.shelf=Off the shelf, please choose another product to order
 no.order.submit.success=Submit order successfully
 no.mendian.exist.classify.not.del=This store has classification data, please delete the classification first and then delete
 no.insufficient.user.security.deposit=Insufficient user security deposit
@@ -333,6 +333,10 @@ flash.delivery.pin.invalid=Invalid delivery PIN
 flash.delivery.scene.invalid=Invalid order status group
 flash.delivery.pricing.not.found=Pricing configuration does not exist
 flash.delivery.pricing.changed=Pricing configuration has changed, please refresh and retry
+flash.delivery.delivery.type.invalid=Unsupported delivery level
+flash.delivery.item.invalid=Item quantity, weight range or specification is invalid
+flash.delivery.tip.invalid=Rider tip must be a non-negative integer TWD amount
+flash.delivery.quote.changed=The quote has changed, please review the latest price
 flash.delivery.tab.invalid=Invalid flash delivery list tab
 flash.delivery.coordinates.invalid=Latitude and longitude must be provided in pairs and within valid ranges
 address.access.denied=Address does not exist or is not accessible
@@ -349,3 +353,7 @@ no.message.push.merchant.delivery.complete=The merchant has confirmed delivery o
 no.order.selfdelivery.unpaid=The order is unpaid; confirm payment receipt before completing delivery
 no.message.push.merchant.accepted.content=The merchant has accepted your order and is preparing it
 no.order.not.found=Order not found
+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

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

@@ -0,0 +1,341 @@
+# ข้อความแสดงข้อผิดพลาด
+not.null=* จำเป็นต้องกรอก
+user.jcaptcha.error=รหัสยืนยันไม่ถูกต้อง
+user.jcaptcha.expire=รหัสยืนยันหมดอายุแล้ว
+user.not.exists=ไม่มีผู้ใช้นี้หรือรหัสผ่านไม่ถูกต้อง
+user.password.not.match=ไม่มีผู้ใช้นี้หรือรหัสผ่านไม่ถูกต้อง
+user.password.retry.limit.count=ป้อนรหัสผ่านผิด {0} ครั้ง
+user.password.retry.limit.exceed=ป้อนรหัสผ่านผิด {0} ครั้ง บัญชีถูกล็อก {1} นาที
+user.password.delete=ขออภัย บัญชีของคุณถูกลบแล้ว
+user.blocked=ผู้ใช้ถูกระงับ โปรดติดต่อผู้ดูแลระบบ
+role.blocked=บทบาทถูกระงับ โปรดติดต่อผู้ดูแลระบบ
+login.blocked=IP ที่เข้าใช้งานถูกเพิ่มในบัญชีดำของระบบ
+user.logout.success=ออกจากระบบสำเร็จ
+
+length.not.valid=ความยาวต้องอยู่ระหว่าง {min} ถึง {max} อักขระ
+
+user.username.not.valid=* ต้องมี 2-20 อักขระ เป็นอักษรจีน ตัวอักษร ตัวเลข หรือขีดล่าง และห้ามขึ้นต้นด้วยตัวเลข
+user.password.not.valid=* 5-50 อักขระ
+
+user.email.not.valid=รูปแบบอีเมลไม่ถูกต้อง
+user.mobile.phone.number.not.valid=รูปแบบหมายเลขโทรศัพท์ไม่ถูกต้อง
+user.login.success=เข้าสู่ระบบสำเร็จ
+user.register.success=ลงทะเบียนสำเร็จ
+user.notfound=โปรดเข้าสู่ระบบอีกครั้ง
+user.forcelogout=ผู้ดูแลระบบบังคับให้ออกจากระบบ โปรดเข้าสู่ระบบอีกครั้ง
+user.unknown.error=เกิดข้อผิดพลาดที่ไม่ทราบสาเหตุ โปรดเข้าสู่ระบบอีกครั้ง
+
+## ข้อความอัปโหลดไฟล์
+upload.exceed.maxSize=ขนาดไฟล์ที่อัปโหลดเกินขีดจำกัด!<br/>ขนาดไฟล์สูงสุดที่อนุญาตคือ {0} MB!
+upload.filename.exceed.length=ชื่อไฟล์ที่อัปโหลดต้องไม่เกิน {0} อักขระ
+
+## สิทธิ์
+no.permission=คุณไม่มีสิทธิ์เข้าถึงข้อมูล โปรดติดต่อผู้ดูแลระบบเพื่อเพิ่มสิทธิ์ [{0}]
+no.create.permission=คุณไม่มีสิทธิ์สร้างข้อมูล โปรดติดต่อผู้ดูแลระบบเพื่อเพิ่มสิทธิ์ [{0}]
+no.update.permission=คุณไม่มีสิทธิ์แก้ไขข้อมูล โปรดติดต่อผู้ดูแลระบบเพื่อเพิ่มสิทธิ์ [{0}]
+no.delete.permission=คุณไม่มีสิทธิ์ลบข้อมูล โปรดติดต่อผู้ดูแลระบบเพื่อเพิ่มสิทธิ์ [{0}]
+no.export.permission=คุณไม่มีสิทธิ์ส่งออกข้อมูล โปรดติดต่อผู้ดูแลระบบเพื่อเพิ่มสิทธิ์ [{0}]
+no.view.permission=คุณไม่มีสิทธิ์ดูข้อมูล โปรดติดต่อผู้ดูแลระบบเพื่อเพิ่มสิทธิ์ [{0}]
+
+## ข้อความตอบกลับที่กำหนดเอง
+no.obtained.permission=รับข้อมูลสำเร็จ
+no.obtained.success=ค้นหาสำเร็จ
+no.action.success=ดำเนินการสำเร็จ
+no.action.fail=ดำเนินการไม่สำเร็จ
+no.data.exist.not.del=หมวดหมู่นี้มีสินค้าอยู่ จึงไม่สามารถลบได้
+no.comment.success=รีวิวสำเร็จ
+no.comment.fail=ไม่สำเร็จ โปรดลองอีกครั้ง
+no.comment.repeat=คุณได้รีวิวคำสั่งซื้อนี้แล้ว
+no.delete.success=ลบสำเร็จ
+no.delete.fail=ไม่สำเร็จ โปรดลองอีกครั้ง
+no.tip.lock.keywork=โปรดกรอกคำค้นหา
+no.load.success=โหลดสำเร็จ
+no.modify.success=แก้ไขสำเร็จ
+no.collection.success=เพิ่มในรายการโปรดสำเร็จ
+no.collection.cancel=ยกเลิกรายการโปรด
+no.success=สำเร็จ
+no.cash_on_delivery_amount.exceed.limit_amount=ยอดเก็บเงินปลายทางเกินวงเงินที่กำหนด
+no.exist.cash_on_delivery_order.incomplete=ยังมีคำสั่งซื้อแบบเก็บเงินปลายทางหลายรายการที่ยังไม่เสร็จสิ้น
+no.address.not.exist=ไม่มีที่อยู่จัดส่งนี้
+no.mendian.not.exist=ไม่มีร้านค้านี้แล้ว
+no.mendian.not.set.business.hours=ร้านค้านี้ยังไม่ได้กำหนดเวลาทำการ
+no.mendian.is.closed=ร้านค้าปิดให้บริการแล้ว
+no.goods.not.exist=ไม่มีสินค้านี้แล้ว โปรดเลือกสินค้าอื่นเพื่อสั่งซื้อ
+no.goods.is.off.shelf=สินค้าถูกนำออกจากชั้นวาง โปรดเลือกสินค้าอื่นเพื่อสั่งซื้อ
+no.order.submit.success=ส่งคำสั่งซื้อสำเร็จ
+no.mendian.exist.classify.not.del=ร้านค้านี้มีข้อมูลหมวดหมู่ โปรดลบหมวดหมู่ก่อนลบร้านค้า
+no.insufficient.user.security.deposit=เงินประกันของผู้ใช้ไม่เพียงพอ
+no.order.snatched=คำสั่งซื้อนี้ถูกรับไปแล้ว
+no.exist.undelivered.order=คุณมีคำสั่งซื้อที่ยังจัดส่งไม่สำเร็จ โปรดดำเนินการให้เสร็จก่อนรับงานใหม่
+no.user.not.vip=ผู้ใช้นี้ยังไม่ได้สมัครสมาชิก
+no.user.vip.expired=สมาชิกหมดอายุแล้ว
+no.user.add=เพิ่มผู้ใช้
+no.user.login.exist=ไม่สำเร็จ บัญชีเข้าสู่ระบบนี้มีอยู่แล้ว
+no.user.mobile.exist=ไม่สำเร็จ หมายเลขโทรศัพท์นี้มีอยู่แล้ว
+no.user.password.not.null=รหัสผ่านต้องไม่ว่าง
+no.user.jcaptcha.error=รหัสยืนยันไม่ถูกต้อง
+no.user.not.exist=ไม่มีผู้ใช้นี้
+no.user.login.success=เข้าสู่ระบบสำเร็จ
+no.user.password.error=รหัสผ่านไม่ถูกต้อง
+no.system.error=เกิดข้อผิดพลาดของระบบ
+no.sms.send.success=ส่ง SMS สำเร็จ
+no.user.password.modify.success=เปลี่ยนรหัสผ่านสำเร็จ
+no.user.password.old.error=รหัสผ่านเดิมไม่ถูกต้อง
+no.user.stop=ผู้ใช้ถูกปิดใช้งานแล้ว
+no.user.token.success=รับ token สำเร็จ
+no.user.info=ข้อมูลผู้ใช้
+no.user.deposit.not.exist=ไม่พบรายการเงินประกัน
+no.user.deposit.type.not.exist=ไม่มีเงินประกันประเภทนี้
+no.user.call.status.not.exist=ไม่มีสถานะการโทร จึงวางสายไม่ได้
+no.user.call.hangup.success=วางสายสำเร็จ
+no.user.call.not.exist=ไม่พบข้อมูลการโทรของผู้ใช้นี้
+no.user.call.calling.success=เริ่มการโทรสำเร็จ
+no.user.call.other.is.calling=อีกฝ่ายกำลังใช้สายอยู่ จึงไม่สามารถโทรได้
+no.user.call.accept.success=รับสายสำเร็จ
+no.user.call.request.success=ส่งคำขอโทรสำเร็จ
+no.user.not.vip=คุณไม่ได้เป็นสมาชิก
+no.upload.success=อัปโหลดสำเร็จ
+no.send.success=ส่งสำเร็จ
+no.send.fail=ส่งไม่สำเร็จ
+no.convert.success=แปลงสำเร็จ
+no.order.not.exist=ไม่มีคำสั่งซื้อนี้
+no.order.place.success=สั่งซื้อสำเร็จ
+no.order.id.error=รหัสคำสั่งซื้อไม่ถูกต้อง
+no.order.create.success=สร้างคำสั่งซื้อสำเร็จ
+no.message.push.message=ข้อความ
+no.message.push.delivery.personnel.receiving.order=ไรเดอร์รับคำสั่งซื้อแล้ว
+no.message.push.rider.accepted.merchant=ไรเดอร์รับคำสั่งซื้อแล้ว โปรดยืนยันและเตรียมอาหาร
+no.message.push.order.cancelled=ยกเลิกคำสั่งซื้อแล้ว
+no.order.rider.accept.required=โปรดรอให้ไรเดอร์รับคำสั่งซื้อก่อนดำเนินการ
+no.message.push.new.order=มีคำสั่งซื้อใหม่
+no.message.push.recharge.success=เติมเงินสำเร็จ
+no.message.push.payment.success=ชำระเงินสำเร็จ
+no.message.push.payment.fail=ชำระเงินไม่สำเร็จ
+no.message.push.end.call=สิ้นสุดการโทร
+no.message.push.call.hangup=อีกฝ่ายวางสายแล้ว
+no.message.push.call.calling=สายเรียกเข้า
+no.message.push.call.request=ส่งคำขอโทรหาคุณ
+no.message.push.call.accept=ข้อความรับสาย
+no.message.push.call.agree=ยอมรับคำขอโทรของคุณแล้ว
+no.export.excel.mendian.classify=ข้อมูลหมวดหมู่ร้านค้า
+no.export.excel.goods.classify=ข้อมูลหมวดหมู่สินค้า
+no.export.excel.collection=ข้อมูลรายการโปรด
+no.export.excel.food=ข้อมูล Food
+no.export.excel.posstore=ข้อมูล PosStore
+no.export.excel.servicetype=ข้อมูล ServiceType
+no.export.excel.distancemultiplier=ข้อมูล DistanceMultiplier
+no.export.excel.address=ข้อมูล Address
+no.export.excel.freight.data=ข้อมูล Freight
+no.export.excel.posorder=ข้อมูล PosOrder
+no.export.excel.review=ข้อมูล Review
+no.export.excel.taxiorder=ข้อมูล TaxiOrder
+no.export.excel.taxiprices=ข้อมูล TaxiPrices
+no.export.excel.bankcard=ข้อมูล BankCard
+no.export.excel.ipnlog=ข้อมูล IpnLog
+no.export.excel.billing=ข้อมูล Billing
+no.export.excel.salespromotion=ข้อมูล SalesPromotion
+no.export.excel.enterpriseintroduce=ข้อมูล EnterpriseIntroduce
+no.export.excel.userinfo=ข้อมูลผู้ใช้
+no.export.excel.margin=ข้อมูล Margin
+no.export.excel.riderposition=ข้อมูล RiderPosition
+no.export.excel.feedback=ข้อมูล Feedback
+no.export.excel.help=ข้อมูล Help
+no.export.excel.footprint=ข้อมูล Footprint
+no.export.excel.usermargin=ข้อมูล UserMargin
+no.export.excel.videocall=ข้อมูล VideoCall
+no.export.excel.viplist=ข้อมูล VipList
+no.export.excel.rights=ข้อมูล Rights
+no.export.excel.version=ข้อมูล Version
+no.userquanyi.not.exist=ไม่พบคูปอง
+userquanyi.isUsed=คูปองถูกใช้แล้ว
+no.activity.not.exist=ไม่มีโปรโมชันนี้
+no.map.exception=เกิดข้อผิดพลาดในการค้นหาเส้นทางบนแผนที่
+no.xiadanyz.message=เพื่อความปลอดภัยของบัญชี โปรดอัปเดตแล้วสั่งซื้ออีกครั้ง!
+no.youhuiquan.lqnodata=ไม่พบข้อมูลคูปอง
+no.youhuiquan.lqyhqgb=ปิดคูปองแล้ว
+no.youhuiquan.lqyhqolqw=คูปองถูกใช้สิทธิ์หมดแล้ว
+no.youhuiquan.lqyhqdy=จำนวนที่รับต้องมากกว่า 0
+no.youhuiquan.lqyhqysx=คุณรับคูปองนี้ถึงจำนวนสูงสุดแล้ว
+no.youhuiquan.lqyhqybxg=ข้อมูลถูกแก้ไขแล้ว โปรดรีเฟรชและลองอีกครั้ง
+no.points.insufficient=คะแนนของคุณไม่เพียงพอ
+no.points.use.enable=ปิดการใช้คะแนนแล้ว
+no.points.update.fail=อัปเดตกระเป๋าคะแนนไม่สำเร็จ โปรดลองอีกครั้ง
+no.points.use.fail=ยังไม่ได้เปิดใช้ส่วนลดจากคะแนน
+no.points.not.exist=ไม่มีกระเป๋าคะแนนของคุณ
+no.message.push.delivery.personnel.qspsz.order=ไรเดอร์กำลังจัดส่ง
+no.message.push.delivery.personnel.qsysd.order=ไรเดอร์จัดส่งถึงแล้ว
+no.wallet.noexist.userinfo=ไม่มีข้อมูลผู้ใช้
+no.user.state.no.audit=สถานะบัญชียังไม่ได้รับการตรวจสอบ
+no.system.busy.try.again=ระบบไม่ว่าง โปรดลองอีกครั้งภายหลัง
+no.operation.interrupted.try.again=การดำเนินการถูกขัดจังหวะ โปรดลองอีกครั้ง
+no.message.push.merchant.ready.title=ร้านค้าเตรียมอาหารเสร็จแล้ว
+no.message.push.merchant.ready.content=ร้านค้าเตรียมอาหารเสร็จแล้ว หมายเลขคำสั่งซื้อ:
+no.posorder.md.yh.mc.messag=คูปอง: {};
+no.posorder.md.yh.jiner.messag=ส่วนลด: {};
+no.posorder.md.cx.mc.messag=โปรโมชัน: {};
+no.posorder.md.cx.jiner.messag=ส่วนลดโปรโมชัน: {};
+no.wallet.common.cs.error=ดำเนินการไม่สำเร็จ โปรดติดต่อฝ่ายบริการลูกค้า
+
+# เข้าสู่ระบบผ่านผู้ให้บริการภายนอก (017-oauth-login)
+no.oauth.provider.blank=ต้องระบุวิธีเข้าสู่ระบบ
+no.oauth.credential.blank=ต้องระบุข้อมูลรับรองจากผู้ให้บริการภายนอก
+no.oauth.provider.unsupported=ไม่รองรับวิธีเข้าสู่ระบบ: {0}
+no.oauth.needphone=การเข้าสู่ระบบครั้งแรกต้องยืนยันหมายเลขโทรศัพท์
+no.oauth.tempkey.missing=ไม่มีข้อมูลรับรองการเข้าสู่ระบบ โปรดเข้าสู่ระบบอีกครั้ง
+no.oauth.tempkey.expired=ข้อมูลรับรองการเข้าสู่ระบบหมดอายุ โปรดเข้าสู่ระบบอีกครั้ง
+no.oauth.token.invalid=ข้อมูลรับรองการเข้าสู่ระบบ {0} ไม่ถูกต้อง
+no.oauth.token.expired=ข้อมูลรับรองการเข้าสู่ระบบ {0} หมดอายุแล้ว
+no.oauth.audience.mismatch=การตรวจสอบข้อมูลรับรอง {0} ไม่ผ่าน
+no.oauth.verify.fail=การตรวจสอบการเข้าสู่ระบบ {0} ไม่สำเร็จ: {1}
+
+# ตรวจสอบใบกำกับภาษีของคำสั่งซื้อ (010)
+no.invoice.choice.invalid=ประเภทใบกำกับภาษีไม่ถูกต้อง: {0}
+no.invoice.barcode.format=รูปแบบบาร์โค้ดมือถือไม่ถูกต้อง (ต้องเป็น / ตามด้วยตัวอักษรอังกฤษพิมพ์ใหญ่ ตัวเลข หรือ +-. จำนวน 7 ตัว)
+no.invoice.barcode.service.unavailable=บริการตรวจสอบบาร์โค้ดมือถือไม่พร้อมใช้งานชั่วคราว โปรดลองอีกครั้งภายหลัง
+no.invoice.barcode.notexist=ไม่มีบาร์โค้ดมือถือ โปรดตรวจสอบหรือเลือกใบกำกับภาษีแบบกระดาษ
+no.invoice.citizen.format=รูปแบบใบรับรองดิจิทัลประชาชนไม่ถูกต้อง (ต้องเป็นตัวอักษรอังกฤษพิมพ์ใหญ่ 2 ตัว + ตัวเลข 14 หลัก)
+no.invoice.lovecode.format=รูปแบบรหัสบริจาคไม่ถูกต้อง (ต้องเป็นตัวเลข 3-7 หลัก)
+no.invoice.lovecode.invalid=รหัสบริจาคไม่ถูกต้อง
+no.invoice.lovecode.verify.fail=ตรวจสอบรหัสบริจาคไม่สำเร็จ โปรดลองอีกครั้งภายหลัง
+no.invoice.ubn.format=รูปแบบเลขประจำตัวผู้ประกอบการไม่ถูกต้อง (ต้องเป็นตัวเลข 8 หลัก)
+no.invoice.company.email=ใบกำกับภาษีบริษัทต้องระบุอีเมลผู้ซื้อที่ถูกต้องเพื่อรับใบกำกับภาษี
+no.invoice.store.notsupport.barcode=ร้านค้านี้ยังไม่รองรับใบกำกับภาษีอิเล็กทรอนิกส์ จึงไม่สามารถตรวจสอบบาร์โค้ดมือถือได้
+no.invoice.store.notsupport.lovecode=ร้านค้านี้ยังไม่รองรับใบกำกับภาษีอิเล็กทรอนิกส์ จึงไม่สามารถตรวจสอบรหัสบริจาคได้
+
+# ตรวจสอบใบกำกับภาษีของคำสั่งซื้อ - พารามิเตอร์การขอใบกำกับภาษี (010)
+no.invoice.b2b.buyername.blank=ใบกำกับภาษี B2B ต้องระบุชื่อผู้ซื้อ
+no.invoice.b2b.buyeremail.blank=ใบกำกับภาษี B2B ต้องระบุอีเมลผู้ซื้อ
+no.invoice.b2b.nocarrier=ใบกำกับภาษี B2B ไม่รองรับพาหะอิเล็กทรอนิกส์
+no.invoice.donation.nocarrier=ใบกำกับภาษีบริจาคไม่รองรับพาหะอิเล็กทรอนิกส์
+no.invoice.donation.noubn=ใบกำกับภาษีบริจาคไม่รองรับเลขประจำตัวผู้ประกอบการ
+no.invoice.buyername.blank=ชื่อผู้ซื้อต้องไม่ว่าง
+no.invoice.carriertype.invalid=ประเภทพาหะต้องเป็น 0 บาร์โค้ดมือถือ / 1 ใบรับรองประชาชน / 2 สมาชิก ezPay
+no.invoice.carriernum.blank=หมายเลขพาหะต้องไม่ว่าง
+line.pay.credential.required=ต้องระบุร้านค้า LINE Pay, Channel ID และ Secret
+line.pay.credential.verify.unknown=บริการตรวจสอบข้อมูลรับรอง LINE Pay ไม่พร้อมใช้งานชั่วคราว โปรดลองอีกครั้งภายหลัง
+line.pay.credential.invalid=ตรวจสอบข้อมูลรับรอง LINE Pay ไม่สำเร็จ โปรดตรวจสอบ Channel ID และ Secret
+line.pay.credential.enabled=ตรวจสอบข้อมูลรับรอง LINE Pay สำเร็จและเปิดใช้งานแล้ว
+line.pay.order.required=ต้องระบุหมายเลขคำสั่งซื้อ LINE Pay
+line.pay.operation.failed=ดำเนินการ LINE Pay ไม่สำเร็จ โปรดลองอีกครั้งภายหลัง
+line.pay.order.not.found=ไม่มีคำสั่งซื้อ LINE Pay นี้
+line.pay.refund.not.allowed=สถานะคำสั่งซื้อปัจจุบันไม่อนุญาตให้คืนเงินผ่าน LINE Pay
+line.pay.refund.payment.not.found=ไม่พบรายการชำระเงิน LINE Pay ที่คืนเงินได้
+line.pay.refund.already.completed=คืนเงินรายการชำระเงิน LINE Pay แล้ว
+line.pay.order.completion.not.allowed=สถานะคำสั่งซื้อ LINE Pay ปัจจุบันไม่อนุญาตให้ดำเนินการให้เสร็จสิ้น
+line.pay.legacy.endpoint.disabled=โปรดใช้ API ดำเนินการคำสั่งซื้อเฉพาะสำหรับคำสั่งซื้อ LINE Pay
+line.pay.offline.merchant.required=บัญชีปัจจุบันไม่ใช่บัญชีร้านค้าที่ใช้งานได้
+line.pay.offline.single.store.required=คำสั่งซื้อที่ร้านค้าสแกนต้องมีร้านค้าเพียงหนึ่งแห่ง
+line.pay.offline.order.invalid=คำสั่งซื้อนี้ไม่สามารถชำระด้วยการสแกน LINE Pay ของร้านค้า
+line.pay.offline.order.forbidden=ไม่มีคำสั่งซื้อนี้หรือคำสั่งซื้อไม่ได้เป็นของร้านค้าปัจจุบัน
+line.pay.offline.mycode.invalid=LINE Pay My Code ต้องเป็นตัวเลข 18 หลัก
+line.pay.offline.attempt.blocked=คำสั่งซื้อนี้มีการชำระเงินที่กำลังดำเนินการ โปรดตรวจสอบสถานะก่อน
+line.pay.offline.not.enabled=ร้านค้านี้ยังไม่เปิดใช้การชำระเงินด้วยการสแกน LINE Pay
+line.pay.offline.attempt.not.found=คำสั่งซื้อนี้ไม่มีรายการชำระเงินด้วยการสแกน LINE Pay
+line.pay.offline.operation.failed=การชำระเงินด้วยการสแกน LINE Pay ยังไม่เสร็จสิ้น โปรดตรวจสอบสถานะภายหลัง
+rider.operation.role.required=เฉพาะไรเดอร์เท่านั้นที่ดำเนินการคำสั่งซื้อจัดส่งได้
+
+omg.payment.ddid.required=ต้องระบุหมายเลขคำสั่งซื้อสำหรับการชำระเงิน OMG
+omg.payment.merchantTradeNo.required=ต้องระบุ OMG MerchantTradeNo
+omg.payment.merchantTradeNo.invalid=OMG MerchantTradeNo ต้องเป็นตัวอักษรอังกฤษพิมพ์ใหญ่หรือตัวเลขไม่เกิน 20 ตัว
+omg.payment.storeId.required=ต้องระบุ ID ร้านค้า OMG
+omg.payment.merchantId.required=ต้องระบุหมายเลขร้านค้า OMG
+omg.payment.merchantId.invalid=หมายเลขร้านค้า OMG ต้องเป็นตัวอักษรอังกฤษหรือตัวเลขไม่เกิน 10 ตัว
+omg.payment.amount.invalid=ยอดชำระเงิน OMG ต้องมากกว่า 0
+omg.payment.credential.snapshot.required=ต้องระบุสแนปช็อตข้อมูลรับรองการชำระเงิน OMG
+omg.pay.auth.required=โปรดเข้าสู่ระบบก่อน
+omg.pay.order.required=ต้องระบุหมายเลขคำสั่งซื้อ
+omg.pay.order.not.available=ไม่มีคำสั่งซื้อนี้หรือคุณไม่มีสิทธิ์ดำเนินการ
+omg.pay.multi.store.unsupported=OMG ยังไม่รองรับคำสั่งซื้อจากหลายร้าน
+omg.pay.order.state.not.payable=สถานะคำสั่งซื้อปัจจุบันไม่สามารถชำระเงินได้
+omg.pay.order.already.paid=ชำระเงินคำสั่งซื้อแล้วหรือสถานะการชำระเงินไม่พร้อมใช้งาน
+omg.pay.order.amount.invalid=ยอดคำสั่งซื้อไม่ถูกต้อง
+omg.pay.payment.type.invalid=วิธีชำระเงินของคำสั่งซื้อไม่ใช่ OMG
+omg.pay.payment.method.invalid=โปรดเลือกบัตรเครดิตหรือ Apple Pay
+omg.pay.credential.unavailable=ร้านค้านี้ยังไม่เปิดใช้การชำระเงิน OMG
+omg.pay.attempt.exists=คำสั่งซื้อนี้มีรายการชำระเงินที่รอดำเนินการอยู่แล้ว
+omg.pay.configuration.invalid=การตั้งค่าการชำระเงิน OMG ไม่ถูกต้อง
+omg.pay.creation.failed=สร้างการชำระเงิน OMG ไม่สำเร็จ โปรดลองอีกครั้งภายหลัง
+omg.pay.query.not.available=ขณะนี้ไม่มีข้อมูลการชำระเงิน OMG ให้ตรวจสอบ
+omg.pay.query.failed=ตรวจสอบสถานะการชำระเงิน OMG ไม่สำเร็จ โปรดลองอีกครั้งภายหลัง
+omg.pay.retry.not.available=สถานะการชำระเงินปัจจุบันไม่สามารถเริ่มใหม่ได้ โปรดรีเฟรชผลการชำระเงินก่อน
+omg.pay.retry.failed=เริ่มการชำระเงิน OMG ใหม่ไม่สำเร็จ โปรดลองอีกครั้งภายหลัง
+omg.pay.refund.unavailable.test.environment=สภาพแวดล้อมทดสอบ OMG ไม่รองรับการคืนเงิน โปรดเปิดใช้ในสภาพแวดล้อมจริงก่อน
+omg.pay.refund.failed=ดำเนินการคืนเงิน OMG ไม่สำเร็จ โปรดลองอีกครั้งภายหลัง
+no.paytype.cash.merchant.only=การชำระด้วยเงินสดรองรับเฉพาะคำสั่งซื้อที่ร้านค้าสร้าง
+no.order.paytype.not.cash=คำสั่งซื้อนี้ไม่ได้ชำระด้วยเงินสด
+no.order.cash.already.paid=ยืนยันรับเงินสำหรับคำสั่งซื้อนี้แล้ว
+no.order.paytype.not.transfer=คำสั่งซื้อนี้ไม่ได้ชำระด้วยการโอนเงิน
+no.order.transfer.cancelled=ยกเลิกคำสั่งซื้อแล้ว ไม่สามารถยืนยันรับเงินได้
+no.order.transfer.already.paid=ยืนยันรับเงินสำหรับคำสั่งซื้อนี้แล้ว
+no.order.transfer.not.paid=ไรเดอร์จะรับคำสั่งซื้อได้หลังจากร้านค้ายืนยันรับเงินแล้วเท่านั้น
+no.order.cash.cancelled=ยกเลิกคำสั่งซื้อแล้ว ไม่สามารถยืนยันรับเงินสดได้
+no.user.audit.reject.reason.required=เมื่อตรวจสอบไม่ผ่าน ต้องระบุเหตุผลที่ปฏิเสธ
+merchant.account.unavailable=บัญชีร้านค้าไม่พร้อมใช้งาน
+merchant.owner.unavailable=บัญชีหลักของร้านค้าไม่พร้อมใช้งาน
+merchant.owner.required=เฉพาะบัญชีหลักของร้านค้าเท่านั้นที่ดำเนินการนี้ได้
+merchant.owner.not.found=ไม่พบบัญชีหลักของร้านค้า
+merchant.store.access.denied=คุณไม่มีสิทธิ์เข้าถึงร้านค้านี้
+merchant.order.not.found=ไม่พบคำสั่งซื้อ
+merchant.food.not.found=ไม่พบสินค้า
+merchant.session.invalid=เซสชันร้านค้าหมดอายุ โปรดเข้าสู่ระบบอีกครั้ง
+merchant.subaccount.request.required=ต้องระบุข้อมูลคำขอ
+merchant.subaccount.name.required=ต้องระบุชื่อบัญชีย่อย
+merchant.subaccount.phone.required=ต้องระบุหมายเลขโทรศัพท์ของบัญชีย่อย
+merchant.subaccount.phone.invalid=รูปแบบหมายเลขโทรศัพท์ของบัญชีย่อยไม่ถูกต้อง
+merchant.subaccount.phone.exists=หมายเลขโทรศัพท์นี้ถูกใช้แล้ว
+merchant.subaccount.password.required=ต้องระบุรหัสผ่านของบัญชีย่อย
+merchant.subaccount.store.required=โปรดเลือกร้านค้าที่รับผิดชอบอย่างน้อยหนึ่งแห่ง
+merchant.subaccount.status.required=ต้องระบุสถานะบัญชีย่อย
+merchant.subaccount.not.found=ไม่พบบัญชีย่อย
+merchant.subaccount.access.denied=คุณไม่มีสิทธิ์จัดการบัญชีย่อยนี้
+merchant.subaccount.create.failed=สร้างบัญชีย่อยไม่สำเร็จ
+merchant.subaccount.update.failed=อัปเดตบัญชีย่อยไม่สำเร็จ
+merchant.subaccount.platform.managed=บัญชีย่อยจัดการได้ผ่านช่องทางเฉพาะเท่านั้น
+no.user.delivery.type.invalid=ประเภทการจัดส่งไม่ถูกต้อง รองรับเฉพาะการส่งอาหารหรือส่งด่วน
+no.user.delivery.type.not.enabled=บัญชีนี้ยังไม่ได้เปิดใช้บริการส่งอาหาร
+
+# บริการส่งด่วน
+flash.delivery.request.required=ต้องระบุข้อมูลคำขอ
+flash.delivery.auth.required=โปรดเข้าสู่ระบบก่อน
+flash.delivery.client.request.id.invalid=ต้องระบุหมายเลขคำขอของไคลเอนต์และต้องไม่เกิน 64 อักขระ
+flash.delivery.note.too.long=หมายเหตุต้องไม่เกิน 500 อักขระ
+flash.delivery.service.type.invalid=ไม่รองรับประเภทบริการส่งด่วนนี้
+flash.delivery.service.unavailable=บริการส่งด่วนนี้ไม่พร้อมใช้งานในขณะนี้
+flash.delivery.address.invalid=ข้อมูลผู้ติดต่อ ที่อยู่ หรือพิกัดไม่ถูกต้อง
+flash.delivery.address.same=ที่อยู่รับและที่อยู่จัดส่งต้องไม่เหมือนกัน
+flash.delivery.order.not.found=ไม่มีคำสั่งซื้อส่งด่วนนี้หรือคุณไม่มีสิทธิ์เข้าถึง
+flash.delivery.order.already.accepted=คำสั่งซื้อนี้ถูกไรเดอร์รายอื่นรับแล้ว
+flash.delivery.rider.required=เฉพาะบัญชีไรเดอร์เท่านั้นที่ดำเนินการนี้ได้
+flash.delivery.proof.required=โปรดอัปโหลดรูปภาพหลักฐานอย่างน้อยหนึ่งรูป
+flash.delivery.proof.url.invalid=URL รูปภาพหลักฐานไม่ถูกต้อง
+flash.delivery.proof.too.many=อัปโหลดรูปภาพหลักฐานได้สูงสุด 9 รูป
+flash.delivery.transition.not.allowed=สถานะคำสั่งซื้อปัจจุบันไม่อนุญาตให้ดำเนินการนี้
+flash.delivery.cancel.reason.required=ต้องระบุเหตุผลในการยกเลิก
+flash.delivery.cancel.not.allowed=สถานะคำสั่งซื้อปัจจุบันไม่อนุญาตให้ยกเลิกด้วยวิธีนี้
+flash.delivery.complete.not.allowed=คำสั่งซื้อยังไม่สามารถดำเนินการให้เสร็จสิ้นได้
+flash.delivery.state.changed=สถานะคำสั่งซื้อเปลี่ยนแปลงแล้ว โปรดรีเฟรชและลองอีกครั้ง
+flash.delivery.pricing.invalid=ค่ากำหนดราคาต้องเป็นจำนวนบวกที่ถูกต้อง
+flash.delivery.pricing.not.available=ไม่มีอัตราค่าบริการที่ใช้ได้ในช่วงเวลานี้
+flash.delivery.pricing.overlap=ช่วงเวลาอัตราค่าบริการของประเภทเดียวกันต้องไม่ซ้อนทับกัน
+flash.delivery.package.type.invalid=โปรดเลือกประเภทพัสดุที่ถูกต้อง
+flash.delivery.package.size.invalid=โปรดเลือกช่วงน้ำหนักพัสดุที่ถูกต้อง
+flash.delivery.distance.too.far=ระยะทางจัดส่งต้องไม่เกิน 40 กิโลเมตร
+flash.delivery.mode.invalid=รูปแบบการจัดส่งไม่ถูกต้อง
+flash.delivery.schedule.invalid=ช่วงเวลานัดรับพัสดุไม่ถูกต้อง
+flash.delivery.pin.invalid=PIN สำหรับส่งมอบไม่ถูกต้อง
+flash.delivery.scene.invalid=พารามิเตอร์กลุ่มสถานะคำสั่งซื้อไม่ถูกต้อง
+flash.delivery.pricing.not.found=ไม่พบการตั้งค่าราคา
+flash.delivery.pricing.changed=การตั้งค่าราคาเปลี่ยนแปลงแล้ว โปรดรีเฟรชและลองอีกครั้ง
+flash.delivery.delivery.type.invalid=ไม่รองรับระดับการจัดส่งนี้
+flash.delivery.item.invalid=ข้อมูลจำนวน ช่วงน้ำหนัก หรือรายละเอียดสิ่งของไม่ถูกต้อง
+flash.delivery.tip.invalid=ทิปสำหรับไรเดอร์ต้องเป็นจำนวนเต็ม TWD ที่ไม่ติดลบ
+flash.delivery.quote.changed=ราคาเสนอมีการเปลี่ยนแปลง โปรดยืนยันค่าบริการล่าสุดอีกครั้ง
+flash.delivery.tab.invalid=แท็บรายการส่งด่วนไม่ถูกต้อง
+flash.delivery.coordinates.invalid=ต้องระบุละติจูดและลองจิจูดเป็นคู่และอยู่ในช่วงที่ถูกต้อง
+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=ทิปเพิ่มเติมต้องเป็นจำนวนเต็มบวกและไม่เกินวงเงินที่รองรับ

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

@@ -113,8 +113,8 @@ no.message.push.recharge.success=nạp tiền thành công
 no.message.push.payment.success=thanh toán thành công
 no.message.push.payment.fail=thanh toán thất bại
 no.message.push.end.call=Kết thúc cuộc gọi
-no.message.push.call.hangoup=Cuộc gọi đã bị ngắt kết nối
-no.message.push.call.call=cuộc gọi
+no.message.push.call.hangup=Cuộc gọi đã bị ngắt kết nối
+no.message.push.call.calling=cuộc gọi
 no.message.push.call.request=Yêu cầu gọi
 no.message.push.call.accept=Bật tin nhắn
 no.message.push.call.agree=đồng ý với yêu cầu gọi của bạn
@@ -333,6 +333,10 @@ flash.delivery.pin.invalid=Mã PIN giao hàng không chính xác
 flash.delivery.scene.invalid=Nhóm trạng thái đơn hàng không hợp lệ
 flash.delivery.pricing.not.found=Không tìm thấy cấu hình giá
 flash.delivery.pricing.changed=Cấu hình giá đã thay đổi, vui lòng tải lại và thử lại
+flash.delivery.delivery.type.invalid=Cấp độ giao hàng không được hỗ trợ
+flash.delivery.item.invalid=Số lượng, khoảng trọng lượng hoặc quy cách hàng hóa không hợp lệ
+flash.delivery.tip.invalid=Tiền boa cho tài xế phải là số nguyên TWD không âm
+flash.delivery.quote.changed=Báo giá đã thay đổi, vui lòng xác nhận lại chi phí mới nhất
 flash.delivery.tab.invalid=Tab danh sách giao hàng nhanh không hợp lệ
 flash.delivery.coordinates.invalid=Vĩ độ và kinh độ phải được cung cấp theo cặp và nằm trong phạm vi hợp lệ
 address.access.denied=Địa chỉ không tồn tại hoặc không có quyền truy cập
@@ -349,3 +353,7 @@ no.message.push.merchant.delivery.complete=Cửa hàng đã xác nhận giao đ
 no.order.selfdelivery.unpaid=Đơn hàng chưa thanh toán, vui lòng xác nhận đã thu tiền trước khi xác nhận giao hàng
 no.message.push.merchant.accepted.content=Cửa hàng đã nhận đơn và đang chuẩn bị món của bạn
 no.order.not.found=Không tìm thấy đơn hàng
+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ợ

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

@@ -334,6 +334,10 @@ flash.delivery.pin.invalid=交付PIN不正确
 flash.delivery.scene.invalid=订单状态分组参数无效
 flash.delivery.pricing.not.found=计价配置不存在
 flash.delivery.pricing.changed=计价配置已发生变化,请刷新后重试
+flash.delivery.delivery.type.invalid=不支持的配送等级
+flash.delivery.item.invalid=物品数量、重量范围或规格信息不正确
+flash.delivery.tip.invalid=骑手小费必须是非负整数新台币金额
+flash.delivery.quote.changed=报价已变化,请重新确认最新费用
 flash.delivery.tab.invalid=闪送列表页签无效
 flash.delivery.coordinates.invalid=经纬度必须成对提供且范围有效
 address.access.denied=地址不存在或无权访问
@@ -350,3 +354,7 @@ no.message.push.merchant.delivery.complete=商家已确认送达您的订单
 no.order.selfdelivery.unpaid=订单未支付,请先确认收款后再确认送达
 no.message.push.merchant.accepted.content=商家已接单,正在为您备餐
 no.order.not.found=订单不存在
+flash.delivery.rider.exclusive.conflict=骑手存在进行中的独占配送任务,暂时不能接此订单
+flash.delivery.edit.not.allowed=仅待接单且未分配骑手的订单可以修改
+flash.delivery.order.version.invalid=请提供有效的订单版本
+flash.delivery.tip.additional.invalid=追加小费必须为正整数且不能超出金额范围

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

@@ -334,6 +334,10 @@ flash.delivery.pin.invalid=交付PIN不正確
 flash.delivery.scene.invalid=訂單狀態分組參數無效
 flash.delivery.pricing.not.found=計價設定不存在
 flash.delivery.pricing.changed=計價設定已發生變更,請重新整理後重試
+flash.delivery.delivery.type.invalid=不支援的配送等級
+flash.delivery.item.invalid=物品數量、重量範圍或規格資訊不正確
+flash.delivery.tip.invalid=騎手小費必須是非負整數新台幣金額
+flash.delivery.quote.changed=報價已變更,請重新確認最新費用
 flash.delivery.tab.invalid=閃送列表頁籤無效
 flash.delivery.coordinates.invalid=經緯度必須成對提供且範圍有效
 address.access.denied=地址不存在或無權存取
@@ -350,3 +354,7 @@ no.message.push.merchant.delivery.complete=商家已確認送達您的訂單
 no.order.selfdelivery.unpaid=訂單未支付,請先確認收款後再確認送達
 no.message.push.merchant.accepted.content=商家已接單,正在為您備餐
 no.order.not.found=訂單不存在
+flash.delivery.rider.exclusive.conflict=騎手存在進行中的獨佔配送任務,暫時不能接此訂單
+flash.delivery.edit.not.allowed=僅待接單且未分配騎手的訂單可以修改
+flash.delivery.order.version.invalid=請提供有效的訂單版本
+flash.delivery.tip.additional.invalid=追加小費必須為正整數且不能超出金額範圍

+ 83 - 1
ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/controller/FlashDeliveryControllerContractTest.java

@@ -4,6 +4,8 @@ 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.FlashDeliveryQuoteView;
+import com.ruoyi.app.flashdelivery.exception.FlashDeliveryQuoteChangedException;
 import com.ruoyi.app.flashdelivery.service.FlashDeliveryApplicationService;
 import com.ruoyi.common.core.domain.AjaxResult;
 import com.ruoyi.common.utils.spring.SpringUtils;
@@ -53,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,
@@ -73,10 +102,12 @@ class FlashDeliveryControllerContractTest {
     @Test
     void appOrderListsUseSimplePageAndRiderFoodOrderParameters() throws Exception {
         Method userOrders = FlashDeliveryUserController.class.getMethod("orders",
-                String.class, int.class, int.class);
+                String.class, int.class, int.class, String.class);
         assertNotNull(userOrders.getParameters()[0].getAnnotation(RequestHeader.class));
         assertNotNull(userOrders.getParameters()[1].getAnnotation(RequestParam.class));
         assertNotNull(userOrders.getParameters()[2].getAnnotation(RequestParam.class));
+        assertNotNull(userOrders.getParameters()[3].getAnnotation(RequestParam.class));
+        assertTrue(!userOrders.getParameters()[3].getAnnotation(RequestParam.class).required());
 
         Method riderOrders = FlashDeliveryRiderController.class.getMethod("orders",
                 String.class, int.class, int.class, String.class, BigDecimal.class, BigDecimal.class);
@@ -114,6 +145,57 @@ 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);
+        FlashDeliveryCreateRequest request = new FlashDeliveryCreateRequest();
+        FlashDeliveryQuoteView latest = new FlashDeliveryQuoteView();
+        latest.setAmount(120L);
+        when(service.create(7L, request)).thenThrow(new FlashDeliveryQuoteChangedException(latest));
+
+        AjaxResult result = new FlashDeliveryUserController(service)
+                .create(JwtUtil.token("7", "user"), request);
+
+        assertSame(latest, result.get(AjaxResult.DATA_TAG));
+        assertTrue(((Number) result.get(AjaxResult.CODE_TAG)).intValue() != 200);
+    }
+
     @Test
     void everyAdminEndpointDeclaresPlatformPermission() {
         for (Method method : FlashDeliveryAdminController.class.getDeclaredMethods()) {

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

@@ -18,8 +18,10 @@ class FlashDeliveryOrderViewTest {
                 "pricingVersion", "cancelOperatorId", "cancelOperatorType", "version")) {
             assertFalse(fields.contains(forbidden), forbidden);
         }
-        for (String required : Set.of("packageType", "packageSize", "deliveryMode",
-                "scheduledPickupStartAt", "pinRequired", "pickup", "delivery")) {
+        for (String required : Set.of("serviceType", "deliveryType", "packageType", "quantity",
+                "weightRange", "specification", "deliveryMode",
+                "scheduledPickupStartAt", "pinRequired", "pickup", "delivery",
+                "baseDeliveryFee", "distanceFee", "urgentFee", "tipAmount", "amount")) {
             assertTrue(fields.contains(required), required);
         }
     }

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

@@ -11,7 +11,8 @@ class FlashDeliveryRiderOrderListViewTest {
     void userListContainsOnlyFieldsRequiredByOrderCards() {
         Set<String> fields = Stream.of(FlashDeliveryUserOrderListView.class.getDeclaredFields())
                 .map(java.lang.reflect.Field::getName).collect(Collectors.toSet());
-        Set<String> expected = Set.of("id", "orderNo", "serviceType", "status", "packageType",
+        Set<String> expected = Set.of("id", "orderNo", "serviceType", "deliveryType", "status", "packageType",
+                "quantity", "weightRange", "baseDeliveryFee", "urgentFee", "tipAmount",
                 "deliveryMode", "scheduledPickupStartAt", "scheduledPickupEndAt", "pickupAddress",
                 "pickupDetailAddress", "deliveryAddress", "deliveryDetailAddress",
                 "estimatedDurationSeconds", "amount", "currency", "deliveredAt", "createTime");
@@ -22,11 +23,13 @@ class FlashDeliveryRiderOrderListViewTest {
     void riderListContainsOnlyFieldsRequiredByOrderCards() {
         Set<String> fields = Stream.of(FlashDeliveryRiderOrderListView.class.getDeclaredFields())
                 .map(java.lang.reflect.Field::getName).collect(Collectors.toSet());
-        Set<String> expected = Set.of("id", "orderNo", "serviceType", "status", "packageType",
-                "packageSize", "deliveryMode", "scheduledPickupStartAt", "scheduledPickupEndAt",
+        Set<String> expected = Set.of("id", "orderNo", "serviceType", "deliveryType", "status", "packageType",
+                "quantity", "weightRange", "specification", "deliveryMode",
+                "scheduledPickupStartAt", "scheduledPickupEndAt",
                 "pinRequired", "pickupAddress", "pickupDetailAddress", "deliveryAddress",
                 "deliveryDetailAddress", "pickupDistanceMeters", "distanceMeters",
-                "estimatedDurationSeconds", "amount", "currency", "createTime");
+                "estimatedDurationSeconds", "baseDeliveryFee", "distanceFee", "urgentFee",
+                "tipAmount", "amount", "currency", "createTime");
         org.junit.jupiter.api.Assertions.assertEquals(expected, fields);
     }
 

+ 619 - 32
ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryApplicationServiceTest.java

@@ -3,14 +3,21 @@ 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;
 import com.ruoyi.app.flashdelivery.dto.FlashDeliveryQuoteRequest;
 import com.ruoyi.app.flashdelivery.dto.FlashDeliveryDeliverRequest;
 import com.ruoyi.app.flashdelivery.dto.FlashDeliveryRiderOrderPageView;
+import com.ruoyi.app.flashdelivery.exception.FlashDeliveryQuoteChangedException;
 import com.ruoyi.app.flashdelivery.route.FlashDeliveryRouteService;
 import com.ruoyi.app.flashdelivery.route.RouteDistance;
+import com.ruoyi.app.order.RiderDeliveryExclusivityService;
+import com.ruoyi.app.order.RiderDeliveryLockService;
 import com.ruoyi.system.domain.InfoUser;
 import com.ruoyi.system.domain.flash.FlashDeliveryOrder;
 import com.ruoyi.system.domain.flash.FlashDeliveryOrderImage;
@@ -37,15 +44,18 @@ import org.springframework.test.util.ReflectionTestUtils;
 import java.math.BigDecimal;
 import java.util.List;
 import java.util.Date;
+import java.util.Calendar;
 
 import static com.ruoyi.system.domain.flash.FlashDeliveryStatus.ACCEPTED;
 import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
 import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertNull;
 import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 import com.ruoyi.common.exception.ServiceException;
 import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
 import static org.mockito.ArgumentMatchers.eq;
 import static org.mockito.ArgumentMatchers.isNull;
 import static org.mockito.Mockito.mock;
@@ -55,6 +65,7 @@ import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
 import static org.mockito.Mockito.doAnswer;
 import static org.mockito.Mockito.inOrder;
+import static org.mockito.Mockito.doThrow;
 
 class FlashDeliveryApplicationServiceTest {
     private static ConfigurableListableBeanFactory originalBeanFactory;
@@ -78,7 +89,7 @@ class FlashDeliveryApplicationServiceTest {
     @Test
     void quoteUsesServerRouteAndCurrentTimePricing() {
         Fixture fixture = new Fixture();
-        when(fixture.pricingMapper.selectCurrent("HELP_SEND")).thenReturn(List.of(pricing()));
+        when(fixture.pricingMapper.selectAtTime(anyString())).thenReturn(List.of(pricing()));
         when(fixture.routeService.calculate(any(), any())).thenReturn(new RouteDistance(3001, 600, "ROUTE"));
 
         var quote = fixture.service.quote(request());
@@ -94,7 +105,7 @@ class FlashDeliveryApplicationServiceTest {
     @Test
     void quoteRejectsRoutesLongerThanFortyKilometres() {
         Fixture fixture = new Fixture();
-        when(fixture.pricingMapper.selectCurrent("HELP_SEND")).thenReturn(List.of(pricing()));
+        when(fixture.pricingMapper.selectAtTime(anyString())).thenReturn(List.of(pricing()));
         when(fixture.routeService.calculate(any(), any())).thenReturn(new RouteDistance(40001, 1800, "ROUTE"));
 
         assertThrows(ServiceException.class, () -> fixture.service.quote(request()));
@@ -153,9 +164,11 @@ class FlashDeliveryApplicationServiceTest {
     }
 
     @Test
-    void scheduledCreateGeneratesUserOnlyPinAndStoresSenderProof() {
+    void scheduledCreateGeneratesUserOnlyPinAndStoresRelativeSenderProof() {
         Fixture fixture = new Fixture();
-        when(fixture.pricingMapper.selectCurrent("HELP_SEND")).thenReturn(List.of(pricing()));
+        when(fixture.userMapper.selectOrdinaryUserIdsByNormalizedPhone("0922222222"))
+                .thenReturn(List.of(88L));
+        when(fixture.pricingMapper.selectAtTime(anyString())).thenReturn(List.of(pricing()));
         when(fixture.routeService.calculate(any(), any())).thenReturn(new RouteDistance(3200, 600, "ROUTE"));
         doAnswer(invocation -> {
             FlashDeliveryOrder order = invocation.getArgument(0);
@@ -165,6 +178,7 @@ class FlashDeliveryApplicationServiceTest {
         when(fixture.imageMapper.selectList(any(Wrapper.class))).thenReturn(List.of());
         when(fixture.logMapper.selectList(any(Wrapper.class))).thenReturn(List.of());
         FlashDeliveryCreateRequest request = createRequest();
+        request.setSenderImageUrls(List.of("/profile/upload/2026/09/07/sender.jpg"));
 
         var result = fixture.service.create(7L, request);
 
@@ -174,10 +188,16 @@ class FlashDeliveryApplicationServiceTest {
         verify(fixture.imageMapper).insert(image.capture());
         assertEquals("SENDER", image.getValue().getProofType());
         assertEquals("USER", image.getValue().getOperatorType());
+        assertEquals("/profile/upload/2026/09/07/sender.jpg", image.getValue().getImageUrl());
         ArgumentCaptor<FlashDeliveryOrder> saved = ArgumentCaptor.forClass(FlashDeliveryOrder.class);
         verify(fixture.orderMapper).insert(saved.capture());
         assertEquals("SCHEDULED", saved.getValue().getDeliveryMode());
+        assertEquals("NORMAL", saved.getValue().getDeliveryType());
         assertEquals("DOCUMENT", saved.getValue().getPackageType());
+        assertEquals(1, saved.getValue().getQuantity());
+        assertEquals("UP_TO_5_KG", saved.getValue().getWeightRange());
+        assertEquals("30 x 20 x 10 cm", saved.getValue().getSpecification());
+        assertEquals(88L, saved.getValue().getReceiverUserId());
         assertEquals(1L, saved.getValue().getPricingId());
         assertEquals("00:00", saved.getValue().getPricingStartTime());
         assertEquals("24:00", saved.getValue().getPricingEndTime());
@@ -186,6 +206,11 @@ class FlashDeliveryApplicationServiceTest {
         assertEquals(new BigDecimal("1.00"), saved.getValue().getDistance());
         assertEquals(15L, saved.getValue().getFreight());
         assertEquals(0L, saved.getValue().getDistanceFee());
+        assertEquals(90L, saved.getValue().getBaseDeliveryFee());
+        assertEquals(new BigDecimal("20.00"), saved.getValue().getUrgentRate());
+        assertEquals(10L, saved.getValue().getMinimumUrgentFee());
+        assertEquals(0L, saved.getValue().getUrgentFee());
+        assertEquals(0L, saved.getValue().getTipAmount());
     }
 
     @Test
@@ -193,12 +218,13 @@ class FlashDeliveryApplicationServiceTest {
     void userListOnlyFiltersByOwnerAndReturnsCardFields() {
         Fixture fixture = new Fixture();
         FlashDeliveryOrder order = order(10L, "COMPLETED");
+        order.setWeightRange("OVER_5_TO_10_KG");
         Page<FlashDeliveryOrder> source = new Page<>(1, 10, 1);
         source.setRecords(List.of(order));
         when(fixture.orderMapper.selectPage(any(com.baomidou.mybatisplus.core.metadata.IPage.class),
                 any(Wrapper.class))).thenReturn(source);
 
-        var result = fixture.service.userOrders(7L, 1, 10);
+        var result = fixture.service.userOrders(7L, 1, 10, null);
 
         ArgumentCaptor<com.baomidou.mybatisplus.core.conditions.query.QueryWrapper> query =
                 ArgumentCaptor.forClass(com.baomidou.mybatisplus.core.conditions.query.QueryWrapper.class);
@@ -210,15 +236,75 @@ class FlashDeliveryApplicationServiceTest {
         assertFalse(sql.contains("service_type"));
         assertTrue(parameters.contains(7L));
         assertEquals("pickup road", result.getRecords().get(0).getPickupAddress());
+        assertEquals("OVER_5_TO_10_KG", result.getRecords().get(0).getWeightRange());
         assertEquals(112L, result.getRecords().get(0).getAmount());
     }
 
+    @Test
+    void createDoesNotBindReceiverWhenNormalizedPhoneMatchesMultipleUsers() {
+        Fixture fixture = new Fixture();
+        when(fixture.pricingMapper.selectAtTime(anyString())).thenReturn(List.of(pricing()));
+        when(fixture.routeService.calculate(any(), any())).thenReturn(new RouteDistance(3200, 600, "ROUTE"));
+        when(fixture.userMapper.selectOrdinaryUserIdsByNormalizedPhone("886912345678"))
+                .thenReturn(List.of(88L, 99L));
+        doAnswer(invocation -> {
+            FlashDeliveryOrder order = invocation.getArgument(0);
+            order.setId(99L);
+            return 1;
+        }).when(fixture.orderMapper).insert(any(FlashDeliveryOrder.class));
+        FlashDeliveryCreateRequest request = createRequest();
+        request.getDelivery().setPhone("+886 912-345-678");
+
+        fixture.service.create(7L, request);
+
+        ArgumentCaptor<FlashDeliveryOrder> saved = ArgumentCaptor.forClass(FlashDeliveryOrder.class);
+        verify(fixture.orderMapper).insert(saved.capture());
+        assertNull(saved.getValue().getReceiverUserId());
+    }
+
+    @Test
+    void createLeavesReceiverUnboundWhenPhoneHasNoRegisteredUser() {
+        Fixture fixture = new Fixture();
+        when(fixture.pricingMapper.selectAtTime(anyString())).thenReturn(List.of(pricing()));
+        when(fixture.routeService.calculate(any(), any())).thenReturn(new RouteDistance(3200, 600, "ROUTE"));
+        when(fixture.userMapper.selectOrdinaryUserIdsByNormalizedPhone("0922222222"))
+                .thenReturn(List.of());
+        doAnswer(invocation -> {
+            FlashDeliveryOrder order = invocation.getArgument(0);
+            order.setId(99L);
+            return 1;
+        }).when(fixture.orderMapper).insert(any(FlashDeliveryOrder.class));
+
+        fixture.service.create(7L, createRequest());
+
+        ArgumentCaptor<FlashDeliveryOrder> saved = ArgumentCaptor.forClass(FlashDeliveryOrder.class);
+        verify(fixture.orderMapper).insert(saved.capture());
+        assertNull(saved.getValue().getReceiverUserId());
+    }
+
+    @Test
+    @SuppressWarnings({"rawtypes", "unchecked"})
+    void receiverListFiltersByCreationTimeReceiverBinding() {
+        Fixture fixture = new Fixture();
+        when(fixture.orderMapper.selectPage(any(com.baomidou.mybatisplus.core.metadata.IPage.class),
+                any(Wrapper.class))).thenReturn(new Page<FlashDeliveryOrder>());
+
+        fixture.service.userOrders(88L, 1, 10, "receiver");
+
+        ArgumentCaptor<com.baomidou.mybatisplus.core.conditions.query.QueryWrapper> query =
+                ArgumentCaptor.forClass(com.baomidou.mybatisplus.core.conditions.query.QueryWrapper.class);
+        verify(fixture.orderMapper).selectPage(any(com.baomidou.mybatisplus.core.metadata.IPage.class), query.capture());
+        assertTrue(query.getValue().getSqlSegment().toLowerCase().contains("receiver_user_id"));
+        assertTrue(query.getValue().getParamNameValuePairs().values().contains(88L));
+    }
+
     @Test
     @SuppressWarnings({"rawtypes", "unchecked"})
     void riderNewTaskUsesFoodOrderParametersAndReturnsPrototypeCardSummary() {
         Fixture fixture = new Fixture();
         rider(fixture, 8L);
         FlashDeliveryOrder order = order(10L, "WAITING_ACCEPTANCE");
+        order.setWeightRange("OVER_10_TO_15_KG");
         Page<FlashDeliveryOrder> source = new Page<>(1, 10, 1);
         source.setRecords(List.of(order));
         when(fixture.orderMapper.selectPage(any(com.baomidou.mybatisplus.core.metadata.IPage.class),
@@ -239,6 +325,7 @@ class FlashDeliveryApplicationServiceTest {
         assertEquals(168L, result.getHighestOrderAmount());
         assertEquals("pickup road", result.getRecords().get(0).getPickupAddress());
         assertEquals("delivery road", result.getRecords().get(0).getDeliveryAddress());
+        assertEquals("OVER_10_TO_15_KG", result.getRecords().get(0).getWeightRange());
         assertTrue(result.getRecords().get(0).getPickupDistanceMeters() > 0);
         assertTrue(result.getRecords().get(0).getPinRequired());
         ArgumentCaptor<com.baomidou.mybatisplus.core.conditions.query.QueryWrapper> query =
@@ -392,6 +479,7 @@ class FlashDeliveryApplicationServiceTest {
         Fixture fixture = new Fixture();
         rider(fixture, 8L);
         FlashDeliveryOrder order = order(10L, "WAITING_ACCEPTANCE");
+        order.setWeightRange("OVER_15_TO_20_KG");
         when(fixture.orderMapper.selectById(10L)).thenReturn(order);
 
         var result = fixture.service.riderDetail(8L, 10L);
@@ -401,6 +489,7 @@ class FlashDeliveryApplicationServiceTest {
         assertEquals("pickup door", result.getPickup().getAddressDetail());
         assertEquals("delivery road", result.getDelivery().getAddress());
         assertEquals("delivery door", result.getDelivery().getAddressDetail());
+        assertEquals("OVER_15_TO_20_KG", result.getWeightRange());
         assertNull(result.getPickup().getName());
         assertNull(result.getPickup().getPhone());
         assertNull(result.getPickup().getLongitude());
@@ -423,6 +512,7 @@ class FlashDeliveryApplicationServiceTest {
         Fixture fixture = new Fixture();
         FlashDeliveryOrder order = order(10L, ACCEPTED);
         order.setRiderId(null);
+        order.setWeightRange("OVER_5_TO_10_KG");
         when(fixture.orderMapper.selectById(10L)).thenReturn(order);
         when(fixture.imageMapper.selectList(any(Wrapper.class))).thenReturn(List.of(
                 image("SENDER", "https://example.com/sender.jpg"),
@@ -436,9 +526,40 @@ class FlashDeliveryApplicationServiceTest {
         assertEquals(List.of("https://example.com/pickup.jpg"), result.getPickupImageUrls());
         assertEquals(List.of("https://example.com/delivery.jpg"), result.getDeliveryImageUrls());
         assertEquals("4821", result.getDeliveryPinCode());
+        assertEquals("OVER_5_TO_10_KG", result.getWeightRange());
         verify(fixture.logMapper, never()).selectList(any());
     }
 
+    @Test
+    void matchedReceiverCanViewAndConfirmButCannotCancelOrder() {
+        Fixture fixture = new Fixture();
+        FlashDeliveryOrder order = order(10L, "DELIVERED");
+        order.setReceiverUserId(88L);
+        when(fixture.orderMapper.selectById(10L)).thenReturn(order);
+        when(fixture.orderMapper.transitionByParticipant(eq(10L), eq(88L), eq("DELIVERED"),
+                eq("COMPLETED"), any(Date.class))).thenReturn(1);
+        when(fixture.imageMapper.selectList(any(Wrapper.class))).thenReturn(List.of());
+
+        assertDoesNotThrow(() -> fixture.service.userDetail(88L, 10L));
+        assertDoesNotThrow(() -> fixture.service.confirmReceipt(88L, 10L));
+        assertThrows(ServiceException.class,
+                () -> fixture.service.userCancel(88L, 10L, new com.ruoyi.app.flashdelivery.dto.FlashDeliveryReasonRequest()));
+        verify(fixture.orderMapper).transitionByParticipant(eq(10L), eq(88L), eq("DELIVERED"),
+                eq("COMPLETED"), any(Date.class));
+    }
+
+    @Test
+    void orderWithoutCreationTimeReceiverBindingIsNotClaimedLater() {
+        Fixture fixture = new Fixture();
+        FlashDeliveryOrder historicalOrder = order(10L, "DELIVERED");
+        historicalOrder.setReceiverUserId(null);
+        when(fixture.orderMapper.selectById(10L)).thenReturn(historicalOrder);
+
+        assertThrows(ServiceException.class, () -> fixture.service.userDetail(88L, 10L));
+
+        verify(fixture.userMapper, never()).selectOrdinaryUserIdsByNormalizedPhone(any());
+    }
+
     @Test
     void pickupRejectsMissingProofBeforeChangingState() {
         Fixture fixture = new Fixture();
@@ -459,11 +580,43 @@ class FlashDeliveryApplicationServiceTest {
         InfoUser rider = new InfoUser();
         rider.setUserId(8L);
         rider.setUserType("2");
+        rider.setDeliveryType("FLASH");
         when(fixture.userMapper.selectInfoUserByUserId(8L)).thenReturn(rider);
+        when(fixture.orderMapper.selectById(10L)).thenReturn(order(10L, "WAITING_ACCEPTANCE"));
         when(fixture.orderMapper.accept(eq(10L), eq(8L), any())).thenReturn(0);
 
         assertThrows(RuntimeException.class, () -> fixture.service.accept(8L, 10L));
-        verify(fixture.orderMapper, never()).selectById(10L);
+    }
+
+    @Test
+    void flashAcceptUsesRiderLockAndChecksExclusivityBeforeAtomicUpdate() {
+        Fixture fixture = new Fixture();
+        rider(fixture, 8L);
+        FlashDeliveryOrder waiting = order(10L, "WAITING_ACCEPTANCE");
+        FlashDeliveryOrder accepted = order(10L, ACCEPTED);
+        accepted.setRiderId(8L);
+        when(fixture.orderMapper.selectById(10L)).thenReturn(waiting, accepted);
+        when(fixture.orderMapper.accept(eq(10L), eq(8L), any(Date.class))).thenReturn(1);
+
+        fixture.service.accept(8L, 10L);
+
+        InOrder order = inOrder(fixture.lockService, fixture.exclusivityService, fixture.orderMapper);
+        order.verify(fixture.lockService).withLock(eq(8L), any(RiderDeliveryLockService.LockedCall.class));
+        order.verify(fixture.exclusivityService).assertCanAcceptFlash(8L, "NORMAL");
+        order.verify(fixture.orderMapper).accept(eq(10L), eq(8L), any(Date.class));
+    }
+
+    @Test
+    void flashExclusiveConflictDoesNotUpdateOrder() {
+        Fixture fixture = new Fixture();
+        rider(fixture, 8L);
+        when(fixture.orderMapper.selectById(10L)).thenReturn(order(10L, "WAITING_ACCEPTANCE"));
+        doThrow(new ServiceException("exclusive"))
+                .when(fixture.exclusivityService).assertCanAcceptFlash(8L, "NORMAL");
+
+        assertThrows(ServiceException.class, () -> fixture.service.accept(8L, 10L));
+
+        verify(fixture.orderMapper, never()).accept(any(), any(), any());
     }
 
     @Test
@@ -501,7 +654,7 @@ class FlashDeliveryApplicationServiceTest {
     void concurrentPricingUpdateIsRejected() {
         Fixture fixture = new Fixture();
         when(fixture.pricingMapper.selectById(1L)).thenReturn(pricing());
-        when(fixture.pricingMapper.countOverlapping("HELP_SEND", "00:00", "12:00", 1L)).thenReturn(0);
+        when(fixture.pricingMapper.countOverlapping("00:00", "12:00", 1L)).thenReturn(0);
         when(fixture.pricingMapper.update(isNull(), any(Wrapper.class))).thenReturn(0);
         FlashDeliveryPricingRequest request = pricingRequest();
         request.setEndTime("12:00");
@@ -516,7 +669,7 @@ class FlashDeliveryApplicationServiceTest {
     void overlappingPricingPeriodIsRejectedBeforeInsert() {
         Fixture fixture = new Fixture();
         FlashDeliveryPricingRequest request = pricingRequest();
-        when(fixture.pricingMapper.countOverlapping("HELP_SEND", "00:00", "24:00", null)).thenReturn(1);
+        when(fixture.pricingMapper.countOverlapping("00:00", "24:00", null)).thenReturn(1);
 
         assertThrows(ServiceException.class, () -> fixture.service.createPricing(1L, request));
 
@@ -554,7 +707,7 @@ class FlashDeliveryApplicationServiceTest {
     void createPricingPersistsImmediatelyActiveTimePeriod() {
         Fixture fixture = new Fixture();
         FlashDeliveryPricingRequest request = pricingRequest();
-        when(fixture.pricingMapper.countOverlapping("HELP_SEND", "00:00", "24:00", null)).thenReturn(0);
+        when(fixture.pricingMapper.countOverlapping("00:00", "24:00", null)).thenReturn(0);
 
         fixture.service.createPricing(7L, request);
 
@@ -565,9 +718,8 @@ class FlashDeliveryApplicationServiceTest {
         assertEquals(90L, saved.getValue().getStartingFare());
 
         InOrder writeOrder = inOrder(fixture.pricingMapper);
-        writeOrder.verify(fixture.pricingMapper).lockServiceType("HELP_SEND");
         writeOrder.verify(fixture.pricingMapper)
-                .countOverlapping("HELP_SEND", "00:00", "24:00", null);
+                .countOverlapping("00:00", "24:00", null);
         writeOrder.verify(fixture.pricingMapper).insert(any(FlashDeliveryPricing.class));
     }
 
@@ -590,7 +742,7 @@ class FlashDeliveryApplicationServiceTest {
     @Test
     void multipleCurrentPricingPeriodsFailExplicitlyBeforeRouteLookup() {
         Fixture fixture = new Fixture();
-        when(fixture.pricingMapper.selectCurrent("HELP_SEND"))
+        when(fixture.pricingMapper.selectAtTime(anyString()))
                 .thenReturn(List.of(pricing(), pricing()));
 
         assertThrows(ServiceException.class, () -> fixture.service.quote(request()));
@@ -599,10 +751,9 @@ class FlashDeliveryApplicationServiceTest {
     }
 
     @Test
-    void movingPricingPeriodLocksOldAndNewServiceTypesInStableOrder() {
+    void updatingPricingChecksOverlapBeforeVersionedWrite() {
         Fixture fixture = new Fixture();
         FlashDeliveryPricing existing = pricing();
-        existing.setServiceType("URGENT");
         when(fixture.pricingMapper.selectById(1L)).thenReturn(existing);
         when(fixture.pricingMapper.update(isNull(), any(Wrapper.class))).thenReturn(1);
         FlashDeliveryPricingRequest request = pricingRequest();
@@ -610,11 +761,10 @@ class FlashDeliveryApplicationServiceTest {
 
         fixture.service.updatePricing(9L, 1L, request);
 
-        InOrder lockOrder = inOrder(fixture.pricingMapper);
-        lockOrder.verify(fixture.pricingMapper).lockServiceType("HELP_SEND");
-        lockOrder.verify(fixture.pricingMapper).lockServiceType("URGENT");
-        lockOrder.verify(fixture.pricingMapper)
-                .countOverlapping("HELP_SEND", "00:00", "24:00", 1L);
+        InOrder writeOrder = inOrder(fixture.pricingMapper);
+        writeOrder.verify(fixture.pricingMapper)
+                .countOverlapping("00:00", "24:00", 1L);
+        writeOrder.verify(fixture.pricingMapper).update(isNull(), any(Wrapper.class));
     }
 
     @Test
@@ -625,18 +775,133 @@ class FlashDeliveryApplicationServiceTest {
 
         fixture.service.deletePricing(18L);
 
-        InOrder deleteOrder = inOrder(fixture.pricingMapper);
-        deleteOrder.verify(fixture.pricingMapper).selectById(18L);
-        deleteOrder.verify(fixture.pricingMapper).lockServiceType("HELP_SEND");
-        deleteOrder.verify(fixture.pricingMapper).selectById(18L);
-        deleteOrder.verify(fixture.pricingMapper).deleteById(18L);
+        verify(fixture.pricingMapper).deleteById(18L);
+    }
+
+    @Test
+    void urgentQuoteReturnsSeparatedFeesAndUsesScheduledPickupTime() {
+        Fixture fixture = new Fixture();
+        when(fixture.pricingMapper.selectAtTime("16:30")).thenReturn(List.of(pricing()));
+        when(fixture.routeService.calculate(any(), any())).thenReturn(new RouteDistance(6000, 900, "ROUTE"));
+        FlashDeliveryQuoteRequest request = request();
+        request.setDeliveryType("URGENT");
+        request.setTipAmount(5L);
+        request.setDeliveryMode("SCHEDULED");
+        Calendar calendar = Calendar.getInstance();
+        calendar.add(Calendar.DAY_OF_MONTH, 1);
+        calendar.set(Calendar.HOUR_OF_DAY, 16);
+        calendar.set(Calendar.MINUTE, 30);
+        calendar.set(Calendar.SECOND, 0);
+        calendar.set(Calendar.MILLISECOND, 0);
+        request.setScheduledPickupStartAt(calendar.getTime());
+        request.setScheduledPickupEndAt(new Date(calendar.getTimeInMillis() + 30L * 60L * 1000L));
+
+        var quote = fixture.service.quote(request);
+
+        assertEquals(45L, quote.getDistanceFee());
+        assertEquals(135L, quote.getBaseDeliveryFee());
+        assertEquals(27L, quote.getUrgentFee());
+        assertEquals(5L, quote.getTipAmount());
+        assertEquals(167L, quote.getAmount());
+        verify(fixture.pricingMapper).selectAtTime("16:30");
+    }
+
+    @Test
+    void createRejectsChangedQuoteAndReturnsLatestQuoteWithoutInsert() {
+        Fixture fixture = new Fixture();
+        when(fixture.pricingMapper.selectAtTime(anyString())).thenReturn(List.of(pricing()));
+        when(fixture.routeService.calculate(any(), any())).thenReturn(new RouteDistance(3200, 600, "ROUTE"));
+        FlashDeliveryCreateRequest request = createRequest();
+        request.setQuotedAmount(91L);
+
+        FlashDeliveryQuoteChangedException error = assertThrows(FlashDeliveryQuoteChangedException.class,
+                () -> fixture.service.create(7L, request));
+
+        assertEquals(90L, error.getLatestQuote().getAmount());
+        assertEquals(1L, error.getLatestQuote().getPricingId());
+        verify(fixture.orderMapper, never()).insert(any(FlashDeliveryOrder.class));
+    }
+
+    @Test
+    void quoteValidatesDeliveryLevelAndCompleteItemInformation() {
+        Fixture fixture = new Fixture();
+        FlashDeliveryQuoteRequest request = request();
+        request.setDeliveryType("FAST");
+        FlashDeliveryQuoteRequest invalidDeliveryType = request;
+        assertThrows(ServiceException.class, () -> fixture.service.quote(invalidDeliveryType));
+
+        request = request();
+        request.setQuantity(0);
+        FlashDeliveryQuoteRequest invalidQuantity = request;
+        assertThrows(ServiceException.class, () -> fixture.service.quote(invalidQuantity));
+
+        request = request();
+        request.setWeightRange("SMALL");
+        FlashDeliveryQuoteRequest oldWeightRange = request;
+        assertThrows(ServiceException.class, () -> fixture.service.quote(oldWeightRange));
+    }
+
+    @Test
+    void quoteAcceptsAllFourWeightRanges() {
+        Fixture fixture = new Fixture();
+        when(fixture.pricingMapper.selectAtTime(anyString())).thenReturn(List.of(pricing()));
+        when(fixture.routeService.calculate(any(), any())).thenReturn(new RouteDistance(3200, 600, "ROUTE"));
+
+        for (String weightRange : List.of("UP_TO_5_KG", "OVER_5_TO_10_KG",
+                "OVER_10_TO_15_KG", "OVER_15_TO_20_KG")) {
+            FlashDeliveryQuoteRequest request = request();
+            request.setWeightRange(weightRange);
+            var quote = assertDoesNotThrow(() -> fixture.service.quote(request), weightRange);
+            assertEquals(90L, quote.getAmount(), weightRange);
+        }
+    }
+
+    @Test
+    void createPersistsAndReturnsEveryWeightRange() {
+        for (String weightRange : List.of("UP_TO_5_KG", "OVER_5_TO_10_KG",
+                "OVER_10_TO_15_KG", "OVER_15_TO_20_KG")) {
+            Fixture fixture = new Fixture();
+            when(fixture.pricingMapper.selectAtTime(anyString())).thenReturn(List.of(pricing()));
+            when(fixture.routeService.calculate(any(), any())).thenReturn(new RouteDistance(3200, 600, "ROUTE"));
+            doAnswer(invocation -> {
+                FlashDeliveryOrder order = invocation.getArgument(0);
+                order.setId(99L);
+                return 1;
+            }).when(fixture.orderMapper).insert(any(FlashDeliveryOrder.class));
+            when(fixture.imageMapper.selectList(any(Wrapper.class))).thenReturn(List.of());
+            when(fixture.logMapper.selectList(any(Wrapper.class))).thenReturn(List.of());
+            FlashDeliveryCreateRequest request = createRequest();
+            request.setWeightRange(weightRange);
+
+            var result = fixture.service.create(7L, request);
+
+            ArgumentCaptor<FlashDeliveryOrder> saved = ArgumentCaptor.forClass(FlashDeliveryOrder.class);
+            verify(fixture.orderMapper).insert(saved.capture());
+            assertEquals(weightRange, saved.getValue().getWeightRange());
+            assertEquals(weightRange, result.getWeightRange());
+        }
+    }
+
+    @Test
+    void quoteRejectsMissingAndUnknownWeightRanges() {
+        Fixture fixture = new Fixture();
+        for (String weightRange : new String[]{null, "", "SMALL", "OVER_20_KG"}) {
+            FlashDeliveryQuoteRequest request = request();
+            request.setWeightRange(weightRange);
+            assertThrows(ServiceException.class, () -> fixture.service.quote(request), String.valueOf(weightRange));
+        }
     }
 
     private FlashDeliveryQuoteRequest request() {
         FlashDeliveryQuoteRequest request = new FlashDeliveryQuoteRequest();
         request.setServiceType("HELP_SEND");
+        request.setDeliveryType("NORMAL");
         request.setPackageType("DOCUMENT");
-        request.setPackageSize("SMALL");
+        request.setQuantity(1);
+        request.setWeightRange("UP_TO_5_KG");
+        request.setSpecification("30 x 20 x 10 cm");
+        request.setTipAmount(0L);
+        request.setDeliveryMode("NOW");
         request.setPickup(address("A", "0911111111", "25.0330", "121.5645"));
         request.setDelivery(address("B", "0922222222", "25.0478", "121.5319"));
         request.getDelivery().setAddress("second road");
@@ -647,8 +912,12 @@ class FlashDeliveryApplicationServiceTest {
         FlashDeliveryCreateRequest request = new FlashDeliveryCreateRequest();
         FlashDeliveryQuoteRequest quote = request();
         request.setServiceType(quote.getServiceType());
+        request.setDeliveryType(quote.getDeliveryType());
         request.setPackageType(quote.getPackageType());
-        request.setPackageSize(quote.getPackageSize());
+        request.setQuantity(quote.getQuantity());
+        request.setWeightRange(quote.getWeightRange());
+        request.setSpecification(quote.getSpecification());
+        request.setTipAmount(quote.getTipAmount());
         request.setPickup(quote.getPickup());
         request.setDelivery(quote.getDelivery());
         request.setClientRequestId("flash-scheduled-001");
@@ -657,6 +926,12 @@ class FlashDeliveryApplicationServiceTest {
         request.setScheduledPickupStartAt(start);
         request.setScheduledPickupEndAt(new Date(start.getTime() + 30L * 60L * 1000L));
         request.setPinRequired(true);
+        request.setPricingId(1L);
+        request.setPricingVersion(1);
+        request.setQuotedBaseDeliveryFee(90L);
+        request.setQuotedDistanceFee(0L);
+        request.setQuotedUrgentFee(0L);
+        request.setQuotedAmount(90L);
         request.setSenderImageUrls(List.of("https://example.com/sender.jpg"));
         return request;
     }
@@ -675,26 +950,28 @@ class FlashDeliveryApplicationServiceTest {
     private FlashDeliveryPricing pricing() {
         FlashDeliveryPricing pricing = new FlashDeliveryPricing();
         pricing.setId(1L);
-        pricing.setServiceType("HELP_SEND");
         pricing.setStartTime("00:00");
         pricing.setEndTime("24:00");
         pricing.setStartingDistance(new BigDecimal("3.00"));
         pricing.setStartingFare(90L);
         pricing.setDistance(new BigDecimal("1.00"));
         pricing.setFreight(15L);
+        pricing.setUrgentRate(new BigDecimal("20.00"));
+        pricing.setMinimumUrgentFee(10L);
         pricing.setConfigVersion(1);
         return pricing;
     }
 
     private FlashDeliveryPricingRequest pricingRequest() {
         FlashDeliveryPricingRequest request = new FlashDeliveryPricingRequest();
-        request.setServiceType("HELP_SEND");
         request.setStartTime("00:00");
         request.setEndTime("24:00");
         request.setStartingDistance(new BigDecimal("3.00"));
         request.setStartingFare(90L);
         request.setDistance(new BigDecimal("1.00"));
         request.setFreight(15L);
+        request.setUrgentRate(new BigDecimal("20.00"));
+        request.setMinimumUrgentFee(10L);
         return request;
     }
 
@@ -722,6 +999,7 @@ class FlashDeliveryApplicationServiceTest {
         InfoUser rider = new InfoUser();
         rider.setUserId(riderId);
         rider.setUserType("2");
+        rider.setDeliveryType("FLASH");
         when(fixture.userMapper.selectInfoUserByUserId(riderId)).thenReturn(rider);
     }
 
@@ -731,9 +1009,11 @@ class FlashDeliveryApplicationServiceTest {
         order.setOrderNo("FL-20260813-021");
         order.setUserId(7L);
         order.setServiceType("HELP_SEND");
+        order.setDeliveryType("NORMAL");
         order.setStatus(status);
         order.setPackageType("DOCUMENT");
-        order.setPackageSize("SMALL");
+        order.setQuantity(1);
+        order.setWeightRange("UP_TO_5_KG");
         order.setDeliveryMode("NOW");
         order.setPinRequired(true);
         order.setDeliveryPinCode("4821");
@@ -755,6 +1035,12 @@ class FlashDeliveryApplicationServiceTest {
         order.setDeliveryLatitude(new BigDecimal("25.0200"));
         order.setDistanceMeters(3200);
         order.setEstimatedDurationSeconds(2100);
+        order.setDistanceFee(12L);
+        order.setBaseDeliveryFee(102L);
+        order.setUrgentRate(new BigDecimal("20.00"));
+        order.setMinimumUrgentFee(10L);
+        order.setUrgentFee(0L);
+        order.setTipAmount(10L);
         order.setAmount(112L);
         order.setCurrency("TWD");
         order.setUserNote("private note");
@@ -769,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);
@@ -776,13 +1359,17 @@ class FlashDeliveryApplicationServiceTest {
         final FlashDeliveryOrderLogMapper logMapper = mock(FlashDeliveryOrderLogMapper.class);
         final InfoUserMapper userMapper = mock(InfoUserMapper.class);
         final FlashDeliveryRouteService routeService = mock(FlashDeliveryRouteService.class);
+        final RiderDeliveryLockService lockService = mock(RiderDeliveryLockService.class);
+        final RiderDeliveryExclusivityService exclusivityService = mock(RiderDeliveryExclusivityService.class);
         final FlashDeliveryApplicationService service = new FlashDeliveryApplicationService(
                 orderMapper, pricingMapper, imageMapper, logMapper, userMapper,
-                routeService, new FlashDeliveryPricingCalculator());
+                routeService, new FlashDeliveryPricingCalculator(), lockService, exclusivityService);
 
         Fixture() {
-            when(pricingMapper.lockServiceType(any()))
-                    .thenAnswer(invocation -> invocation.getArgument(0));
+            when(lockService.withLock(any(), any())).thenAnswer(invocation -> {
+                RiderDeliveryLockService.LockedCall<?> call = invocation.getArgument(1);
+                return call.call();
+            });
         }
     }
 }

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

@@ -11,7 +11,8 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
 
 class FlashDeliveryI18nContractTest {
     private static final List<String> FILES = List.of("messages.properties", "messages_zh_CN.properties",
-            "messages_zh_TW.properties", "messages_en_US.properties", "messages_vi.properties");
+            "messages_zh_TW.properties", "messages_en_US.properties", "messages_vi.properties",
+            "messages_th_TH.properties");
     private static final List<String> KEYS = List.of(
             "flash.delivery.request.required", "flash.delivery.auth.required",
             "flash.delivery.client.request.id.invalid", "flash.delivery.note.too.long",
@@ -26,6 +27,11 @@ class FlashDeliveryI18nContractTest {
             "flash.delivery.pricing.invalid", "flash.delivery.pricing.not.available",
             "flash.delivery.pricing.overlap", "flash.delivery.pricing.not.found",
             "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");
 
     @Test

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

@@ -52,6 +52,35 @@ class FlashDeliveryPricingCalculatorTest {
         assertEquals(601L, calculator.calculateBreakdown(pricing, 4201).getDistanceFee());
     }
 
+    @Test
+    void calculatesUrgentFeeFromBaseDeliveryFeeAndAddsTipSeparately() {
+        var breakdown = calculator.calculateBreakdown(pricing(), 6000, "URGENT", 5L);
+
+        assertEquals(45L, breakdown.getDistanceFee());
+        assertEquals(135L, breakdown.getBaseDeliveryFee());
+        assertEquals(27L, breakdown.getUrgentFee());
+        assertEquals(5L, breakdown.getTipAmount());
+        assertEquals(167L, breakdown.getAmount());
+    }
+
+    @Test
+    void urgentFeeUsesMinimumAndRoundsPercentageHalfUp() {
+        FlashDeliveryPricing pricing = pricing();
+        pricing.setUrgentRate(new BigDecimal("12.50"));
+        pricing.setMinimumUrgentFee(20L);
+        assertEquals(20L, calculator.calculateBreakdown(pricing, 3000, "URGENT", 0L).getUrgentFee());
+
+        pricing.setMinimumUrgentFee(0L);
+        assertEquals(11L, calculator.calculateBreakdown(pricing, 3000, "URGENT", 0L).getUrgentFee());
+    }
+
+    @Test
+    void normalDeliveryNeverChargesUrgentFee() {
+        var breakdown = calculator.calculateBreakdown(pricing(), 3000, "NORMAL", 8L);
+        assertEquals(0L, breakdown.getUrgentFee());
+        assertEquals(98L, breakdown.getAmount());
+    }
+
     @Test
     void rejectsInvalidDistanceOrPricing() {
         assertThrows(IllegalArgumentException.class, () -> calculator.calculateBreakdown(pricing(), 0));
@@ -90,6 +119,8 @@ class FlashDeliveryPricingCalculatorTest {
         pricing.setStartingFare(90L);
         pricing.setDistance(new BigDecimal("1.00"));
         pricing.setFreight(15L);
+        pricing.setUrgentRate(new BigDecimal("20.00"));
+        pricing.setMinimumUrgentFee(10L);
         return pricing;
     }
 }

+ 42 - 1
ruoyi-admin/src/test/java/com/ruoyi/app/order/OrderLifecycleServiceTest.java

@@ -28,11 +28,15 @@ import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.anyInt;
 import static org.mockito.ArgumentMatchers.anyLong;
 import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
 import static org.mockito.Mockito.doAnswer;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.never;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.inOrder;
+import org.mockito.InOrder;
 
 class OrderLifecycleServiceTest {
 
@@ -41,6 +45,8 @@ class OrderLifecycleServiceTest {
     private OrderLogHelper orderLogHelper;
     private IUserWalletService userWalletService;
     private IPointsTransactionService pointsTransactionService;
+    private RiderDeliveryLockService riderDeliveryLockService;
+    private RiderDeliveryExclusivityService riderDeliveryExclusivityService;
     private OrderLifecycleService service;
 
     @BeforeEach
@@ -50,8 +56,15 @@ class OrderLifecycleServiceTest {
         orderLogHelper = mock(OrderLogHelper.class);
         userWalletService = mock(IUserWalletService.class);
         pointsTransactionService = mock(IPointsTransactionService.class);
+        riderDeliveryLockService = mock(RiderDeliveryLockService.class);
+        riderDeliveryExclusivityService = mock(RiderDeliveryExclusivityService.class);
+        when(riderDeliveryLockService.withLock(any(), any())).thenAnswer(invocation -> {
+            RiderDeliveryLockService.LockedCall<?> call = invocation.getArgument(1);
+            return call.call();
+        });
         service = new OrderLifecycleService(posOrderService, billingService, orderLogHelper,
-                userWalletService, pointsTransactionService);
+                userWalletService, pointsTransactionService, null, null,
+                riderDeliveryLockService, riderDeliveryExclusivityService);
     }
 
     @Test
@@ -212,6 +225,34 @@ class OrderLifecycleServiceTest {
                 () -> service.pickupDeliveryByRider(10L, 88L, "proof"));
     }
 
+    @Test
+    void foodAcceptUsesSharedRiderLockAndChecksExclusivityBeforeCas() {
+        PosOrder order = deliveryOrder();
+        order.setState(0L);
+        order.setDeliveryStatus(0L);
+        order.setQsId(null);
+        when(posOrderService.getById(10L)).thenReturn(order);
+        when(posOrderService.update(any(PosOrder.class), any(Wrapper.class))).thenReturn(true);
+
+        service.acceptDeliveryByRider(10L, 88L);
+
+        InOrder calls = inOrder(riderDeliveryLockService, riderDeliveryExclusivityService, posOrderService);
+        calls.verify(riderDeliveryLockService)
+                .withLock(eq(88L), any(RiderDeliveryLockService.LockedCall.class));
+        calls.verify(riderDeliveryExclusivityService).assertCanAcceptFood(88L);
+        calls.verify(posOrderService).update(any(PosOrder.class), any(Wrapper.class));
+    }
+
+    @Test
+    void foodExclusiveConflictDoesNotUpdateOrder() {
+        doThrow(new ServiceException("exclusive"))
+                .when(riderDeliveryExclusivityService).assertCanAcceptFood(88L);
+
+        assertThrows(ServiceException.class, () -> service.acceptDeliveryByRider(10L, 88L));
+
+        verify(posOrderService, never()).update(any(PosOrder.class), any(Wrapper.class));
+    }
+
     @Test
     void marksDeliveryAsDeliveredAndCompletesExactlyOnce() {
         PosOrder order = deliveryOrder();

+ 3 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/order/PosOrderQsOprateControllerTest.java

@@ -9,6 +9,8 @@ import com.ruoyi.app.service.UserService;
 import com.ruoyi.common.exception.ServiceException;
 import com.ruoyi.system.domain.InfoUser;
 import com.ruoyi.system.domain.PosOrder;
+import com.ruoyi.system.domain.InfoUser;
+import com.ruoyi.app.service.UserService;
 import com.ruoyi.system.service.IInfoUserService;
 import com.ruoyi.system.service.IPosOrderService;
 import com.ruoyi.system.service.IPosStoreService;
@@ -66,6 +68,7 @@ class PosOrderQsOprateControllerTest {
         InfoUser rider = new InfoUser();
         rider.setUserId(88L);
         rider.setUserType("2");
+        rider.setDeliveryType("FOOD");
         when(userService.checkUserStatus(88L)).thenReturn(rider);
         Page<PosOrder> emptyPage = new Page<>(1, 10);
         emptyPage.setRecords(Collections.emptyList());

+ 126 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/order/RiderDeliveryExclusivityServiceTest.java

@@ -0,0 +1,126 @@
+package com.ruoyi.app.order;
+
+import com.baomidou.mybatisplus.core.conditions.Wrapper;
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.common.utils.spring.SpringUtils;
+import com.ruoyi.system.mapper.flash.FlashDeliveryOrderMapper;
+import com.ruoyi.system.service.IPosOrderService;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.springframework.beans.factory.config.ConfigurableListableBeanFactory;
+import org.springframework.beans.factory.support.DefaultListableBeanFactory;
+import org.springframework.context.support.StaticMessageSource;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.when;
+import static org.mockito.Mockito.verify;
+
+class RiderDeliveryExclusivityServiceTest {
+    private static ConfigurableListableBeanFactory originalBeanFactory;
+
+    @BeforeAll
+    static void initializeMessages() {
+        originalBeanFactory = (ConfigurableListableBeanFactory)
+                ReflectionTestUtils.getField(SpringUtils.class, "beanFactory");
+        DefaultListableBeanFactory beanFactory = new DefaultListableBeanFactory();
+        StaticMessageSource messageSource = new StaticMessageSource();
+        messageSource.setUseCodeAsDefaultMessage(true);
+        beanFactory.registerSingleton("messageSource", messageSource);
+        new SpringUtils().postProcessBeanFactory(beanFactory);
+    }
+
+    @AfterAll
+    static void restoreMessages() {
+        new SpringUtils().postProcessBeanFactory(originalBeanFactory);
+    }
+
+    @Test
+    void urgentRejectsActiveFood() {
+        Fixture fixture = new Fixture();
+        when(fixture.foodOrders.count(any(Wrapper.class))).thenReturn(1L);
+        assertThrows(ServiceException.class,
+                () -> fixture.service.assertCanAcceptFlash(123L, "URGENT"));
+    }
+
+    @Test
+    void urgentRejectsActiveNormalFlash() {
+        Fixture fixture = new Fixture();
+        when(fixture.foodOrders.count(any(Wrapper.class))).thenReturn(0L);
+        when(fixture.flashOrders.selectCount(any(Wrapper.class))).thenReturn(1L);
+        assertThrows(ServiceException.class,
+                () -> fixture.service.assertCanAcceptFlash(123L, "URGENT"));
+    }
+
+    @Test
+    void urgentAllowsRiderWithoutActiveWork() {
+        Fixture fixture = new Fixture();
+        when(fixture.foodOrders.count(any(Wrapper.class))).thenReturn(0L);
+        when(fixture.flashOrders.selectCount(any(Wrapper.class))).thenReturn(0L);
+        assertDoesNotThrow(() -> fixture.service.assertCanAcceptFlash(123L, "URGENT"));
+    }
+
+    @Test
+    void normalFlashRejectsActiveUrgent() {
+        Fixture fixture = new Fixture();
+        when(fixture.flashOrders.selectCount(any(Wrapper.class))).thenReturn(1L);
+        assertThrows(ServiceException.class,
+                () -> fixture.service.assertCanAcceptFlash(123L, "NORMAL"));
+    }
+
+    @Test
+    void foodRejectsActiveUrgent() {
+        Fixture fixture = new Fixture();
+        when(fixture.flashOrders.selectCount(any(Wrapper.class))).thenReturn(1L);
+        assertThrows(ServiceException.class, () -> fixture.service.assertCanAcceptFood(123L));
+    }
+
+    @Test
+    void foodAllowsActiveNormalFlashWhenNoUrgentExists() {
+        Fixture fixture = new Fixture();
+        when(fixture.flashOrders.selectCount(any(Wrapper.class))).thenReturn(0L);
+        assertDoesNotThrow(() -> fixture.service.assertCanAcceptFood(123L));
+    }
+
+    @Test
+    @SuppressWarnings({"rawtypes", "unchecked"})
+    void activeQueriesUseOnlyDefinedFoodAndFlashStates() {
+        Fixture fixture = new Fixture();
+        when(fixture.foodOrders.count(any(Wrapper.class))).thenReturn(0L);
+        when(fixture.flashOrders.selectCount(any(Wrapper.class))).thenReturn(0L);
+
+        fixture.service.assertCanAcceptFlash(123L, "URGENT");
+        fixture.service.assertCanAcceptFood(123L);
+
+        ArgumentCaptor<Wrapper> foodQuery = ArgumentCaptor.forClass(Wrapper.class);
+        ArgumentCaptor<Wrapper> flashQuery = ArgumentCaptor.forClass(Wrapper.class);
+        verify(fixture.foodOrders).count(foodQuery.capture());
+        verify(fixture.flashOrders, times(2)).selectCount(flashQuery.capture());
+        String foodSql = foodQuery.getValue().getSqlSegment().toLowerCase();
+        String allFlashSql = flashQuery.getAllValues().get(0).getSqlSegment().toLowerCase();
+        String urgentFlashSql = flashQuery.getAllValues().get(1).getSqlSegment().toLowerCase();
+        assertTrue(foodSql.contains("qs_id"));
+        assertTrue(foodSql.contains("delivery_status"));
+        assertTrue(foodSql.contains("state"));
+        assertTrue(foodSql.contains("after_sale_status"));
+        assertTrue(allFlashSql.contains("rider_id"));
+        assertTrue(allFlashSql.contains("status"));
+        assertTrue(urgentFlashSql.contains("delivery_type"));
+        assertTrue(!allFlashSql.contains("service_type"));
+        assertTrue(!urgentFlashSql.contains("service_type"));
+    }
+
+    private static class Fixture {
+        final IPosOrderService foodOrders = mock(IPosOrderService.class);
+        final FlashDeliveryOrderMapper flashOrders = mock(FlashDeliveryOrderMapper.class);
+        final RiderDeliveryExclusivityService service =
+                new RiderDeliveryExclusivityService(foodOrders, flashOrders);
+    }
+}

+ 109 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/order/RiderDeliveryLockServiceTest.java

@@ -0,0 +1,109 @@
+package com.ruoyi.app.order;
+
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.common.utils.spring.SpringUtils;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.redisson.api.RLock;
+import org.redisson.api.RedissonClient;
+import org.springframework.transaction.support.TransactionSynchronization;
+import org.springframework.transaction.support.TransactionSynchronizationManager;
+import org.springframework.beans.factory.config.ConfigurableListableBeanFactory;
+import org.springframework.beans.factory.support.DefaultListableBeanFactory;
+import org.springframework.context.support.StaticMessageSource;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+class RiderDeliveryLockServiceTest {
+    private static ConfigurableListableBeanFactory originalBeanFactory;
+
+    @BeforeAll
+    static void initializeMessages() {
+        originalBeanFactory = (ConfigurableListableBeanFactory)
+                ReflectionTestUtils.getField(SpringUtils.class, "beanFactory");
+        DefaultListableBeanFactory beanFactory = new DefaultListableBeanFactory();
+        StaticMessageSource messageSource = new StaticMessageSource();
+        messageSource.setUseCodeAsDefaultMessage(true);
+        beanFactory.registerSingleton("messageSource", messageSource);
+        new SpringUtils().postProcessBeanFactory(beanFactory);
+    }
+
+    @AfterAll
+    static void restoreMessages() {
+        new SpringUtils().postProcessBeanFactory(originalBeanFactory);
+    }
+
+    @Test
+    void usesSharedRiderLockWithWatchdogAndReleasesOutsideTransaction() throws Exception {
+        RedissonClient redisson = mock(RedissonClient.class);
+        RLock lock = mock(RLock.class);
+        when(redisson.getLock("lock:delivery:rider:123")).thenReturn(lock);
+        when(lock.tryLock(3L, TimeUnit.SECONDS)).thenReturn(true);
+        when(lock.isHeldByCurrentThread()).thenReturn(true);
+        RiderDeliveryLockService service = new RiderDeliveryLockService(redisson);
+
+        String result = service.withLock(123L, () -> "accepted");
+
+        assertEquals("accepted", result);
+        verify(lock).tryLock(3L, TimeUnit.SECONDS);
+        verify(lock).unlock();
+    }
+
+    @Test
+    void defersUnlockUntilTransactionCompletion() throws Exception {
+        RedissonClient redisson = mock(RedissonClient.class);
+        RLock lock = mock(RLock.class);
+        when(redisson.getLock("lock:delivery:rider:123")).thenReturn(lock);
+        when(lock.tryLock(3L, TimeUnit.SECONDS)).thenReturn(true);
+        when(lock.isHeldByCurrentThread()).thenReturn(true);
+        RiderDeliveryLockService service = new RiderDeliveryLockService(redisson);
+        TransactionSynchronizationManager.initSynchronization();
+        try {
+            assertEquals("accepted", service.withLock(123L, () -> "accepted"));
+            verify(lock, never()).unlock();
+            List<TransactionSynchronization> synchronizations =
+                    TransactionSynchronizationManager.getSynchronizations();
+            assertEquals(1, synchronizations.size());
+
+            synchronizations.get(0).afterCompletion(TransactionSynchronization.STATUS_COMMITTED);
+
+            verify(lock).unlock();
+        } finally {
+            TransactionSynchronizationManager.clearSynchronization();
+        }
+    }
+
+    @Test
+    void rejectsTimeoutInterruptionAndRedisFailure() throws Exception {
+        RedissonClient timeoutRedisson = mock(RedissonClient.class);
+        RLock timeoutLock = mock(RLock.class);
+        when(timeoutRedisson.getLock("lock:delivery:rider:123")).thenReturn(timeoutLock);
+        when(timeoutLock.tryLock(3L, TimeUnit.SECONDS)).thenReturn(false);
+        assertThrows(ServiceException.class,
+                () -> new RiderDeliveryLockService(timeoutRedisson).withLock(123L, () -> "ignored"));
+
+        RedissonClient interruptedRedisson = mock(RedissonClient.class);
+        RLock interruptedLock = mock(RLock.class);
+        when(interruptedRedisson.getLock("lock:delivery:rider:123")).thenReturn(interruptedLock);
+        when(interruptedLock.tryLock(3L, TimeUnit.SECONDS)).thenThrow(new InterruptedException());
+        assertThrows(ServiceException.class,
+                () -> new RiderDeliveryLockService(interruptedRedisson).withLock(123L, () -> "ignored"));
+        assertTrue(Thread.interrupted());
+
+        RedissonClient failedRedisson = mock(RedissonClient.class);
+        when(failedRedisson.getLock("lock:delivery:rider:123")).thenThrow(new IllegalStateException("redis down"));
+        assertThrows(ServiceException.class,
+                () -> new RiderDeliveryLockService(failedRedisson).withLock(123L, () -> "ignored"));
+    }
+}

+ 68 - 0
ruoyi-admin/src/test/java/com/ruoyi/common/utils/BackendI18nBundleContractTest.java

@@ -0,0 +1,68 @@
+package com.ruoyi.common.utils;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.context.support.ResourceBundleMessageSource;
+
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+import java.util.Properties;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+
+class BackendI18nBundleContractTest {
+    private static final List<String> LOCALIZED_BUNDLES = List.of(
+            "messages_zh_CN.properties", "messages_zh_TW.properties",
+            "messages_en_US.properties", "messages_vi.properties",
+            "messages_th_TH.properties");
+    private static final Pattern PLACEHOLDER = Pattern.compile("\\{[^{}]*}");
+
+    @Test
+    void everyLocalizedBundleHasTheSameNonBlankKeysAndPlaceholders() throws Exception {
+        Properties reference = load("messages_zh_TW.properties");
+        for (String bundle : LOCALIZED_BUNDLES) {
+            Properties candidate = load(bundle);
+            assertEquals(reference.stringPropertyNames(), candidate.stringPropertyNames(), bundle);
+            for (String key : reference.stringPropertyNames()) {
+                assertFalse(candidate.getProperty(key).isBlank(), bundle + " blank " + key);
+                assertEquals(placeholders(reference.getProperty(key)),
+                        placeholders(candidate.getProperty(key)), bundle + " placeholders " + key);
+            }
+        }
+    }
+
+    @Test
+    void messageSourceLoadsThaiMessagesAndFormatsArguments() {
+        ResourceBundleMessageSource source = new ResourceBundleMessageSource();
+        source.setBasename("i18n/messages");
+        source.setDefaultEncoding(StandardCharsets.UTF_8.name());
+        Locale thai = new Locale("th", "TH");
+        assertEquals("ราคาเสนอมีการเปลี่ยนแปลง โปรดยืนยันค่าบริการล่าสุดอีกครั้ง",
+                source.getMessage("flash.delivery.quote.changed", null, thai));
+        assertEquals("ป้อนรหัสผ่านผิด 3 ครั้ง บัญชีถูกล็อก 10 นาที",
+                source.getMessage("user.password.retry.limit.exceed", new Object[]{3, 10}, thai));
+    }
+
+    private Properties load(String file) throws Exception {
+        Properties properties = new Properties();
+        try (InputStream input = getClass().getClassLoader().getResourceAsStream("i18n/" + file)) {
+            assertNotNull(input, file);
+            properties.load(new InputStreamReader(input, StandardCharsets.UTF_8));
+        }
+        return properties;
+    }
+
+    private List<String> placeholders(String value) {
+        List<String> result = new ArrayList<>();
+        Matcher matcher = PLACEHOLDER.matcher(value);
+        while (matcher.find()) result.add(matcher.group());
+        return result;
+    }
+}

+ 31 - 0
ruoyi-admin/src/test/java/com/ruoyi/common/utils/LocaleUtilsTest.java

@@ -0,0 +1,31 @@
+package com.ruoyi.common.utils;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
+
+import java.util.Locale;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+class LocaleUtilsTest {
+    @ParameterizedTest
+    @CsvSource({
+            "zh_CN,zh,CN", "zh-CN,zh,CN", "zh,zh,CN",
+            "zh_TW,zh,TW", "zh-TW,zh,TW", "tw,zh,TW",
+            "en_US,en,US", "en-US,en,US", "en,en,US",
+            "vi_VN,vi,VN", "vi-VN,vi,VN", "vi,vi,VN",
+            "th_TH,th,TH", "th-TH,th,TH", "th,th,TH"
+    })
+    void parsesSupportedLanguageAliases(String input, String language, String country) {
+        assertEquals(new Locale(language, country), LocaleUtils.parseLocale(input));
+    }
+
+    @Test
+    void fallsBackToTraditionalChineseForMissingOrUnknownLanguage() {
+        Locale expected = new Locale("zh", "TW");
+        assertEquals(expected, LocaleUtils.parseLocale(null));
+        assertEquals(expected, LocaleUtils.parseLocale("  "));
+        assertEquals(expected, LocaleUtils.parseLocale("fr-FR"));
+    }
+}

+ 40 - 0
ruoyi-admin/src/test/java/com/ruoyi/framework/config/BackendI18nConfigTest.java

@@ -0,0 +1,40 @@
+package com.ruoyi.framework.config;
+
+import java.util.Locale;
+import org.junit.jupiter.api.Test;
+import org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.mock.web.MockHttpServletResponse;
+import org.springframework.web.servlet.DispatcherServlet;
+import org.springframework.web.servlet.LocaleResolver;
+import org.springframework.web.servlet.i18n.LocaleChangeInterceptor;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+class BackendI18nConfigTest
+{
+    @Test
+    void defaultsToTraditionalChinese()
+    {
+        LocaleResolver resolver = new I18nConfig().localeResolver();
+
+        assertEquals(new Locale("zh", "TW"), resolver.resolveLocale(new MockHttpServletRequest()));
+    }
+
+    @Test
+    void switchesAllThaiAliasesThroughExistingLangParameter() throws Exception
+    {
+        for (String language : new String[] { "th_TH", "th-TH", "th" })
+        {
+            I18nConfig config = new I18nConfig();
+            LocaleResolver resolver = config.localeResolver();
+            LocaleChangeInterceptor interceptor = config.localeChangeInterceptor();
+            MockHttpServletRequest request = new MockHttpServletRequest();
+            request.setAttribute(DispatcherServlet.LOCALE_RESOLVER_ATTRIBUTE, resolver);
+            request.setParameter("lang", language);
+
+            interceptor.preHandle(request, new MockHttpServletResponse(), new Object());
+
+            assertEquals(new Locale("th", "TH"), resolver.resolveLocale(request), language);
+        }
+    }
+}

+ 19 - 28
ruoyi-common/src/main/java/com/ruoyi/common/utils/LocaleUtils.java

@@ -20,6 +20,11 @@ public class LocaleUtils
     /** 默认语言(台湾繁体) */
     private static final Locale DEFAULT_LOCALE = new Locale("zh", "TW");
 
+    private static final Locale SIMPLIFIED_CHINESE = new Locale("zh", "CN");
+    private static final Locale ENGLISH = new Locale("en", "US");
+    private static final Locale VIETNAMESE = new Locale("vi", "VN");
+    private static final Locale THAI = new Locale("th", "TH");
+
     /** Redis���û��������õ�keyǰ׺ */
     private static final String USER_LANGUAGE_KEY_PREFIX = "user:language:";
 
@@ -35,34 +40,20 @@ public class LocaleUtils
      */
     public static Locale parseLocale(String language)
     {
-        return DEFAULT_LOCALE;
-//        if (StringUtils.isEmpty(language))
-//        {
-//            return DEFAULT_LOCALE;
-//        }
-//
-//        // �����»��߸�ʽ���� zh_CN��
-//        if (language.contains("_"))
-//        {
-//            String[] parts = language.split("_", 2);
-//            if (parts.length == 2)
-//            {
-//                return new Locale(parts[0], parts[1]);
-//            }
-//            else
-//            {
-//                return new Locale(parts[0]);
-//            }
-//        }
-//
-//        // �������ַ���ʽ���� zh-CN��
-//        if (language.contains("-"))
-//        {
-//            return Locale.forLanguageTag(language);
-//        }
-//
-//        // �����Դ��루�� zh, en, vi��
-//        return new Locale(language);
+        if (StringUtils.isEmpty(language) || language.trim().isEmpty())
+        {
+            return DEFAULT_LOCALE;
+        }
+        String normalized = language.trim().replace('-', '_').toLowerCase(Locale.ROOT);
+        return switch (normalized)
+        {
+            case "zh", "zh_cn" -> SIMPLIFIED_CHINESE;
+            case "zh_tw", "tw" -> DEFAULT_LOCALE;
+            case "en", "en_us" -> ENGLISH;
+            case "vi", "vi_vn" -> VIETNAMESE;
+            case "th", "th_th" -> THAI;
+            default -> DEFAULT_LOCALE;
+        };
     }
 
     /**

+ 10 - 2
ruoyi-framework/src/main/java/com/ruoyi/framework/config/I18nConfig.java

@@ -1,6 +1,7 @@
 package com.ruoyi.framework.config;
 
 import java.util.Locale;
+import com.ruoyi.common.utils.LocaleUtils;
 import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Configuration;
 import org.springframework.web.servlet.LocaleResolver;
@@ -29,7 +30,14 @@ public class I18nConfig implements WebMvcConfigurer
     @Bean
     public LocaleChangeInterceptor localeChangeInterceptor()
     {
-        LocaleChangeInterceptor lci = new LocaleChangeInterceptor();
+        LocaleChangeInterceptor lci = new LocaleChangeInterceptor()
+        {
+            @Override
+            protected Locale parseLocaleValue(String localeValue)
+            {
+                return LocaleUtils.parseLocale(localeValue);
+            }
+        };
         // 参数名
         lci.setParamName("lang");
         return lci;
@@ -40,4 +48,4 @@ public class I18nConfig implements WebMvcConfigurer
     {
         registry.addInterceptor(localeChangeInterceptor());
     }
-}
+}

+ 12 - 2
ruoyi-system/src/main/java/com/ruoyi/system/domain/flash/FlashDeliveryOrder.java

@@ -18,13 +18,18 @@ public class FlashDeliveryOrder {
     /** 客户端请求幂等号,与 userId 共同建立唯一约束。 */
     private String clientRequestId;
     private Long userId;
+    /** 创建订单时按收件手机号唯一匹配的普通用户;匹配后不再动态追溯。 */
+    private Long receiverUserId;
     /** 抢单前为空,原子抢单成功后绑定且不允许骑手主动放弃。 */
     private Long riderId;
     private String serviceType;
+    private String deliveryType;
     private String status;
-    /** 用户申报的包裹类别和机车可载重量档。 */
+    /** 用户申报的包裹类别和重量范围。 */
     private String packageType;
-    private String packageSize;
+    private Integer quantity;
+    private String weightRange;
+    private String specification;
     /** NOW 为立即配送,SCHEDULED 为预约取件时段。 */
     private String deliveryMode;
     private Date scheduledPickupStartAt;
@@ -72,6 +77,11 @@ public class FlashDeliveryOrder {
     private BigDecimal distance;
     private Long freight;
     private Long distanceFee;
+    private Long baseDeliveryFee;
+    private BigDecimal urgentRate;
+    private Long minimumUrgentFee;
+    private Long urgentFee;
+    private Long tipAmount;
     private String userNote;
     /** 订单条件更新版本号,用于记录并发状态变化次数。 */
     private Integer version;

+ 2 - 1
ruoyi-system/src/main/java/com/ruoyi/system/domain/flash/FlashDeliveryPricing.java

@@ -14,13 +14,14 @@ import java.util.Date;
 public class FlashDeliveryPricing {
     @TableId(type = IdType.AUTO)
     private Long id;
-    private String serviceType;
     private String startTime;
     private String endTime;
     private BigDecimal startingDistance;
     private Long startingFare;
     private BigDecimal distance;
     private Long freight;
+    private BigDecimal urgentRate;
+    private Long minimumUrgentFee;
     /** 管理员并发更新使用的乐观版本号。 */
     private Integer configVersion;
     private Long updatedBy;

+ 3 - 0
ruoyi-system/src/main/java/com/ruoyi/system/mapper/InfoUserMapper.java

@@ -28,6 +28,9 @@ public interface InfoUserMapper  extends BaseMapper<InfoUser>
      */
     public InfoUser selectInfoUserByUserId(Long userId);
 
+    /** 按标准化手机号查询有效普通用户,最多返回两条以识别重复账号。 */
+    public List<Long> selectOrdinaryUserIdsByNormalizedPhone(@Param("normalizedPhone") String normalizedPhone);
+
     /**
      * 查询用户信息列表
      *

+ 11 - 3
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,
@@ -27,9 +35,9 @@ public interface FlashDeliveryOrderMapper extends BaseMapper<FlashDeliveryOrder>
                           @Param("timeColumn") String timeColumn, @Param("now") Date now);
 
     /** 按订单所有人和预期状态完成用户确认收货。 */
-    int transitionByUser(@Param("id") Long id, @Param("userId") Long userId,
-                         @Param("expectedStatus") String expectedStatus,
-                         @Param("nextStatus") String nextStatus, @Param("now") Date now);
+    int transitionByParticipant(@Param("id") Long id, @Param("userId") Long userId,
+                                @Param("expectedStatus") String expectedStatus,
+                                @Param("nextStatus") String nextStatus, @Param("now") Date now);
 
     /** 供平台和系统任务按预期状态执行条件更新。 */
     int transitionByStatus(@Param("id") Long id, @Param("expectedStatus") String expectedStatus,

+ 8 - 18
ruoyi-system/src/main/java/com/ruoyi/system/mapper/flash/FlashDeliveryPricingMapper.java

@@ -10,34 +10,24 @@ import java.util.List;
 /** 闪送时段运价持久化接口。 */
 public interface FlashDeliveryPricingMapper extends BaseMapper<FlashDeliveryPricing> {
     @Select("""
-            select service_type
-            from flash_delivery_pricing_lock
-            where service_type = #{serviceType}
-            for update
-            """)
-    String lockServiceType(@Param("serviceType") String serviceType);
-
-    @Select("""
-            select id, service_type, start_time, end_time, starting_distance, starting_fare,
-                   distance, freight, config_version, updated_by, create_time, update_time
+            select id, start_time, end_time, starting_distance, starting_fare,
+                   distance, freight, urgent_rate, minimum_urgent_fee,
+                   config_version, updated_by, create_time, update_time
             from flash_delivery_pricing
-            where service_type = #{serviceType}
-              and time(start_time) <= current_time()
-              and time(end_time) > current_time()
+            where start_time <= #{targetTime}
+              and end_time > #{targetTime}
             order by start_time desc
             """)
-    List<FlashDeliveryPricing> selectCurrent(@Param("serviceType") String serviceType);
+    List<FlashDeliveryPricing> selectAtTime(@Param("targetTime") String targetTime);
 
     @Select("""
             select count(1)
             from flash_delivery_pricing
-            where service_type = #{serviceType}
-              and start_time < #{endTime}
+            where start_time < #{endTime}
               and end_time > #{startTime}
               and (#{excludedId} is null or id <> #{excludedId})
             """)
-    int countOverlapping(@Param("serviceType") String serviceType,
-                         @Param("startTime") String startTime,
+    int countOverlapping(@Param("startTime") String startTime,
                          @Param("endTime") String endTime,
                          @Param("excludedId") Long excludedId);
 }

+ 43 - 3
ruoyi-system/src/main/resources/mapper/flash/FlashDeliveryOrderMapper.xml

@@ -6,11 +6,15 @@
         <result property="orderNo" column="order_no"/>
         <result property="clientRequestId" column="client_request_id"/>
         <result property="userId" column="user_id"/>
+        <result property="receiverUserId" column="receiver_user_id"/>
         <result property="riderId" column="rider_id"/>
         <result property="serviceType" column="service_type"/>
+        <result property="deliveryType" column="delivery_type"/>
         <result property="status" column="status"/>
         <result property="packageType" column="package_type"/>
-        <result property="packageSize" column="package_size"/>
+        <result property="quantity" column="quantity"/>
+        <result property="weightRange" column="weight_range"/>
+        <result property="specification" column="specification"/>
         <result property="deliveryMode" column="delivery_mode"/>
         <result property="scheduledPickupStartAt" column="scheduled_pickup_start_at"/>
         <result property="scheduledPickupEndAt" column="scheduled_pickup_end_at"/>
@@ -48,6 +52,11 @@
         <result property="distance" column="distance"/>
         <result property="freight" column="freight"/>
         <result property="distanceFee" column="distance_fee"/>
+        <result property="baseDeliveryFee" column="base_delivery_fee"/>
+        <result property="urgentRate" column="urgent_rate"/>
+        <result property="minimumUrgentFee" column="minimum_urgent_fee"/>
+        <result property="urgentFee" column="urgent_fee"/>
+        <result property="tipAmount" column="tip_amount"/>
         <result property="userNote" column="user_note"/>
         <result property="version" column="version"/>
         <result property="acceptedAt" column="accepted_at"/>
@@ -74,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
@@ -84,10 +124,10 @@
     </update>
 
     <!-- 用户确认收货必须同时满足订单归属和已送达状态。 -->
-    <update id="transitionByUser">
+    <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} and status=#{expectedStatus}
+        where id=#{id} and (user_id=#{userId} or receiver_user_id = #{userId}) and status=#{expectedStatus}
     </update>
 
     <!-- 平台/系统完成同样使用预期状态条件,防止与用户确认并发重复成功。 -->

+ 11 - 0
ruoyi-system/src/main/resources/mapper/infouser/InfoUserMapper.xml

@@ -102,6 +102,17 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         where user_id = #{userId}
     </select>
 
+    <select id="selectOrdinaryUserIdsByNormalizedPhone" resultType="java.lang.Long">
+        select user_id
+        from info_user
+        where user_type = '0'
+          and status = '0'
+          and (del_flag = '0' or del_flag is null)
+          and replace(replace(replace(replace(replace(replace(replace(replace(phone, ' ', ''), char(9), ''), char(10), ''), char(13), ''), '(', ''), ')', ''), '+', ''), '-', '') = #{normalizedPhone}
+        order by user_id
+        limit 2
+    </select>
+
     <insert id="insertInfoUser" parameterType="InfoUser" useGeneratedKeys="true" keyProperty="userId">
         insert into info_user
         <trim prefix="(" suffix=")" suffixOverrides=",">

+ 72 - 1
ruoyi-system/src/test/java/com/ruoyi/system/mapper/flash/FlashDeliveryMapperXmlTest.java

@@ -12,11 +12,71 @@ import java.util.Set;
 import java.util.stream.Collectors;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertNotNull;
 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";
+        String xml;
+        try (InputStream input = getClass().getClassLoader().getResourceAsStream(resource)) {
+            assertNotNull(input);
+            xml = new String(input.readAllBytes(), StandardCharsets.UTF_8).toLowerCase();
+        }
+
+        assertTrue(xml.contains("selectordinaryuseridsbynormalizedphone"));
+        assertTrue(xml.contains("user_type = '0'"));
+        assertTrue(xml.contains("status = '0'"));
+        assertTrue(xml.contains("del_flag = '0' or del_flag is null"));
+        assertFalse(xml.contains("regexp_replace("));
+        assertTrue(xml.contains("replace(phone, ' ', '')"));
+        assertTrue(xml.contains("char(9)"));
+        assertTrue(xml.contains("char(10)"));
+        assertTrue(xml.contains("char(13)"));
+        assertTrue(xml.contains("replace(replace(replace(replace(replace(replace(replace(replace(phone, "
+                + "' ', ''), char(9), ''), char(10), ''), char(13), ''), '(', ''), ')', ''), '+', ''), "
+                + "'-', '') = #{normalizedphone}"));
+        assertTrue(xml.contains("limit 2"));
+    }
+
     @Test
     void orderMapperMapsSnapshotsAndUsesConditionalAtomicUpdates() throws Exception {
         String resource = "mapper/flash/FlashDeliveryOrderMapper.xml";
@@ -36,14 +96,24 @@ class FlashDeliveryMapperXmlTest {
                 .getResultMappings().stream().filter(item -> "amount".equals(item.getProperty()))
                 .findFirst().orElseThrow();
         assertEquals("amount", amount.getColumn());
+        ResultMapping weightRange = configuration
+                .getResultMap("com.ruoyi.system.mapper.flash.FlashDeliveryOrderMapper.FlashDeliveryOrderResult")
+                .getResultMappings().stream().filter(item -> "weightRange".equals(item.getProperty()))
+                .findFirst().orElseThrow();
+        assertEquals("weight_range", weightRange.getColumn());
         Set<String> mappedProperties = configuration
                 .getResultMap("com.ruoyi.system.mapper.flash.FlashDeliveryOrderMapper.FlashDeliveryOrderResult")
                 .getResultMappings().stream()
                 .map(ResultMapping::getProperty)
                 .collect(Collectors.toSet());
         assertTrue(mappedProperties.containsAll(Set.of(
+                "receiverUserId",
+                "deliveryType", "quantity", "weightRange", "specification",
                 "pricingId", "pricingVersion", "pricingStartTime", "pricingEndTime",
-                "startingDistance", "startingFare", "distance", "freight", "distanceFee")));
+                "startingDistance", "startingFare", "distance", "freight", "distanceFee",
+                "baseDeliveryFee", "urgentRate", "minimumUrgentFee", "urgentFee", "tipAmount")));
+        assertFalse(mappedProperties.contains("totalWeightKg"));
+        assertFalse(mappedProperties.contains("packageSize"));
         assertTrue(xml.contains("status = 'WAITING_ACCEPTANCE'"));
         assertTrue(xml.contains("rider_id is null"));
         assertTrue(xml.contains("scheduled_pickup_start_at &lt;= #{now}"));
@@ -51,5 +121,6 @@ class FlashDeliveryMapperXmlTest {
         assertTrue(xml.contains("delivery_pin_code"));
         assertTrue(xml.contains("delivered_at &lt;="));
         assertTrue(xml.contains("version = version + 1"));
+        assertTrue(xml.contains("receiver_user_id = #{userId}"));
     }
 }

+ 6 - 13
ruoyi-system/src/test/java/com/ruoyi/system/mapper/flash/FlashDeliveryPricingMapperContractTest.java

@@ -12,33 +12,26 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
 
 class FlashDeliveryPricingMapperContractTest {
 
-    @Test
-    void serviceTypeLockUsesDatabaseRowLock() throws Exception {
-        Method method = FlashDeliveryPricingMapper.class.getMethod("lockServiceType", String.class);
-        String sql = sql(method);
-
-        assertTrue(sql.contains("flash_delivery_pricing_lock"));
-        assertTrue(sql.contains("for update"));
-    }
-
     @Test
     void currentPeriodUsesInclusiveStartExclusiveEndAndDoesNotHideDuplicates() throws Exception {
-        Method method = FlashDeliveryPricingMapper.class.getMethod("selectCurrent", String.class);
+        Method method = FlashDeliveryPricingMapper.class.getMethod("selectAtTime", String.class);
         String sql = sql(method);
 
-        assertTrue(sql.contains("time(start_time) <= current_time()"));
-        assertTrue(sql.contains("time(end_time) > current_time()"));
+        assertTrue(sql.contains("start_time <= #{targettime}"));
+        assertTrue(sql.contains("end_time > #{targettime}"));
+        assertFalse(sql.contains("service_type"));
         assertFalse(sql.contains("limit"));
     }
 
     @Test
     void overlapQueryAllowsAdjacentPeriodsIncludingEndOfDay() throws Exception {
         Method method = FlashDeliveryPricingMapper.class.getMethod(
-                "countOverlapping", String.class, String.class, String.class, Long.class);
+                "countOverlapping", String.class, String.class, Long.class);
         String sql = sql(method);
 
         assertTrue(sql.contains("start_time < #{endtime}"));
         assertTrue(sql.contains("end_time > #{starttime}"));
+        assertFalse(sql.contains("service_type"));
     }
 
     private String sql(Method method) {

+ 98 - 16
specs/024-flash-delivery/contracts/api.md

@@ -6,9 +6,10 @@
 - 平台权限:`flash:pricing:list/edit`、`flash:order:list/query/cancel/complete`。
 - 响应沿用 `AjaxResult`:`{"code":200,"msg":"...","data":...}`;分页 data 为 `records,total,current,size`。
 - 金额为整数 TWD;路线距离为米,运价配置距离为公里。
-- `serviceType`:`HELP_SEND`、`HELP_PICKUP`、`URGENT`。
+- `serviceType`:`HELP_SEND`、`HELP_PICKUP`。
+- `deliveryType`:`NORMAL`、`URGENT`。
 - `packageType`:`DOCUMENT`、`GIFT`、`CLOTHING`、`BEAUTY`、`DAILY_NECESSITIES`、`FOOD_INGREDIENTS`、`ELECTRONICS`、`SMALL_APPLIANCE`、`OTHER`。
-- `packageSize`:`SMALL`(不超过 5kg)、`MEDIUM`(不超过 12kg)、`LARGE`(不超过 20kg);不提交精确重量
+- 物品信息提交 `packageType`、正整数 `quantity`、必填 `weightRange` 及最长 255 字符的可选 `specification`;`weightRange` 只接受 `UP_TO_5_KG`、`OVER_5_TO_10_KG`、`OVER_10_TO_15_KG`、`OVER_15_TO_20_KG`
 - `deliveryMode`:`NOW`、`SCHEDULED`。预约起止时间使用 ISO 日期时间,时段固定 30 分钟且开始时间不晚于三天后。
 - `status`:`WAITING_ACCEPTANCE`、`ACCEPTED`、`PICKED_UP`、`DELIVERED`、`COMPLETED`、`CANCELLED`。
 
@@ -30,17 +31,19 @@
 
 | 方法 | 路径 | 请求/说明 |
 |---|---|---|
-| GET | `/system/flashDelivery/home` | 返回当前时间存在运价时段的服务及摘要 `serviceType,pricingId,startTime,endTime,startingDistance,startingFare,distance,freight,pricingVersion,currency`;不返回内部更新人、时间或地图密钥 |
-| POST | `/system/flashDelivery/quote` | `serviceType,packageType,packageSize,pickup,delivery` |
-| POST | `/system/flashDelivery/orders` | 报价请求字段 + `clientRequestId,deliveryMode,scheduledPickupStartAt?,scheduledPickupEndAt?,pinRequired?,senderImageUrls?,userNote?`;服务端重算并幂等创建 |
-| GET | `/system/flashDelivery/orders` | `page,size`;仅本人,按创建时间倒序返回全部状态的用户列表摘要 |
-| GET | `/system/flashDelivery/orders/{id}` | 本人 App 订单详情;`data` 直接为订单字段,不返回原始状态日志 |
-| POST | `/system/flashDelivery/orders/{id}/cancel` | `{"reason":"行程变化"}`;仅待接/已接 |
-| POST | `/system/flashDelivery/orders/{id}/confirmReceipt` | 无 body;仅 DELIVERED |
+| GET | `/system/flashDelivery/home` | 返回 `serviceTypes=[HELP_SEND,HELP_PICKUP]`、`deliveryTypes=[NORMAL,URGENT]` 及当前时刻命中的统一运价摘要;不返回内部更新人、时间或地图密钥 |
+| POST | `/system/flashDelivery/quote` | `serviceType,deliveryType,deliveryMode,scheduledPickupStartAt?,scheduledPickupEndAt?,packageType,quantity,weightRange,specification?,tipAmount,pickup,delivery`;立即订单按当前时刻、预约订单按预约开始时刻匹配运价 |
+| POST | `/system/flashDelivery/orders` | 报价请求字段 + `clientRequestId,pricingId,pricingVersion,quotedBaseDeliveryFee,quotedDistanceFee,quotedUrgentFee,quotedAmount,pinRequired?,senderImageUrls?,userNote?`;服务端复算并逐项校验后幂等创建 |
+| GET | `/system/flashDelivery/orders` | `page,size,role?`;`role=sender/receiver`,省略为 `sender`;按创建时间倒序返回当前用户作为寄件人或已绑定收件人的订单摘要 |
+| GET | `/system/flashDelivery/orders/{id}` | 订单创建人或创建时已绑定收件人的 App 订单详情;`data` 直接为订单字段,不返回原始状态日志 |
+| POST | `/system/flashDelivery/orders/{id}/cancel` | `{"reason":"行程变化"}`;仅订单创建人且状态为待接/已接 |
+| POST | `/system/flashDelivery/orders/{id}/confirmReceipt` | 无 body;订单创建人或已绑定收件人可对 DELIVERED 订单确认签收 |
 
-用户列表摘要字段固定为:`id,orderNo,serviceType,status,packageType,deliveryMode,scheduledPickupStartAt,scheduledPickupEndAt,pickupAddress,pickupDetailAddress,deliveryAddress,deliveryDetailAddress,estimatedDurationSeconds,amount,currency,deliveredAt,createTime`。列表不返回联系人、电话、精确坐标、照片、日志或计价审计字段。
+用户列表摘要字段固定为:`id,orderNo,serviceType,deliveryType,status,packageType,quantity,weightRange,deliveryMode,scheduledPickupStartAt,scheduledPickupEndAt,pickupAddress,pickupDetailAddress,deliveryAddress,deliveryDetailAddress,estimatedDurationSeconds,baseDeliveryFee,urgentFee,tipAmount,amount,currency,deliveredAt,createTime`。列表不返回联系人、电话、精确坐标、照片、日志或内部计价审计字段。`receiver` 不是按当前手机号动态查询:订单只在创建时唯一匹配已注册普通用户并固化 `receiverUserId`,未匹配订单以后不追溯认领。
 
-报价响应 data:`serviceType,pricingId,startTime,endTime,distanceMeters,distanceSource,estimatedDurationSeconds?,startingDistance,startingFare,distance,freight,billableDistance,distanceFee,amount,currency,pricingVersion`。`billableDistance` 为按外卖规则处理 0.5 公里边界后的计费公里数,`distanceFee` 与 `amount` 为整数 TWD。地址快照字段为联系人、电话、市/区、交付方式、地址、详细地址和经纬度。客户端传入的金额/距离不会被使用。用户创建和详情响应的 `data` 直接为订单详情字段,照片分别为 `senderImageUrls,pickupImageUrls,deliveryImageUrls`,不返回原始状态日志;`deliveryPinCode` 仅在启用时向订单用户返回,用户仅在 `ACCEPTED`、`PICKED_UP` 阶段获得骑手位置。
+报价响应 data:`serviceType,deliveryType,deliveryMode,scheduledPickupStartAt?,scheduledPickupEndAt?,pricingId,startTime,endTime,distanceMeters,distanceSource,estimatedDurationSeconds?,startingDistance,startingFare,distance,freight,billableDistance,distanceFee,baseDeliveryFee,urgentRate,minimumUrgentFee,urgentFee,tipAmount,amount,currency,pricingVersion`。`billableDistance` 为按外卖规则处理 0.5 公里边界后的计费公里数;全部费用为整数 TWD,`urgentRate` 为百分比。`baseDeliveryFee=startingFare+distanceFee`;普通配送 `urgentFee=0`;加急配送 `urgentFee=max(minimumUrgentFee,roundHalfUp(baseDeliveryFee*urgentRate/100))`;`amount=baseDeliveryFee+urgentFee+tipAmount`。
+
+创建时服务端重新获取路线、按配送方式匹配运价并复算费用。`pricingId`、`pricingVersion`、`quotedBaseDeliveryFee`、`quotedDistanceFee`、`quotedUrgentFee`、`quotedAmount` 任一不一致时不创建订单,返回国际化“报价已变化,请重新确认”及完整最新报价。地址快照字段为联系人、电话、市/区、交付方式、地址、详细地址和经纬度。用户创建和详情响应的 `data` 直接为订单详情字段,照片分别为 `senderImageUrls,pickupImageUrls,deliveryImageUrls`,不返回原始状态日志;`deliveryPinCode` 仅在启用时向订单参与用户返回,用户仅在 `ACCEPTED`、`PICKED_UP` 阶段获得骑手位置。
 
 ## 骑手端
 
@@ -56,7 +59,7 @@
 
 骑手页签状态映射:`newTask=WAITING_ACCEPTANCE`、`toPickup=ACCEPTED`、`delivering=PICKED_UP`、`completed=DELIVERED+COMPLETED`、`cancelled=CANCELLED`。除 `newTask` 外只返回当前骑手本人任务;闪送没有 `refund` 页签。
 
-骑手列表摘要字段固定为:`id,orderNo,serviceType,status,packageType,packageSize,deliveryMode,scheduledPickupStartAt,scheduledPickupEndAt,pinRequired,pickupAddress,pickupDetailAddress,deliveryAddress,deliveryDetailAddress,pickupDistanceMeters,distanceMeters,estimatedDurationSeconds,amount,currency,createTime`。`newTask` 分页 data 在 `records,total,current,size` 外增加 `nearbyTaskCount,highestOrderAmount`;不返回尖峰倍率或骑手收入字段。
+骑手列表摘要字段固定为:`id,orderNo,serviceType,deliveryType,status,packageType,quantity,weightRange,specification,deliveryMode,scheduledPickupStartAt,scheduledPickupEndAt,pinRequired,pickupAddress,pickupDetailAddress,deliveryAddress,deliveryDetailAddress,pickupDistanceMeters,distanceMeters,estimatedDurationSeconds,baseDeliveryFee,urgentFee,tipAmount,amount,currency,createTime`。`newTask` 分页 data 在 `records,total,current,size` 外增加 `nearbyTaskCount,highestOrderAmount`;不返回加急比例、最低加急费或骑手收入字段。
 
 骑手接单前可按原型查看完整取送文字地址,但不返回用户 ID、联系人、电话、实际 PIN、精确经纬度、备注、幂等请求号、履约凭证或日志。抢单成功后才向中单骑手返回完整联系方式、坐标和有权限查看的照片数组。骑手详情在接单前后使用同一个直接 DTO,不返回 `{order,images,logs}` 包装,也不要求 App 判断 `data.order`。
 
@@ -64,8 +67,8 @@
 
 | 方法 | 路径 | 请求/说明 |
 |---|---|---|
-| GET | `/system/flashDelivery/admin/pricing` | 返回全部运价时段,按服务类型和开始时间排序 |
-| POST | `/system/flashDelivery/admin/pricing` | `serviceType,startTime,endTime,startingDistance,startingFare,distance,freight`;保存即生效 |
+| GET | `/system/flashDelivery/admin/pricing` | 返回帮送和帮取共享的全部统一运价时段,按开始时间排序 |
+| POST | `/system/flashDelivery/admin/pricing` | `startTime,endTime,startingDistance,startingFare,distance,freight,urgentRate,minimumUrgentFee`;保存即生效 |
 | PUT | `/system/flashDelivery/admin/pricing/{id}` | 与新增相同的完整请求体;版本原子加一,并发覆盖返回业务错误 |
 | DELETE | `/system/flashDelivery/admin/pricing/{id}` | 删除运价时段并立即停止用于报价 |
 | GET | `/system/flashDelivery/admin/orders` | 分页;可按状态、类型、订单号、用户、骑手筛选 |
@@ -73,6 +76,85 @@
 | POST | `/system/flashDelivery/admin/orders/{id}/cancel` | `reason` 必填;非终态可取消 |
 | POST | `/system/flashDelivery/admin/orders/{id}/complete` | 无 body;仅 DELIVERED |
 
-本期没有支付、退款、取消费、退回费、结算、骑手收入、代购/垫付、精确重量、件数、违禁品电子确认、骑手放弃或自动派单字段和接口。
+本期没有支付、退款、取消费、退回费、结算、骑手收入、代购/垫付、商品金额、违禁品电子确认、骑手放弃或自动派单字段和接口。所有闪送金额保持整数 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,基础配送费、加急费和路线不变。零、负数、空值及金额溢出拒绝。成功返回更新后的直接订单详情和递增版本。此操作仍属于非支付订单金额调整,不产生支付或结算。
+
+### 状态、并发与重试
 
-运价时段使用业务当地时间和 `HH:mm` 格式,采用开始时间包含、结束时间不包含的匹配语义;`24:00` 只允许作为结束时间,跨午夜时段必须拆成两个配置。同一服务类型的时间段不得重叠。起送距离和计价距离必须为正数,起送价格和计价金额必须为正整数;当前时间没有匹配时段时,用户首页不返回该服务,报价和创建接口返回“当前时段暂无可用运价”。
+- 创建人以外的用户(包括已绑定收件人)请求返回订单不存在;状态不是待接单或已有骑手返回 `flash.delivery.edit.not.allowed`。
+- 缺失或负数版本返回 `flash.delivery.order.version.invalid`;旧版本或最终条件 UPDATE 未命中返回 `flash.delivery.state.changed`。
+- 最终 UPDATE 同时匹配订单 ID、创建人、待接单、无骑手和版本;接单、取消、另一次地址修改或追加小费先成功时,本次不得覆盖。
+- 同一小费请求成功后重发旧版本会被拒绝,不会重复加款。网络超时应刷新详情核对小费和总金额,不应自动换新版本重复追加。
+- 地址报价后如追加过小费,旧地址确认失效;刷新详情、重新报价并让用户确认。
+- 当前取消规则、支付和退款范围不变;无新增数据库字段或迁移 SQL。

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

@@ -2,17 +2,13 @@
 
 ## `flash_delivery_order`
 
-核心字段:`id`、唯一 `order_no`、与用户联合唯一的 `client_request_id`、`user_id`、`rider_id`、`service_type`、`status`;`package_type`、`package_size`;`delivery_mode`、预约开始/结束时间;`pin_required`、四位交付 PIN;取件/收件联系人、电话、市/区、交付方式、地址、详细地址和经纬度快照;`distance_meters`、`distance_source`、`estimated_duration_seconds`;整数 `amount`、`currency`、`pricing_id`、`pricing_version`、`pricing_start_time`、`pricing_end_time`、`starting_distance`、`starting_fare`、`distance`、`freight`、`distance_fee` 计价快照;`user_note`、`version`、关键状态时间、取消审计及创建/更新时间。
+核心字段:`id`、唯一 `order_no`、与用户联合唯一的 `client_request_id`、创建人 `user_id`、创建时按收件手机号唯一匹配并固化的可空 `receiver_user_id`、`rider_id`、帮送/帮取 `service_type`、普通/加急 `delivery_type`、`status`;`package_type`、正整数 `quantity`、四档 `weight_range`、可空 `specification`;`delivery_mode`、预约开始/结束时间;`pin_required`、四位交付 PIN;取件/收件联系人、电话、市/区、交付方式、地址、详细地址和经纬度快照;`distance_meters`、`distance_source`、`estimated_duration_seconds`;整数 `amount`、`currency`、`pricing_id`、`pricing_version`、`pricing_start_time`、`pricing_end_time`、`starting_distance`、`starting_fare`、`distance`、`freight`、`distance_fee`、`base_delivery_fee`、`urgent_rate`、`minimum_urgent_fee`、`urgent_fee`、`tip_amount` 计价快照;`user_note`、`version`、关键状态时间、取消审计及创建/更新时间。
 
-索引:订单号唯一、用户幂等号唯一、待抢排序、用户列表、骑手列表、自动完成扫描
+索引:订单号唯一、用户幂等号唯一、待抢排序、创建人列表、收件人列表、骑手列表、自动完成扫描。`receiver_user_id` 只保存创建当时唯一匹配的普通用户;空值和历史订单不补写,用户手机号变更也不回写
 
 ## `flash_delivery_pricing`
 
-每个服务类型可保存多个运价时段。字段为 `service_type`、`start_time`、`end_time`、`starting_distance`、整数 `starting_fare`、`distance`、整数 `freight`、`config_version`、`updated_by` 和创建/更新时间。配置保存即生效,不保存 `enabled`;同一服务类型的时间段不得重叠,索引支持按服务类型和时间匹配当前时段。有效修改后版本加一,历史订单不回写。当前时段查询不使用 `LIMIT 1` 隐藏异常数据;若匹配到多条则明确返回配置重叠错误。
-
-## `flash_delivery_pricing_lock`
-
-以 `service_type` 为主键,固定保存 `HELP_SEND`、`HELP_PICKUP`、`URGENT` 三行。新增、修改和删除运价时,事务先通过 `SELECT ... FOR UPDATE` 锁定涉及的服务类型,再检查重叠并写入,避免并发请求同时通过“先查重、后写入”。修改跨服务类型时按服务类型字典序加锁,避免反向加锁造成死锁。
+帮送与帮取共享多个统一运价时段,不再保存 `service_type`。字段为 `start_time`、`end_time`、`starting_distance`、整数 `starting_fare`、`distance`、整数 `freight`、非负两位小数百分比 `urgent_rate`、非负整数 `minimum_urgent_fee`、`config_version`、`updated_by` 和创建/更新时间。配置保存即生效,不保存 `enabled`;全部时间段不得重叠,索引支持按时间匹配目标时刻。有效修改后版本加一,历史订单不回写。时段查询不使用 `LIMIT 1` 隐藏异常数据;若匹配到多条则明确返回配置重叠错误。运价写入不再维护专用锁表;新增和修改仍执行重叠校验,修改仍使用 `config_version` 防止旧版本覆盖。
 
 ## `flash_delivery_order_image`
 

+ 60 - 10
specs/024-flash-delivery/design.md

@@ -14,7 +14,7 @@
 6. 闪送
 7. 闪送 送
 
-最初的蓝湖 7 页明确展示三种服务、取件与收件地址、联系人和电话、地址簿、起步价格及下单入口,未展示重量和类别;2026-08-24 用户端/骑手端补充原型进一步明确包裹类别、重量档、预约配送、交付 PIN、寄件图片和费用明细。两套设计均未明确支付、代购、精确重量或件数规则
+最初的蓝湖 7 页明确展示三种入口、取件与收件地址、联系人和电话、地址簿、起步价格及下单入口;后续用户端/骑手端原型明确包裹类别、数量、四档重量范围、体积/规格、预约配送、交付 PIN、寄件图片以及基础配送、距离加价、加急和骑手小费明细。2026-09-07 起按设计稿使用重量范围,不要求用户填写无法准确获知的精确重量
 
 ## 2. 架构决策
 
@@ -74,7 +74,7 @@ ruoyi-system
 
 - “闪送管理”作为父菜单,“价格配置”和“闪送订单”作为两个子页面。
 - `src/api/flashDelivery/index.js` 集中封装平台接口,页面不直接拼接请求。
-- 价格配置页按帮送、帮取和加急送展示运价时段,新增与编辑弹窗承载时间、距离和整数金额校验,支持删除不再需要的时段;配置保存即生效,不提供启停开关。
+- 价格配置页展示帮送和帮取共享的统一运价时段,新增与编辑弹窗承载时间、距离、整数金额、加急比例和最低加急费校验,支持删除不再需要的时段;配置保存即生效,不提供启停开关。
 - 订单页以服务端分页表格为主体,详情弹窗分区展示概况、地址、费用、凭证和日志;取消原因使用独立输入弹窗,完成操作使用二次确认。
 - 操作按钮同时使用状态条件和 `v-hasPermi` 控制可见性,后端权限与状态机仍是最终安全边界。
 - 所有新增文本集中在四个前端语言文件的 `flashDelivery` 对象中,key 集合保持一致。
@@ -93,11 +93,11 @@ ruoyi-system
 
 ### 4.1 `flash_delivery_order`
 
-保存订单号、客户端请求号、用户、骑手、服务类型、包裹类别、重量档、配送方式、预约时段、交付 PIN、取件与收件信息快照、路线距离、距离来源、预计时长、报价金额、匹配时段的起送与里程计价快照、币种、计价配置版本、备注、状态、并发版本号、关键操作时间与取消信息。
+保存订单号、客户端请求号、用户、骑手、帮送/帮取业务场景、普通/加急配送等级、包裹类别、数量、重量范围、规格、配送方式、预约时段、交付 PIN、取件与收件信息快照、路线距离、距离来源、预计时长、基础配送费、距离费、加急费、小费、总金额、匹配时段完整计价快照、币种、计价配置版本、备注、状态、并发版本号、关键操作时间与取消信息。
 
 ### 4.2 `flash_delivery_pricing`
 
-按服务类型保存多个不重叠的运价时段;每个时段保存开始时间、结束时间、起送距离、起送价格、计价距离、计价金额、配置版本、修改人和修改时间。保存即生效,删除即失效,不设置启停字段。
+保存一套供帮送和帮取共享、全局互不重叠的运价时段;每个时段保存开始时间、结束时间、起送距离、起送价格、计价距离、计价金额、加急比例、最低加急费、配置版本、修改人和修改时间。保存即生效,删除即失效,不设置启停字段。低频管理员配置不使用专用数据库锁表;服务端继续检查时段重叠,修改继续使用配置版本条件,读取侧发现多条匹配时明确报错。
 
 ### 4.3 `flash_delivery_order_image`
 
@@ -114,14 +114,17 @@ ruoyi-system
 ## 5. 计价与距离
 
 ```text
-当前配置 = 按服务类型和当前时间匹配唯一运价时段
+目标计价时刻 = NOW ? 当前报价时刻 : scheduledPickupStartAt
+当前配置 = 按目标计价时刻匹配唯一统一运价时段
 超出公里 = max(0, 路线距离公里 - 起送距离)
 计费公里 = 超出公里 < 0.5 ? 0 : 超出公里 < 1 ? 1 : 超出公里
 里程费用 = 计费公里 * (计价金额 / 计价距离)
-价格 = 起送价格 + 四舍五入到整数元的里程费用
+普通配送费 = 起送价格 + 四舍五入到整数元的里程费用
+加急费 = NORMAL ? 0 : max(最低加急费, 四舍五入(普通配送费 * 加急比例 / 100))
+订单金额 = 普通配送费 + 加急费 + 骑手小费
 ```
 
-计价规则与现有外卖订单一致,但闪送使用自己的时段配置表。起送价格和计价金额为正整数新台币;距离与除法过程使用十进制定点数,里程费用采用 `HALF_UP` 四舍五入到整数元,最终价格不显示小数,也不执行外卖旧实现中金额达到 1000 后的千位特殊取整。帮送、帮取和加急送分别拥有多个互不重叠的时段;当前时间没有匹配时段时拒绝报价和创建。路线预计时长继续返回和保存,仅用于履约展示,不参与计价。报价优先使用地图路线距离,失败时降级为直线距离;创建订单时服务端必须重新匹配时段、计算价格并固化配置快照。
+普通计价规则与现有外卖订单一致,但闪送使用自己的统一时段配置表。起送价格和计价金额为正整数新台币;距离、比例与除法过程使用十进制定点数,里程费用和比例加急费采用 `HALF_UP` 四舍五入到整数元,最终价格不显示小数,也不执行外卖旧实现中金额达到 1000 后的千位特殊取整。立即订单按当前时刻、预约订单按预约开始时刻匹配时段;目标时刻没有匹配时段时拒绝报价和创建。路线预计时长继续返回和保存,仅用于履约展示,不参与计价。报价优先使用地图路线距离,失败时降级为直线距离;创建订单时服务端必须重新匹配时段、计算价格并核对客户端回传报价,完全一致后才能固化配置快照。
 
 ## 6. 状态机
 
@@ -160,7 +163,7 @@ COMPLETED
 
 ## 8. 测试方向
 
-- 时段匹配、重叠拒绝、0.5 公里边界、整数金额取整和配置版本测试。
+- 统一时段匹配、预约取件时段匹配、重叠拒绝、0.5 公里边界、整数金额取整、比例加急费、最低加急费、小费和配置版本测试。
 - 地图路线成功、失败及直线降级测试。
 - 地址和订单跨用户访问测试。
 - 多骑手并发抢单测试。
@@ -175,7 +178,7 @@ COMPLETED
 
 ## 9. 明确延期内容
 
-支付、退款、分账、骑手收入、代购、垫付、商品金额、精确重量、件数、自动派单、动态附近骑手数、预计接单时间以及用户端、骑手端和商家端页面均不在本期范围。
+支付、退款、分账、骑手收入、代购、垫付、商品金额、自动派单、动态附近骑手数、预计接单时间以及用户端、骑手端和商家端页面均不在本期范围。物品数量、重量范围、体积/规格和骑手小费属于本期接口范围。
 
 ## 10. App 订单列表精简增量(2026-09-01)
 
@@ -198,7 +201,7 @@ COMPLETED
 - 参数名、默认分页和坐标用途与骑手外卖订单列表保持一致;经纬度用于 `newTask` 的附近范围、取件点距离和排序,其他页签忽略坐标。
 - `newTask -> WAITING_ACCEPTANCE`;`toPickup -> ACCEPTED`;`delivering -> PICKED_UP`;`completed -> DELIVERED + COMPLETED`;`cancelled -> CANCELLED`。除 `newTask` 外均只查询当前骑手本人。
 - 闪送没有退款流程,因此不接受外卖列表的 `refund` 页签。
-- 列表使用统一卡片摘要 DTO,包含订单标识、服务类型、状态、包裹类别/重量档、配送方式/预约时段、是否需要 PIN、完整取送文字地址、取件点距离、路线距离、预计时长、订单金额和创建时间。
+- 列表使用统一卡片摘要 DTO,包含订单标识、业务场景、配送等级、状态、包裹类别/数量/重量范围、配送方式/预约时段、是否需要 PIN、完整取送文字地址、取件点距离、路线距离、预计时长、费用明细、订单金额和创建时间。
 - `newTask` 分页响应额外返回附近任务总数与最高订单金额,用于原型顶部摘要;不返回已移除的尖峰倍率,也不将订单金额表述为尚未实现的骑手收入。
 - 接单前不返回联系人、电话、实际 PIN、精确坐标、用户备注、图片凭证、状态日志、用户 ID、幂等号或计价审计字段。接单成功后,详情接口仅向中单骑手返回完整履约信息。
 
@@ -214,3 +217,50 @@ COMPLETED
 - App 与后端同步切换到新接口,不保留测试阶段旧 `/available`、`/mine` 路由或旧参数兼容层。
 - `page`、`size` 或 `tab` 非法时返回国际化业务错误;坐标必须成对出现并通过经纬度范围校验。
 - 列表必须先按页签和角色过滤,再由数据库分页;禁止查出完整集合后在 Java 或客户端过滤。
+
+## 11. 2026-09-03 蓝湖稿对齐增量
+
+### 11.1 已确认边界
+
+- 闪送不接入支付;订单创建后直接进入 `WAITING_ACCEPTANCE`,蓝湖中的待支付、去支付和支付倒计时不进入接口。
+- 金额继续使用整数 TWD,蓝湖小数价格作为视觉占位处理。
+- 不实现动态附近骑手数或预计接单分钟数,用户端文案统一为“发布后等待附近骑手接单”。
+- `deliveryType=URGENT` 按 1 对 1 专送实现骑手独占;`serviceType` 只表示帮送或帮取业务场景。
+
+### 11.2 “我收的”身份绑定
+
+采用创建时快照方案。创建订单时将收件手机号去除首尾/内部空白、`+`、`-` 和圆括号但保留国家码数字,并以同样规则匹配当时已注册的普通用户:恰好匹配一名时保存其用户 ID 到 `receiver_user_id`;没有匹配或出现重复匹配时保存空值。该字段创建后不可因注册、手机号换绑或地址簿变化而自动改写。
+
+没有采用每次查询时按当前手机号动态匹配,因为手机号回收或换绑会让新持有人读取历史订单;也不实现注册后追溯认领,因为这需要独立的手机号归属证明、认领审计和撤销流程,超出本次范围。
+
+用户列表增加 `role=sender/receiver`,省略时保持原有 `sender` 行为。寄件人与绑定收件人都能查看详情和交付 PIN、确认签收;取消仍只允许订单创建人。列表和详情权限直接使用固化用户 ID,不使用请求中的手机号判断。
+
+### 11.3 急送独占与并发
+
+所有骑手接单入口先获取同一 Redisson 骑手锁 `lock:delivery:rider:{riderId}`,再查询该骑手的有效外卖和闪送任务。抢 `deliveryType=URGENT` 订单时要求不存在未送达外卖及 `ACCEPTED/PICKED_UP` 闪送;抢普通外卖或 `deliveryType=NORMAL` 闪送时,如果已有 `ACCEPTED/PICKED_UP` 急送则拒绝。急送进入 `DELIVERED`、`COMPLETED` 或 `CANCELLED` 后解除独占。
+
+同一骑手的外卖和闪送接单必须共享这把分布式锁,确保并发请求不能分别在两张订单表中同时通过“无冲突”检查。锁使用 Redisson 看门狗续期,不指定固定租期,并通过事务同步回调在提交或回滚完成后释放;获取超时、线程中断或 Redis 异常均拒绝本次接单。数据库订单条件更新继续负责防止不同骑手抢中同一订单。
+
+## 12. 2026-09-07 费用与物品信息对齐增量
+
+### 12.1 正交业务维度
+
+`serviceType` 只区分 `HELP_SEND` 和 `HELP_PICKUP`;`deliveryType` 独立区分 `NORMAL` 和 `URGENT`;`deliveryMode` 继续区分 `NOW` 和 `SCHEDULED`。帮送和帮取共享同一套运价,只有 `deliveryType=URGENT` 启用加急计价和骑手独占。
+
+### 12.2 统一分时段运价
+
+平台每个时间段配置 `startTime`、`endTime`、`startingDistance`、`startingFare`、`distance`、`freight`、`urgentRate` 和 `minimumUrgentFee`。所有时间段全局互斥,不再按 `serviceType` 分组。立即订单按报价时刻匹配,预约订单按 `scheduledPickupStartAt` 匹配;日期只用于确定预约有效性,时间段按业务当地时间的时分命中。
+
+普通配送费先沿用现有起送与超距规则计算:`baseDeliveryFee = startingFare + distanceFee`。普通配送 `urgentFee=0`;加急配送使用 `urgentFee = max(minimumUrgentFee, roundHalfUp(baseDeliveryFee × urgentRate / 100))`。骑手小费 `tipAmount` 是用户输入的独立非负整数 TWD,不参与加急费计算;最终 `amount = baseDeliveryFee + urgentFee + tipAmount`。
+
+### 12.3 报价回传与创建校验
+
+报价响应返回配置 ID/版本、距离及来源、普通运价明细、`baseDeliveryFee`、`urgentRate`、`minimumUrgentFee`、`urgentFee`、`tipAmount` 和 `amount`。创建订单时客户端回传配置 ID/版本以及 `baseDeliveryFee`、`distanceFee`、`urgentFee`、`amount`;服务端重新取路线、匹配时间段并计算费用,逐项精确一致才创建。任何配置或费用变化都不得静默创建订单,响应必须携带最新报价供 App 重新确认。
+
+### 12.4 物品信息
+
+请求保存 `packageType`、`quantity`、`weightRange` 和可选 `specification`。数量必须是正整数;`weightRange` 必须为 `UP_TO_5_KG`、`OVER_5_TO_10_KG`、`OVER_10_TO_15_KG`、`OVER_15_TO_20_KG` 之一;规格说明去除首尾空格后最长 255 字符。重量范围由客户端按设计稿选择并作为订单快照保留给用户、骑手和平台,不再维护精确重量或另一套包裹尺寸字段。
+
+### 12.5 非支付边界
+
+费用字段是报价和订单应付金额快照,不代表已经完成资金扣款。订单创建后仍直接进入 `WAITING_ACCEPTANCE`,不增加待支付状态、支付倒计时、支付回调或退款流程;骑手小费也只作为订单费用明细和接单参考展示。

+ 254 - 0
specs/024-flash-delivery/implementation-plan-2026-09-04.md

@@ -0,0 +1,254 @@
+# 闪送“我收的”与急送独占 Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** 为闪送订单增加创建时收件账号绑定和“我收的”查询,并用骑手级 Redisson 分布式锁保证 `URGENT` 跨外卖、闪送独占。
+
+**Architecture:** `flash_delivery_order.receiver_user_id` 保存创建时唯一匹配的普通用户,不动态追溯;用户列表通过 `role` 在数据库分页前切换寄件人/收件人条件。`RiderDeliveryLockService` 统一串行化同一骑手的接单请求,`RiderDeliveryExclusivityService` 统一查询两类进行中订单;闪送和外卖接单都在同一锁键内完成检查与原子更新。
+
+**Tech Stack:** Java 21、Spring Boot、Spring Transaction、MyBatis/MyBatis-Plus、MySQL、Redisson、JUnit 5、Mockito。
+
+## Global Constraints
+
+- 不接入闪送支付、退款、结算或骑手收入分账;创建成功直接进入 `WAITING_ACCEPTANCE`。
+- 所有闪送金额保持整数 TWD。
+- 数据库迁移只能追加到 `updatesql/sql.md`,不得直接执行。
+- Controller token 使用 `@RequestHeader String token`,查询参数显式使用 `@RequestParam`。
+- 业务错误必须使用五语言 i18n key,不硬编码单一语言。
+- 保持 `ruoyi-admin -> ruoyi-system` 依赖方向,跨订单集成服务放在 `ruoyi-admin`。
+- 不创建独立 worktree,不修改用户全局 JDK 配置;验证命令临时使用 `C:\Users\qmj\.jdks\graalvm-jdk-21.0.7`。
+
+---
+
+### Task 1: 创建时绑定收件账号并支持“我收的”
+
+**Files:**
+- Modify: `ruoyi-system/src/main/java/com/ruoyi/system/domain/flash/FlashDeliveryOrder.java`
+- Modify: `ruoyi-system/src/main/java/com/ruoyi/system/mapper/InfoUserMapper.java`
+- Modify: `ruoyi-system/src/main/java/com/ruoyi/system/mapper/flash/FlashDeliveryOrderMapper.java`
+- Modify: `ruoyi-system/src/main/resources/mapper/infouser/InfoUserMapper.xml`
+- Modify: `ruoyi-system/src/main/resources/mapper/flash/FlashDeliveryOrderMapper.xml`
+- Modify: `ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryApplicationService.java`
+- Modify: `ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/controller/FlashDeliveryUserController.java`
+- Modify: `ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryApplicationServiceTest.java`
+- Modify: `ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/controller/FlashDeliveryControllerContractTest.java`
+- Modify: `ruoyi-system/src/test/java/com/ruoyi/system/mapper/flash/FlashDeliveryMapperXmlTest.java`
+
+**Interfaces:**
+- Consumes: `delivery.phone` from `FlashDeliveryCreateRequest` and token-derived current user ID.
+- Produces: `FlashDeliveryOrder.receiverUserId`; `InfoUserMapper.selectOrdinaryUserIdsByNormalizedPhone(String)`; `FlashDeliveryOrderMapper.transitionByParticipant(...)`; `GET /system/flashDelivery/orders?page=1&size=10&role=receiver`; participant-aware detail and confirmation.
+
+- [x] **Step 1: Write receiver-binding and participant-access tests**
+
+Add service tests whose observable assertions are:
+
+```java
+when(userMapper.selectOrdinaryUserIdsByNormalizedPhone("886912345678"))
+        .thenReturn(List.of(88L));
+fixture.service.create(7L, requestWithDeliveryPhone("+886 912-345-678"));
+assertEquals(88L, insertedOrder.getReceiverUserId());
+
+when(userMapper.selectOrdinaryUserIdsByNormalizedPhone(anyString()))
+        .thenReturn(List.of(88L, 99L));
+fixture.service.create(7L, requestWithDeliveryPhone("+886 912-345-678"));
+assertNull(insertedOrder.getReceiverUserId());
+
+fixture.service.userOrders(88L, 1, 10, "receiver");
+assertTrue(capturedQuery.getSqlSegment().contains("receiver_user_id"));
+
+assertDoesNotThrow(() -> fixture.service.userDetail(88L, orderId));
+assertThrows(ServiceException.class,
+        () -> fixture.service.userCancel(88L, orderId, reasonRequest()));
+```
+
+Add Controller reflection tests requiring optional `@RequestParam String role`. Extend the mapper XML test to assert `receiverUserId` mapping and participant transition SQL.
+
+- [x] **Step 2: Run tests and verify RED**
+
+```powershell
+$env:JAVA_HOME='C:\Users\qmj\.jdks\graalvm-jdk-21.0.7'
+$env:PATH="$env:JAVA_HOME\bin;$env:PATH"
+mvn -pl ruoyi-admin -am -Dtest='FlashDeliveryApplicationServiceTest,FlashDeliveryControllerContractTest,FlashDeliveryMapperXmlTest' -Dsurefire.failIfNoSpecifiedTests=false test
+```
+
+Expected: compilation or assertions fail because `receiverUserId`, role-aware list, participant transition and phone lookup do not exist.
+
+- [x] **Step 3: Implement the minimal receiver model and queries**
+
+Add:
+
+```java
+private Long receiverUserId;
+```
+
+Add `InfoUserMapper` lookup returning at most two IDs so ambiguous matches are detectable:
+
+```java
+List<Long> selectOrdinaryUserIdsByNormalizedPhone(@Param("normalizedPhone") String normalizedPhone);
+```
+
+The XML query must require `user_type='0'`, active/non-deleted account, normalize stored phone by removing whitespace, `+`, `-`, `(` and `)`, and use `LIMIT 2`. In `create`, resolve once before insert and set only when the returned list contains exactly one ID. Idempotent retries return the original order without resolving again.
+
+Change the list signature to:
+
+```java
+IPage<FlashDeliveryUserOrderListView> userOrders(
+        Long userId, int pageNum, int pageSize, String role)
+```
+
+Accept only `sender` and `receiver`, default null/blank to `sender`, and apply `user_id` or `receiver_user_id` before `selectPage`. Detail allows either participant; cancellation remains creator-only. Replace `transitionByUser` with `transitionByParticipant` and participant-aware SQL:
+
+```sql
+WHERE id = #{id}
+  AND (user_id = #{userId} OR receiver_user_id = #{userId})
+  AND status = #{expectedStatus}
+```
+
+- [x] **Step 4: Run Task 1 tests and verify GREEN**
+
+Run the Step 2 command. Expected: all specified tests pass with zero failures and errors.
+
+---
+
+### Task 2: 骑手级 Redisson 锁和独占判定
+
+**Files:**
+- Create: `ruoyi-admin/src/main/java/com/ruoyi/app/order/RiderDeliveryLockService.java`
+- Create: `ruoyi-admin/src/main/java/com/ruoyi/app/order/RiderDeliveryExclusivityService.java`
+- Create: `ruoyi-admin/src/test/java/com/ruoyi/app/order/RiderDeliveryLockServiceTest.java`
+- Create: `ruoyi-admin/src/test/java/com/ruoyi/app/order/RiderDeliveryExclusivityServiceTest.java`
+- Modify: `ruoyi-admin/src/main/resources/i18n/messages.properties`
+- Modify: `ruoyi-admin/src/main/resources/i18n/messages_zh_CN.properties`
+- Modify: `ruoyi-admin/src/main/resources/i18n/messages_zh_TW.properties`
+- Modify: `ruoyi-admin/src/main/resources/i18n/messages_en_US.properties`
+- Modify: `ruoyi-admin/src/main/resources/i18n/messages_vi.properties`
+
+**Interfaces:**
+- Consumes: `RedissonClient`, `IPosOrderService`, `FlashDeliveryOrderMapper`, rider ID and target service type.
+- Produces: `RiderDeliveryLockService.withLock(Long, LockedCall<T>)`; `RiderDeliveryExclusivityService.assertCanAcceptFood(Long)`; `assertCanAcceptFlash(Long,String)`.
+
+- [x] **Step 1: Write failing lock lifecycle tests**
+
+Test the real lock wrapper behavior around a mocked external Redisson boundary:
+
+```java
+when(redissonClient.getLock("lock:delivery:rider:123")).thenReturn(lock);
+when(lock.tryLock(3L, TimeUnit.SECONDS)).thenReturn(true);
+
+String result = service.withLock(123L, () -> "accepted");
+assertEquals("accepted", result);
+```
+
+Verify through behavior that acquisition timeout, interruption and runtime Redis failure throw `ServiceException`; outside a transaction the lock releases in `finally`; with active transaction synchronization it remains held until `afterCompletion`.
+
+- [x] **Step 2: Write failing exclusivity tests**
+
+Cover these literal outcomes:
+
+```text
+URGENT + active food         -> reject
+URGENT + active normal flash -> reject
+URGENT + no active work      -> allow
+normal flash + active URGENT -> reject
+food + active URGENT         -> reject
+food + active normal flash   -> allow
+```
+
+Active flash statuses are exactly `ACCEPTED` and `PICKED_UP`; active food uses `qs_id=riderId`, `delivery_status in (1,2)`, `state in (0,1,2)` and `after_sale_status=0`.
+
+- [x] **Step 3: Run tests and verify RED**
+
+```powershell
+mvn -pl ruoyi-admin -am -Dtest='RiderDeliveryLockServiceTest,RiderDeliveryExclusivityServiceTest' -Dsurefire.failIfNoSpecifiedTests=false test
+```
+
+Expected: compilation fails because both services are absent.
+
+- [x] **Step 4: Implement lock and exclusivity services**
+
+`RiderDeliveryLockService` uses:
+
+```java
+RLock lock = redissonClient.getLock("lock:delivery:rider:" + riderId);
+boolean acquired = lock.tryLock(3L, TimeUnit.SECONDS);
+```
+
+Do not pass a lease time so Redisson watchdog renews the lock. Register `TransactionSynchronization.afterCompletion` and unlock only when `lock.isHeldByCurrentThread()`; if no synchronization is active, release in `finally`. Translate timeout, interruption and Redis failure to the existing system-busy/operation-interrupted i18n errors.
+
+`RiderDeliveryExclusivityService` uses real MyBatis-Plus count queries and throws the new `flash.delivery.rider.exclusive.conflict` message when rules reject acceptance.
+
+- [x] **Step 5: Run Task 2 tests and verify GREEN**
+
+Run the Step 3 command. Expected: all specified tests pass with zero failures and errors.
+
+---
+
+### Task 3: 将独占规则接入闪送与外卖接单并完成交付文档
+
+**Files:**
+- Modify: `ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryApplicationService.java`
+- Modify: `ruoyi-admin/src/main/java/com/ruoyi/app/order/OrderLifecycleService.java`
+- Modify: `ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryApplicationServiceTest.java`
+- Modify: `ruoyi-admin/src/test/java/com/ruoyi/app/order/OrderLifecycleServiceTest.java`
+- Modify: `updatesql/sql.md`
+- Modify: `docs/flash-delivery-app-api.md`
+- Modify: `specs/024-flash-delivery/quickstart.md`
+- Modify: `specs/024-flash-delivery/tasks.md`
+
+**Interfaces:**
+- Consumes: Task 1 participant model and Task 2 lock/exclusivity services.
+- Produces: both effective acceptance paths execute eligibility checks and order update under `lock:delivery:rider:{riderId}`; documented receiver role API and SQL migration.
+
+- [x] **Step 1: Write failing integration-level service tests**
+
+For flash acceptance, assert the returned order is accepted only when `withLock` invokes the callback and exclusivity allows it; assert exclusive conflict leaves `orderMapper.accept` uncalled. For food acceptance, assert `OrderLifecycleService.acceptDeliveryByRider` enters the same rider lock and checks `assertCanAcceptFood` before applying CAS.
+
+- [x] **Step 2: Run tests and verify RED**
+
+```powershell
+mvn -pl ruoyi-admin -am -Dtest='FlashDeliveryApplicationServiceTest,OrderLifecycleServiceTest,RiderDeliveryLockServiceTest,RiderDeliveryExclusivityServiceTest' -Dsurefire.failIfNoSpecifiedTests=false test
+```
+
+Expected: assertions fail because acceptance methods do not invoke the shared lock and exclusivity services.
+
+- [x] **Step 3: Integrate both acceptance services**
+
+Wrap `FlashDeliveryApplicationService.accept` as:
+
+```java
+return riderDeliveryLockService.withLock(riderId, () -> {
+    FlashDeliveryOrder order = requireOrder(orderId);
+    exclusivityService.assertCanAcceptFlash(riderId, order.getServiceType());
+    // existing conditional accept, log and response
+});
+```
+
+Wrap the full body of `OrderLifecycleService.acceptDeliveryByRider` with the same lock and call `assertCanAcceptFood(riderId)` before `applyCas`. Keep the existing per-order conditional update; do not lock pickup or delivery operations.
+
+- [x] **Step 4: Record SQL and update integration docs**
+
+Append, but do not execute:
+
+```sql
+ALTER TABLE flash_delivery_order
+  ADD COLUMN receiver_user_id BIGINT NULL COMMENT '创建时匹配的收件用户ID' AFTER user_id,
+  ADD KEY idx_flash_receiver_list (receiver_user_id, create_time);
+```
+
+Document `role=sender/receiver`, default `sender`, participant detail/signature rules, no historical claim, integer TWD, no payment and the UI copy “发布后等待附近骑手接单”. Mark T055–T060 complete only after their corresponding source and docs exist.
+
+- [x] **Step 5: Run focused tests and full module verification**
+
+```powershell
+$env:JAVA_HOME='C:\Users\qmj\.jdks\graalvm-jdk-21.0.7'
+$env:PATH="$env:JAVA_HOME\bin;$env:PATH"
+mvn -pl ruoyi-admin -am -Dtest='FlashDeliveryApplicationServiceTest,FlashDeliveryControllerContractTest,FlashDeliveryMapperXmlTest,OrderLifecycleServiceTest,RiderDeliveryLockServiceTest,RiderDeliveryExclusivityServiceTest,FlashDeliveryI18nContractTest' -Dsurefire.failIfNoSpecifiedTests=false test
+mvn -pl ruoyi-admin -am -DskipTests package
+git diff --check
+```
+
+Expected: tests report zero failures/errors, package exits 0, and diff check produces no errors.
+
+- [x] **Step 6: Audit and commit**
+
+Inspect `git diff --name-only` and `git diff --cached --name-only`; stage only Phase 12 production, tests, five i18n bundles, SQL and flash-delivery documents. Commit with a Chinese subject. Do not push unless the user requests it.

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

@@ -4,7 +4,7 @@
 
 ## 摘要
 
-在既有独立闪送模块上补充包裹类别/重量档、立即或预约配送、可选 PIN 交付、寄件图片、费用明细、页面状态分组、骑手公开摘要和角色安全响应。服务端继续负责路线报价、骑手原子抢单、取件/送达图片、用户签收、24 小时自动完成和平台介入;支付、退款、结算、骑手收入与自动派单仍不在本期范围。
+在既有独立闪送模块上补充包裹类别/四档重量范围、立即或预约配送、可选 PIN 交付、寄件图片、费用明细、页面状态分组、骑手公开摘要和角色安全响应。服务端继续负责路线报价、骑手原子抢单、取件/送达图片、用户签收、24 小时自动完成和平台介入;支付、退款、结算、骑手收入与自动派单仍不在本期范围。
 
 ## 技术上下文
 
@@ -60,7 +60,7 @@ ruoyi-system/src/test/java/com/ruoyi/system/mapper/flash/
 7. 寄件图片在创建事务中写入;取件/送达图片在状态更新事务中写入。三类凭证均校验 1 至 9 个 HTTP(S) URL,取件和送达不得为空。
 8. 预约订单沿用 `WAITING_ACCEPTANCE`,可抢查询和原子抢单 SQL 同时限制 `scheduled_pickup_start_at <= now`,不增加仅为调度使用的新状态。
 9. PIN 默认启用并由服务端生成四位数字;用户详情可见,骑手响应始终剔除,送达事务先校验 PIN 再写凭证和状态。
-10. 用户/骑手使用业务视图,平台详情保留完整审计实体;待抢视图只增加类别、重量、配送时段、预计时长和市/区级路线信息。
+10. 用户/骑手使用业务视图,平台详情保留完整审计实体;待抢视图只增加类别、重量范围、配送时段、预计时长和市/区级路线信息。
 11. 自动任务扫描送达满 24 小时订单并逐条条件完成,和用户/平台并发时只允许一次成功。
 
 ## 补充原型增量实施批次
@@ -353,7 +353,7 @@ public class FlashDeliveryOrderDetailView extends FlashDeliveryOrderView {
 }
 ```
 
-`FlashDeliveryUserOrderListView` 固定卡片字段为订单标识、服务/包裹、状态、配送方式/预约时段、取送文字地址、预计时长、金额、送达时间和创建时间。`FlashDeliveryRiderOrderListView` 固定增加重量、`pinRequired`、`pickupDistanceMeters` 和路线距离。分页 DTO 只使用上述六个分页/摘要属性。
+`FlashDeliveryUserOrderListView` 固定卡片字段为订单标识、服务/包裹、状态、配送方式/预约时段、取送文字地址、预计时长、金额、送达时间和创建时间。`FlashDeliveryRiderOrderListView` 固定增加重量范围、`pinRequired`、`pickupDistanceMeters` 和路线距离。分页 DTO 只使用上述六个分页/摘要属性。
 
 - [ ] **Step 4: Run DTO tests**
 
@@ -519,3 +519,185 @@ git push origin test
 ```
 
 The staged list must not contain `.claude/homunculus/observations.jsonl`、`.tmp/` or `docs/merchant-subaccount-api.md`.
+
+## 新版蓝湖稿增量计划(2026-09-04)
+
+详细文件级执行步骤见 `implementation-plan-2026-09-04.md`。
+
+### 目标
+
+保持闪送非支付和整数 TWD 现状,新增创建时收件账号绑定与“我收的”查询,并将 `URGENT` 落实为跨外卖、闪送任务的骑手独占。附近骑手人数和接单分钟数不进入接口,用户端统一展示“发布后等待附近骑手接单”。
+
+### 数据与身份
+
+1. `flash_delivery_order` 增加可空 `receiver_user_id BIGINT` 和 `(receiver_user_id, create_time)` 索引。
+2. 创建订单时将 `delivery.phone` 去除空白、`+`、`-` 和圆括号但保留国家码数字,并以相同规则只查询已注册普通用户;唯一匹配时固化用户 ID,无匹配或多匹配时留空。
+3. 不扫描或回填历史订单,不因用户注册、注销或手机号换绑修改 `receiver_user_id`。
+4. 用户列表新增可选 `role`,默认 `sender`;`receiver` 使用 `receiver_user_id` 在数据库分页前过滤。
+5. 详情和确认签收允许创建人或绑定收件人;取消只允许创建人。
+
+### 急送独占
+
+1. 新增共享骑手接单锁服务,使用 Redisson 锁键 `lock:delivery:rider:{riderId}`;闪送接单和当前有效外卖接单入口在事务内首先获取同一把锁。
+2. 抢 `URGENT` 前检查该骑手没有未送达外卖,也没有 `ACCEPTED/PICKED_UP` 闪送。
+3. 抢其他任务前检查该骑手没有 `ACCEPTED/PICKED_UP` 的 `URGENT` 闪送。
+4. 使用不指定固定租期的 `tryLock(waitTime, unit)` 交由 Redisson 看门狗续期,并通过事务同步回调在事务完成后释放;获取失败或 Redis 异常时拒绝接单。
+5. 独占冲突使用统一国际化业务错误;数据库条件更新仍作为订单被其他骑手抢走的最终并发保护。
+
+### 测试与交付
+
+先写并运行失败测试,再实现生产代码。测试覆盖手机号唯一/零个/多个匹配、幂等创建、角色分页、参与者权限、取消边界、急送双向互斥、终态释放和同骑手并发接单。数据库变更只追加到 `updatesql/sql.md`,不直接执行。最后使用 JDK 21 运行定向测试和模块构建,并执行 `git diff --check`。
+
+## 设计稿费用与物品信息对齐计划(2026-09-07)
+
+> **执行要求**:在当前会话按测试驱动方式依次完成下列任务;每个任务先运行指定测试确认旧实现失败,再实现最小改动并重新运行。数据库 SQL 只追加到 `updatesql/sql.md`,不得执行。
+
+**目标**:拆分帮送/帮取业务场景与普通/加急配送等级,让两种业务场景共享统一分时段运价,按普通配送费比例和最低金额计算加急费,支持骑手小费、预约时段计价、客户端报价回传校验及设计稿中的完整物品信息。
+
+**架构**:`flash_delivery_pricing` 改为全局时间段配置,计价器负责纯函数费用计算,应用服务负责选择立即或预约计价时刻、路线编排和报价一致性校验。订单持久化完整物品及费用快照;`delivery_type` 成为急送独占的唯一判断字段。平台管理端只维护一组时间段,不再展示三类服务价格。
+
+**技术栈**:Java 21、Spring Boot、MyBatis-Plus/MyBatis XML、MySQL、JUnit 5、Mockito、Vue 2、Element UI、Node 测试。
+
+### 全局约束
+
+- Controller 继续使用明确 DTO、`@RequestBody`、`@RequestParam`、`@PathVariable` 和 `@RequestHeader String token`,不接收 Map。
+- 所有 TWD 费用为整数 `Long`;比例和重量使用 `BigDecimal`,计算使用 `RoundingMode.HALF_UP`。
+- `serviceType` 只接受 `HELP_SEND/HELP_PICKUP`,`deliveryType` 只接受 `NORMAL/URGENT`,`deliveryMode` 只接受 `NOW/SCHEDULED`。
+- 立即订单按报价时刻、预约订单按 `scheduledPickupStartAt` 的业务当地时分匹配统一运价。
+- 不接入支付、退款或结算;小费仅作为应付费用快照。
+- 平台新增可见文本同步简中、繁中、英文和越南文;前端文件保持 CRLF。
+- 不执行数据库迁移,只把可人工执行 SQL 追加到 `updatesql/sql.md`。
+
+### Task 1:计价模型与数据层
+
+**文件:**
+
+- 修改:`ruoyi-system/src/main/java/com/ruoyi/system/domain/flash/FlashDeliveryPricing.java`
+- 修改:`ruoyi-system/src/main/java/com/ruoyi/system/domain/flash/FlashDeliveryOrder.java`
+- 修改:`ruoyi-system/src/main/java/com/ruoyi/system/mapper/flash/FlashDeliveryPricingMapper.java`
+- 修改:`ruoyi-system/src/main/resources/mapper/flash/FlashDeliveryOrderMapper.xml`
+- 修改:`ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryPriceBreakdown.java`
+- 修改:`ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryPricingCalculator.java`
+- 测试:`ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryPricingCalculatorTest.java`
+- 测试:`ruoyi-system/src/test/java/com/ruoyi/system/mapper/flash/FlashDeliveryPricingMapperContractTest.java`
+- 测试:`ruoyi-system/src/test/java/com/ruoyi/system/mapper/flash/FlashDeliveryMapperXmlTest.java`
+
+**接口:**
+
+- `FlashDeliveryPricingCalculator.calculateBreakdown(FlashDeliveryPricing pricing, int distanceMeters, String deliveryType, Long tipAmount)` 返回 `distanceFee/baseDeliveryFee/urgentFee/tipAmount/amount`。
+- `FlashDeliveryPricingMapper.selectAtTime(String targetTime)` 返回目标 `HH:mm` 命中的全部配置;`countOverlapping(String startTime,String endTime,Long excludedId)` 全局查重;`lockPricing()` 获取单行全局锁。
+- `FlashDeliveryOrder` 使用 `deliveryType,quantity,weightRange,specification,baseDeliveryFee,urgentRate,minimumUrgentFee,urgentFee,tipAmount`。
+
+- [ ] 先扩展计价器和 Mapper 契约测试,覆盖普通加急费为 0、比例费高于最低值、最低值兜底、HALF_UP、小费相加、全局时段查询/查重和订单字段映射,并运行测试确认旧实现失败。
+- [ ] 修改实体、Mapper 和纯计价器;删除运价配置的 `serviceType` 依赖,使上述测试通过。
+- [ ] 运行:`mvn -pl ruoyi-admin -am -Dtest='FlashDeliveryPricingCalculatorTest,FlashDeliveryPricingMapperContractTest,FlashDeliveryMapperXmlTest' -Dsurefire.failIfNoSpecifiedTests=false test`。
+
+### Task 2:报价、创建校验与物品信息
+
+**文件:**
+
+- 修改:`ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryQuoteRequest.java`
+- 修改:`ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryCreateRequest.java`
+- 修改:`ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryQuoteView.java`
+- 修改:`ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryHomeView.java`
+- 修改:`ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryOrderView.java`
+- 修改:`ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryUserOrderListView.java`
+- 修改:`ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryRiderOrderListView.java`
+- 新增:`ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/exception/FlashDeliveryQuoteChangedException.java`
+- 修改:`ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryApplicationService.java`
+- 修改:`ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/controller/FlashDeliveryUserController.java`
+- 修改:`ruoyi-admin/src/main/java/com/ruoyi/app/order/RiderDeliveryExclusivityService.java`
+- 测试:`ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryApplicationServiceTest.java`
+- 测试:`ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/controller/FlashDeliveryControllerContractTest.java`
+- 测试:`ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryOrderViewTest.java`
+- 测试:`ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryRiderOrderListViewTest.java`
+
+**接口:**
+
+- 报价请求使用 `deliveryType,deliveryMode,scheduledPickupStartAt,scheduledPickupEndAt,quantity,weightRange,specification,tipAmount`,不接收精确重量或另一套包裹尺寸字段。
+- 创建请求新增 `pricingId,pricingVersion,quotedBaseDeliveryFee,quotedDistanceFee,quotedUrgentFee,quotedAmount`。
+- 报价和订单视图提供业务、物品与费用明细;订单视图原样返回四档 `weightRange` 快照。
+- 报价变化异常携带 `FlashDeliveryQuoteView latestQuote`;Controller 返回国际化错误和 `data=latestQuote`。
+
+- [ ] 先扩展应用服务、Controller 和 DTO 测试,覆盖业务/配送维度校验、完整物品校验、立即与预约时段匹配、报价费用字段、创建逐项一致、配置或金额变化拒绝并返回最新报价、订单费用快照和 `deliveryType=URGENT` 独占,并确认旧实现失败。
+- [ ] 实现请求/响应 DTO、目标计价时刻选择、统一运价查询、报价复算比较、重量范围校验、快照保存和报价变化错误响应。
+- [ ] 将所有急送独占查询从 `service_type='URGENT'` 改为 `delivery_type='URGENT'`,保持既有骑手锁和状态边界不变。
+- [ ] 运行:`mvn -pl ruoyi-admin -am -Dtest='FlashDeliveryApplicationServiceTest,FlashDeliveryControllerContractTest,FlashDeliveryOrderViewTest,FlashDeliveryRiderOrderListViewTest' -Dsurefire.failIfNoSpecifiedTests=false test`。
+
+### Task 3:平台配置接口和管理页面
+
+**文件:**
+
+- 修改:`ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryPricingRequest.java`
+- 修改:`ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryApplicationService.java`
+- 修改:`ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/controller/FlashDeliveryAdminController.java`
+- 修改:`E:/QtwCode/foodie/foodie-admin-vue/src/api/flashDelivery/contracts.js`
+- 修改:`E:/QtwCode/foodie/foodie-admin-vue/src/views/flashDelivery/shared.js`
+- 修改:`E:/QtwCode/foodie/foodie-admin-vue/src/views/flashDelivery/pricing/index.vue`
+- 修改:`E:/QtwCode/foodie/foodie-admin-vue/src/views/flashDelivery/orders/OrderDetail.vue`
+- 修改:`E:/QtwCode/foodie/foodie-admin-vue/src/api/language/language.zh.js`
+- 修改:`E:/QtwCode/foodie/foodie-admin-vue/src/api/language/language.tw.js`
+- 修改:`E:/QtwCode/foodie/foodie-admin-vue/src/api/language/language.en.js`
+- 修改:`E:/QtwCode/foodie/foodie-admin-vue/src/api/language/language.vi.js`
+- 测试:`E:/QtwCode/foodie/foodie-admin-vue/tests/flash-delivery.test.cjs`
+
+**接口:**
+
+- 平台运价请求为 `startTime,endTime,startingDistance,startingFare,distance,freight,urgentRate,minimumUrgentFee,configVersion?`,不再含 `serviceType`。
+- 列表按 `startTime` 排序,所有时段全局重叠校验;平台订单详情展示业务场景、配送等级、物品和五项费用。
+
+- [ ] 先修改后端平台契约测试和前端定向测试,确认旧三服务配置结构失败。
+- [ ] 实现统一时段 CRUD、全局锁和字段校验;更新平台价格表单、订单详情及四语言文案。
+- [ ] 运行后端平台定向测试和 `npm run test:flash-delivery`,再对修改的前端文件运行 ESLint。
+
+### Task 4:SQL、App 文档和规格任务状态
+
+**文件:**
+
+- 修改:`updatesql/sql.md`
+- 修改:`docs/flash-delivery-app-api.md`
+- 修改:`specs/024-flash-delivery/quickstart.md`
+- 修改:`specs/024-flash-delivery/tasks.md`
+- 修改:五个 `ruoyi-admin/src/main/resources/i18n/messages*.properties`
+- 测试:`ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryI18nContractTest.java`
+
+- [ ] 在 `updatesql/sql.md` 追加 2026-09-07 人工迁移脚本:统一运价时段、全局锁、订单业务/物品/费用字段和相关索引;不得连接或修改数据库。
+- [ ] 重写 App 文档的枚举、首页、报价、创建、列表、详情和费用示例,明确报价变化响应及非支付边界。
+- [ ] 添加物品、配送等级、报价变化和运价字段错误的五语言 key,并运行 i18n 契约测试。
+- [ ] 更新 `tasks.md` 实际完成状态,不提前勾选未运行验证的任务。
+
+### Task 5:统一验证与提交
+
+- [ ] 临时设置 `JAVA_HOME=C:\Users\qmj\.jdks\graalvm-jdk-21.0.7`,运行闪送、外卖独占和 i18n 定向测试。
+- [ ] 运行 `mvn -pl ruoyi-admin -am -DskipTests package`。
+- [ ] 在平台前端运行 `npm run test:flash-delivery`、修改文件 ESLint 和 `npm run build:prod`。
+- [ ] 运行两个仓库的 `git diff --check`,核对编码、换行和暂存文件清单。
+- [ ] 后端与平台前端分别形成中文独立提交;最终报告两个 SHA、实际检查结果和任何未执行项。
+
+## 移除运价专用锁表增量(2026-09-07)
+
+本节替代本文此前关于 `flash_delivery_pricing_lock`、全局锁和 `SELECT ... FOR UPDATE` 的当前架构要求;旧段落仅保留历史设计背景。
+
+- 平台运价增删改继续在 Spring 事务内执行,不再读取专用锁行。
+- 新增和修改继续通过 `countOverlapping(startTime,endTime,excludedId)` 检查所有统一时段;修改继续使用 `config_version` 更新条件。
+- 报价和创建订单仍要求目标时刻只匹配一个时段,异常多条匹配时明确失败,不使用 `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 文档及定向验证,原有费用和并发业务规则保持不变。

+ 21 - 5
specs/024-flash-delivery/quickstart.md

@@ -1,8 +1,24 @@
 # 闪送 API 联调快速开始
 
-1. 开发者人工执行 `updatesql/sql.md` 中 2026-08-31 闪送迁移,并在字典 `sys_google_routes_key` 配置仅供后端使用、已启用 Google Routes API 的独立密钥不得复用匿名接口可读取的 `sys_googlemap_key`。
+1. 开发者人工执行 `updatesql/sql.md` 中既有闪送迁移及 2026-09-07 统一运价增量,核对每个时段的加急比例和最低加急费;并在字典 `sys_google_routes_key` 配置仅供后端使用、已启用 Google Routes API 的独立密钥不得复用匿名接口可读取的 `sys_googlemap_key`。
 2. 启动 `ruoyi-admin`;用户和骑手请求携带现有 `token` 请求头,平台请求使用后台权限。
-3. 调用 `/system/flashDelivery/home`、`/quote`、`/orders`;创建时服务端重新报价。
-4. 骑手从 `/system/flashDelivery/rider/orders?page=1&size=10&tab=newTask&longitude=121.5&latitude=25.0` 拉取待抢任务并调用 `/accept`;五个页签分别为 `newTask`(待接单)、`toPickup`(已接单)、`delivering`(已取件)、`completed`(已送达/已完成)和 `cancelled`(已取消)。`newTask` 的经纬度仅用于附近范围、取件点距离和排序,闪送没有 `refund` 页签。接单前只展示完整文字地址,隐藏联系人、电话、实际 PIN、精确坐标和履约图片;图片先走现有上传接口,再把 URL 传给 `/pickup` 和 `/deliver`。
-5. 用户 `/confirmReceipt` 完成;送达满 24 小时未操作则自动完成。
-6. 完整字段见 [API 契约](contracts/api.md)。
+3. 调用 `/system/flashDelivery/home`、`/quote`、`/orders`;报价提交帮送/帮取业务场景、普通/加急配送等级、立即/预约取件时段、完整物品信息和骑手小费。创建时回传报价 ID、版本及各项金额,服务端复算不一致时返回最新报价且不创建订单;收件账号匹配结果固化在新订单中,不追溯历史订单。
+4. 用户通过 `/system/flashDelivery/orders?role=sender` 查看“我发的”,通过 `role=receiver` 查看“我收的”;省略 `role` 时默认 `sender`。发件人与固化收件人均可查看详情和确认收货,只有发件人可以取消。
+5. 骑手从 `/system/flashDelivery/rider/orders?page=1&size=10&tab=newTask&longitude=121.5&latitude=25.0` 拉取待抢任务并调用 `/accept`;五个页签分别为 `newTask`(待接单)、`toPickup`(已接单)、`delivering`(已取件)、`completed`(已送达/已完成)和 `cancelled`(已取消)。`newTask` 的经纬度仅用于附近范围、取件点距离和排序,闪送没有 `refund` 页签。接单前只展示完整文字地址,隐藏联系人、电话、实际 PIN、精确坐标和履约图片;图片先走现有上传接口,再把 URL 传给 `/pickup` 和 `/deliver`。
+6. `deliveryType=URGENT` 的急送接单与外卖、普通闪送共用骑手级 Redis 锁;急送进行期间骑手不能承接其他配送,接急送前骑手也不能有任何进行中配送。
+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 的接口冒烟需在开发联调环境执行,自动化测试使用模拟外部依赖,不代替实际联调。

+ 81 - 33
specs/024-flash-delivery/spec.md

@@ -4,30 +4,33 @@
 
 **创建日期**:2026-08-31
 
-**状态**:平台前端与外卖同规则时段运价调整已验证;App 订单列表与详情精简已提交,定向测试与模块构建通过
+**状态**:既有功能已验证;2026-09-07 设计稿费用与物品信息对齐增量已确认,待实施
 
-**输入**:基于蓝湖“闪送”分组 7 个设计页面及 2026-08-24 用户端/骑手端补充原型,实现帮送、帮取、加急送的完整非支付业务闭环,包括共享地址簿、路线报价、包裹信息、立即/预约配送、可选 PIN 交付、创建订单、骑手主动抢单、取件、送达、用户签收、平台配置与介入。
+**输入**:基于蓝湖“闪送”分组 7 个设计页面、2026-08-24 用户端/骑手端补充原型及 2026-09-03 更新的 19 张用户端设计稿,实现帮送/帮取业务场景、普通/1 对 1 加急配送、共享地址簿、路线报价、完整物品信息、立即/预约配送、费用明细与报价校验、可选 PIN 交付、创建订单、寄件人/收件人订单查询、骑手主动抢单、急送独占、取件、送达、用户签收、平台配置与介入的非支付业务闭环
 
 ## 用户场景与测试
 
 ### 用户故事 1:用户获取报价并发布闪送订单(优先级:P1)
 
-用户选择帮送、帮取或加急送,填写取件与收件地址后获取服务端报价,并发布一笔等待骑手接单的闪送订单。
+用户选择帮送或帮取、普通或 1 对 1 加急配送,填写取送地址、物品信息和可选骑手小费后获取服务端报价,并回传报价明细发布一笔等待骑手接单的闪送订单。
 
 **优先级原因**:报价与发布订单是闪送服务成立的基础,没有该能力就无法形成可配送任务。
 
-**独立测试**:为同一组取件和收件坐标分别选择三种服务,验证系统按服务类型和当前时间匹配运价时段,使用与外卖订单一致的起送与里程规则返回整数新台币报价,并在创建订单时重新计算价格、保存地址与计价快照且进入待接单状态
+**独立测试**:为同一组取件和收件坐标分别选择帮送/帮取及普通/加急配送,验证两种业务场景共享运价,立即订单按当前时刻、预约订单按预约开始时刻匹配唯一运价时段;系统使用起送与里程规则计算普通配送费,以当前时段的加急比例和最低加急费计算加急费,再加入用户小费返回整数新台币明细;创建订单时前端回传报价,后端复算并逐项校验后保存物品、地址、路线和计价快照
 
 **验收场景**:
 
-1. **假如** 当前时间命中所选服务类型的运价时段,**当** 用户请求报价,**那么** 系统使用该时段的起送距离、起送价格、计价距离和计价金额计算报价。
+1. **假如** 立即订单的当前时刻或预约订单的预约开始时刻命中唯一运价时段,**当** 用户请求报价,**那么** 帮送与帮取均使用该时段的起送距离、起送价格、计价距离、计价金额、加急比例和最低加急费计算报价。
 2. **假如** 路线距离超过起送距离不足 0.5 公里、达到 0.5 公里但不足 1 公里或超过 1 公里,**当** 用户请求报价,**那么** 系统分别按 0、1 公里或实际超出距离计算里程费用,并将新台币金额四舍五入为整数元。
 3. **假如** 地图路线服务正常,**当** 用户请求报价,**那么** 系统使用路线距离并标记距离来源为路线服务。
 4. **假如** 地图路线服务超时或失败,**当** 用户请求报价,**那么** 系统使用经纬度直线距离降级报价并明确标记距离来源。
-5. **假如** 客户端提交伪造金额或距离,**当** 用户创建订单,**那么** 系统忽略客户端金额与距离并在服务端重新报价。
+5. **假如** 客户端创建订单时回传的配置版本、基础配送费、距离费、加急费或总金额与服务端最新复算结果不一致,**当** 系统校验报价,**那么** 系统不得创建订单,并返回报价已变化的业务结果及最新报价。
 6. **假如** 同一用户以相同客户端请求号重复创建订单,**当** 请求被重复处理,**那么** 系统返回同一订单且不产生重复订单。
 7. **假如** 用户选择预约配送,**当** 创建订单,**那么** 系统保存 30 分钟取件时段,预约开始前订单不进入骑手可抢列表。
 8. **假如** 路线距离超过 40 公里,**当** 用户报价或创建订单,**那么** 系统拒绝请求。
+9. **假如** 用户选择普通配送,**当** 系统报价,**那么** 加急费必须为 0;用户选择加急配送时,加急费必须为普通配送费乘当前时段加急比例并与最低加急费取较大值。
+10. **假如** 用户填写物品类别、数量、四档重量范围和规格,**当** 报价或创建订单,**那么** 系统校验数量为正整数,并且重量范围属于设计稿规定的四个枚举之一。
+11. **假如** 用户填写骑手小费,**当** 系统报价或创建订单,**那么** 小费作为独立非负整数 TWD 明细参与总金额,但不改变基础配送费或加急费。
 
 ---
 
@@ -86,23 +89,27 @@
 2. **假如** 订单已取件,**当** 订单用户尝试取消,**那么** 系统拒绝并要求平台介入。
 3. **假如** 骑手已确认送达,**当** 订单用户确认签收,**那么** 订单进入已完成状态。
 4. **假如** 骑手送达后用户 24 小时未确认,**当** 自动完成任务执行,**那么** 订单进入已完成状态并记录自动操作日志。
-5. **假如** 用户访问其他用户的订单,**当** 其查询详情、取消或签收,**那么** 系统拒绝访问。
-6. **假如** 用户查询订单列表,**当** 仅提交 `page` 和 `size`,**那么** 系统按创建时间倒序返回本人各状态订单的轻量摘要,不要求客户端提交 `scene`、`status` 或 `serviceType`。
+5. **假如** 用户既不是订单创建人也不是已绑定收件人,**当** 其查询详情、取消或签收,**那么** 系统拒绝访问。
+6. **假如** 用户查询订单列表,**当** 提交 `role=sender` 或省略 `role`,**那么** 系统按创建时间倒序返回本人创建的各状态订单轻量摘要,不要求客户端提交 `scene`、`status` 或 `serviceType`。
+7. **假如** 创建订单时收件手机号唯一匹配一名当时已注册的普通用户,**当** 该用户提交 `role=receiver` 查询列表或查询订单详情,**那么** 系统返回该用户作为收件人的订单并允许读取收件码和配送进度。
+8. **假如** 创建订单时收件手机号没有匹配账号或匹配结果不唯一,**当** 手机号对应用户以后注册或发生换绑,**那么** 历史订单不自动认领或改变收件账号归属。
+9. **假如** 匹配收件人查询订单,**当** 尝试取消订单,**那么** 系统拒绝操作;寄件人仍是唯一有权主动取消订单的用户。
+10. **假如** 寄件人或匹配收件人确认已收货,**当** 订单处于已送达状态,**那么** 系统以原子状态转换完成订单。
 
 ---
 
 ### 用户故事 5:平台管理时段运价并介入异常订单(优先级:P2)
 
-平台管理员为三种闪送服务分别维护多个运价时段,查询全部闪送订单,并在取件后的异常场景中取消或完成订单。
+平台管理员维护一套供帮送和帮取共享的分时段运价,查询全部闪送订单,并在取件后的异常场景中取消或完成订单。
 
 **优先级原因**:价格必须可运营调整,取件后的订单又必须具备受控的人工处置入口。
 
-**独立测试**:管理员新增、修改和删除加急送运价时段,验证重叠时段被拒绝、保存后立即生效、新报价使用当前时段及最新版本;随后对已取件订单执行平台取消并检查状态日志。
+**独立测试**:管理员新增、修改和删除统一运价时段,验证重叠时段被拒绝、保存后立即生效,普通报价使用基础配送规则,加急报价额外使用该时段的加急比例和最低加急费;随后对已取件订单执行平台取消并检查状态日志。
 
 **验收场景**:
 
-1. **假如** 管理员新增或修改某一服务类型的运价时段,**当** 新请求在该时段内报价,**那么** 新报价立即使用新配置且已创建订单价格不变。
-2. **假如** 时间范围重叠、起止时间非法、距离或金额不是正数、金额不是整数或服务类型非法,**当** 管理员提交配置,**那么** 系统拒绝保存。
+1. **假如** 管理员新增或修改统一运价时段,**当** 帮送或帮取的新请求命中该时段,**那么** 新报价立即使用新配置且已创建订单价格不变。
+2. **假如** 时间范围重叠、起止时间非法、距离或金额不是正数、金额不是整数、加急比例为负数或最低加急费为负数,**当** 管理员提交配置,**那么** 系统拒绝保存。
 3. **假如** 普通用户或骑手调用平台配置或介入接口,**当** 权限校验执行,**那么** 系统拒绝操作。
 4. **假如** 订单已取件但发生异常,**当** 有权限的管理员取消订单,**那么** 系统允许取消并记录管理员、原因和时间。
 5. **假如** 订单已送达但用户无法确认,**当** 有权限的管理员确认完成,**那么** 系统完成订单并记录平台操作日志。
@@ -111,29 +118,46 @@
 
 ### 用户故事 6:平台在管理后台配置价格并处理闪送订单(优先级:P2)
 
-平台管理员从“闪送管理”菜单进入价格配置或订单管理页面,无需直接调用接口即可维护三种服务价格、筛选订单、查看完整履约资料并执行受权限控制的平台介入。
+平台管理员从“闪送管理”菜单进入价格配置或订单管理页面,无需直接调用接口即可维护统一分时段运价、筛选订单、查看完整履约资料并执行受权限控制的平台介入。
 
 **优先级原因**:后端接口已具备,但缺少平台可操作入口会导致计价只能通过接口工具维护,异常订单也无法进入日常运营流程。
 
-**独立测试**:以拥有完整闪送权限的管理员登录后台,分别完成三种服务价格读取与修改、订单组合筛选、详情查看、异常取消和已送达订单完成;再以缺少对应权限的账号验证菜单与操作按钮不可用。
+**独立测试**:以拥有完整闪送权限的管理员登录后台,完成统一分时段运价读取与修改、订单组合筛选、详情查看、异常取消和已送达订单完成;再以缺少对应权限的账号验证菜单与操作按钮不可用。
 
 **验收场景**:
 
-1. **假如** 管理员进入价格配置页面,**当** 页面加载,**那么** 系统按帮送、帮取和加急送展示全部运价时段、起送规则、计价规则及配置版本。
-2. **假如** 管理员新增、修改或删除合法时段,**当** 操作成功,**那么** 页面刷新服务端配置;非法值、重叠时段或并发冲突必须保留编辑内容并展示错误。
+1. **假如** 管理员进入价格配置页面,**当** 页面加载,**那么** 系统按开始时间展示全部统一运价时段、起送规则、普通超距规则、加急比例、最低加急费及配置版本。
+2. **假如** 管理员新增、修改或删除合法时段,**当** 操作成功,**那么** 页面刷新服务端配置;非法值、重叠时段或配置版本冲突必须保留编辑内容并展示错误。
 3. **假如** 管理员进入订单页面,**当** 按状态、服务类型、订单号、用户 ID 或骑手 ID 查询,**那么** 表格按服务端分页结果展示并可重置条件。
 4. **假如** 管理员查看订单详情,**当** 详情加载,**那么** 页面分区展示订单概况、取送地址、费用明细、预约与 PIN、骑手、图片凭证和状态日志。
 5. **假如** 订单处于非终态,**当** 具有取消权限的管理员填写原因并二次确认,**那么** 页面调用平台取消接口并刷新列表与详情。
 6. **假如** 订单处于已送达状态,**当** 具有完成权限的管理员二次确认,**那么** 页面调用平台完成接口并刷新订单状态。
 
+---
+
+### 用户故事 7:收件人查看订单且急送骑手保持独占(优先级:P1)
+
+已注册普通用户在他人创建订单时通过收件手机号被安全绑定为收件人,可在“我收的”查看订单详情和配送进度;骑手承接 `deliveryType=URGENT` 的 1 对 1 急送后,在订单送达或取消前不得承接其他外卖或闪送任务。
+
+**独立测试**:使用已注册、未注册、重复手机号和换绑手机号创建订单,验证收件账号只在创建时唯一匹配并固化;并发发起急送与其他任务抢单,验证同一骑手只能进入一个互斥的有效接单结果。
+
+**验收场景**:
+
+1. **假如** 收件手机号唯一匹配一名已注册普通用户,**当** 创建订单,**那么** 系统将该用户 ID 固化为 `receiverUserId`,后续手机号变化不改写历史订单。
+2. **假如** 收件手机号没有匹配或匹配到多名用户,**当** 创建订单,**那么** 订单照常创建但 `receiverUserId` 为空,不向任何账号开放“我收的”权限。
+3. **假如** 骑手已有未送达的外卖或有效闪送任务,**当** 抢 1 对 1 急送订单,**那么** 系统拒绝接单。
+4. **假如** 骑手已承接 1 对 1 急送且订单仍处于已接单或已取件,**当** 再抢外卖、普通闪送或另一笔急送,**那么** 系统拒绝接单。
+5. **假如** 急送订单已送达、完成或取消,**当** 骑手再次抢单,**那么** 独占限制解除。
+6. **假如** 急送与其他任务并发抢单,**当** 请求针对同一骑手到达,**那么** 所有相关接单入口使用同一骑手级 Redis 分布式锁串行检查,不能绕过独占规则。
+
 ## 边界情况
 
 - 取件地址和收件地址相同或坐标相同,不允许创建订单。
 - 地址缺少联系人、联系电话、完整地址或有效经纬度时,不允许用于报价和创建订单。
 - 经纬度超出合法范围时拒绝请求,不调用地图服务。
-- 对应服务类型在当前时间没有匹配的运价时段时,不允许报价或创建订单,并返回“当前时段暂无可用运价”。
+- 立即订单在当前时刻、预约订单在预约开始时刻没有匹配的运价时段时,不允许报价或创建订单,并返回“对应取件时段暂无可用运价”。
 - 地图路线结果缺少有效距离时按路线失败处理并降级为直线距离。
-- 创建订单时当前配置与先前报价时不同,以创建时服务端重新计算结果为准。
+- 创建订单时当前配置或路线结果与先前报价不同,拒绝创建并返回最新报价,不得静默使用新金额创建订单
 - 待接单列表仅返回待接单订单;已取消或已被抢走的订单不得继续出现在新查询结果中。
 - 待抢订单列表按原型显示完整取送文字地址,但隐藏联系人、电话、实际 PIN、精确坐标和内部字段;抢单成功后订单骑手才能读取履约所需的完整联系方式与坐标。
 - 骑手第一版不能自行放弃已抢订单,需要平台介入处理。
@@ -147,18 +171,18 @@
 
 ### 功能需求
 
-- **FR-001**:系统必须提供帮送、帮取和加急送三种服务类型
+- **FR-001**:系统必须将业务场景与配送等级分离:`serviceType` 只接受 `HELP_SEND`(帮送)和 `HELP_PICKUP`(帮取),`deliveryType` 只接受 `NORMAL`(普通配送)和 `URGENT`(1 对 1 加急配送)
 - **FR-002**:帮取只承担取件配送,不包含代购、垫付或商品金额。
-- **FR-003**:三种服务必须使用同一订单履约状态机;加急送仅在排序和计价上体现优先级
-- **FR-004**:系统必须按服务类型维护多个运价时段,每个时段保存开始时间、结束时间、起送距离、起送价格、计价距离、计价金额、配置版本与修改记录;配置保存即生效,不设置启停状态。
-- **FR-005**:报价必须先按服务类型和当前时间匹配唯一运价时段;同一服务类型的时间段不得重叠,没有匹配时段时必须拒绝报价和创建订单。
+- **FR-003**:帮送、帮取、普通配送和加急配送必须使用同一订单履约状态机;`deliveryType=URGENT` 额外启用骑手独占规则和加急计价
+- **FR-004**:系统必须维护一套供帮送和帮取共享的多个运价时段;每个时段保存开始时间、结束时间、起送距离、起送价格、计价距离、计价金额、加急比例、最低加急费、配置版本与修改记录,配置保存即生效且不设置启停状态。
+- **FR-005**:立即订单必须按报价时刻匹配唯一运价时段,预约订单必须按 `scheduledPickupStartAt` 匹配唯一运价时段;全部时间段之间不得重叠,没有匹配时段时必须拒绝报价和创建订单。
 - **FR-006**:计价必须与现有外卖订单一致:起送距离内只收起送价格;超出不足 0.5 公里不加价,超出 0.5 公里但不足 1 公里按 1 公里计,超出至少 1 公里按实际超出距离计;里程费用四舍五入到整数元后与起送价格相加,所有 TWD 金额均为整数且不再按百位或千位特殊取整。
 - **FR-007**:系统必须优先使用地图路线距离,地图失败时使用经纬度直线距离并向客户端返回距离来源。
 - **FR-008**:地图密钥必须从服务端配置读取,不得返回客户端或写入业务日志。
-- **FR-009**:创建订单时必须由服务端重新计算距离和价格,并保存地址、路线、金额和计价配置快照。
+- **FR-009**:创建订单时前端必须回传报价配置 ID、版本、基础配送费、距离费、加急费和总金额;服务端必须重新计算距离和价格并逐项精确校验,一致后才能保存地址、路线、金额和计价配置快照,不一致时不得创建订单并必须返回最新报价
 - **FR-010**:系统必须使用客户端请求号保证同一用户的订单创建幂等。
 - **FR-011**:闪送订单不得复用餐饮订单或打车订单数据模型。
-- **FR-012**:订单必须保存包裹类别和机车可载重量档,重量档为 `SMALL`(不超过 5kg)、`MEDIUM`(不超过 12kg)或 `LARGE`(不超过 20kg);不保存精确重量或件数
+- **FR-012**:订单必须保存包裹类别、正整数数量、用户选择的重量范围及可选体积/规格说明;重量范围必须为 `UP_TO_5_KG`、`OVER_5_TO_10_KG`、`OVER_10_TO_15_KG`、`OVER_15_TO_20_KG` 之一,并作为订单快照原样保存
 - **FR-013**:订单必须支持用户备注,但备注不参与计价。
 - **FR-014**:骑手必须通过统一闪送订单列表的 `newTask` 页签主动抢单,第一阶段不实现自动派单。
 - **FR-015**:骑手闪送列表必须复用骑手外卖订单列表的查询参数名称和分页习惯:`page`、`size`、`tab`、`longitude`、`latitude`;不得再拆分为 `available`、`mine` 或叠加 `scene`、`status`、`serviceType` 筛选。
@@ -197,28 +221,40 @@
 - **FR-048**:路线距离不得超过 40 公里;超过限制时报价和创建均拒绝。
 - **FR-049**:平台管理前端必须提供“闪送管理”父菜单,并提供“价格配置”和“闪送订单”两个子页面。
 - **FR-050**:平台前端必须通过现有六个 `flash:*` 权限分别控制页面访问、价格修改、订单查看、取消和完成操作,不得仅依赖按钮隐藏代替后端鉴权。
-- **FR-051**:价格配置页面必须按服务类型展示全部时段,并支持新增、修改和删除;表单字段为 `startTime`、`endTime`、`startingDistance`、`startingFare`、`distance` 和 `freight`,同时展示只读配置版本与更新时间。
-- **FR-052**:价格表单必须校验时间范围有效、同服务时间段不重叠、距离为正数、起送价格和计价金额为正整数;配置保存即生效,删除后立即不可用于报价,操作成功后必须重新读取服务端列表。
+- **FR-051**:价格配置页面必须按开始时间展示统一运价时段,并支持新增、修改和删除;表单字段为 `startTime`、`endTime`、`startingDistance`、`startingFare`、`distance`、`freight`、`urgentRate` 和 `minimumUrgentFee`,同时展示只读配置版本与更新时间。
+- **FR-052**:价格表单必须校验时间范围有效、全部时段互不重叠、距离为正数、起送价格和计价金额为正整数、加急比例和最低加急费为非负数;配置保存即生效,删除后立即不可用于报价,操作成功后必须重新读取服务端列表。
 - **FR-053**:订单页面必须按服务端分页,并支持状态、服务类型、订单号、用户 ID 和骑手 ID 组合筛选及一键重置。
 - **FR-054**:平台订单详情必须展示订单概况、取送地址、路线与费用、预约与 PIN、骑手 ID 与履约时间、三类图片凭证及状态日志;不得要求管理员从列表字段拼接详情。
 - **FR-055**:平台取消必须要求非空原因和二次确认;平台完成必须二次确认。操作成功后刷新服务端数据,操作失败时保留当前页面并显示后端错误。
 - **FR-056**:平台前端新增的全部用户可见文本必须通过 Vue i18n 提供简体中文、繁体中文、英文和越南文,不得硬编码单一语言文本。
 - **FR-057**:平台前端必须沿用现有 Vue 2、Element UI、若依请求封装、动态菜单和 `v-hasPermi` 体系,不新增重复的状态管理或 UI 框架。
 - **FR-058**:骑手列表 `tab` 只接受 `newTask`、`toPickup`、`delivering`、`completed` 和 `cancelled`,分别映射待接单、已接单、已取件、已送达/已完成和已取消;闪送不提供外卖退款页签。
-- **FR-059**:骑手 `newTask` 列表必须返回取件点距离、路线距离、预计时长、订单金额、包裹类别、重量档、配送方式、预约时段、完整取送文字地址和是否需要 PIN,并返回附近任务总数与最高订单金额;不得返回已移除的尖峰倍率或尚未实现的骑手收入。
+- **FR-059**:骑手 `newTask` 列表必须返回取件点距离、路线距离、预计时长、基础配送费、加急费、小费、订单总金额、业务场景、配送等级、包裹类别、数量、重量范围、规格、配送方式、预约时段、完整取送文字地址和是否需要 PIN,并返回附近任务总数与最高订单金额;不得返回内部加急比例、最低加急费或尚未实现的骑手收入。
 - **FR-060**:用户创建订单、用户详情、骑手详情和骑手接单成功响应必须使用直接且稳定的 App 订单详情结构;权限差异只影响敏感字段是否返回,不得改变 `data` 的顶层形状,也不得要求 App 通过 `data.order` 是否存在判断响应类型。
+- **FR-061**:订单新增可空 `receiver_user_id`;创建订单时将收件手机号去除首尾/内部空白、`+`、`-` 和圆括号但不改写国家码,再以剩余数字唯一匹配当时已注册且 `userType=0` 的普通用户并固化匹配结果,不执行历史订单追溯认领。
+- **FR-062**:手机号无匹配或存在多个普通用户匹配时不得绑定收件账号;订单仍可创建,且不得选择任意一名用户作为收件人。
+- **FR-063**:用户订单列表新增 `role` 参数,只接受 `sender` 和 `receiver`,省略时兼容为 `sender`;筛选必须在数据库分页前完成。
+- **FR-064**:订单创建人和已绑定收件人均可查询订单详情并确认签收;只有订单创建人可以主动取消订单。交付 PIN 对两类参与者可见。
+- **FR-065**:`deliveryType=URGENT` 表示 1 对 1 独占配送。骑手已有未送达外卖、`ACCEPTED` 或 `PICKED_UP` 闪送时不得接急送;骑手存在 `ACCEPTED` 或 `PICKED_UP` 急送时不得再接任何外卖或闪送。
+- **FR-066**:所有有效骑手接单入口必须使用同一 Redisson 锁键 `lock:delivery:rider:{riderId}`,在持锁期间检查跨外卖和闪送订单的独占条件并执行接单;锁必须在数据库事务完成后释放,获取超时或 Redis 异常时按失败关闭原则拒绝接单。
+- **FR-067**:闪送继续保持非支付业务,创建成功直接进入 `WAITING_ACCEPTANCE`,不增加待支付状态、支付倒计时、支付接口或退款流程。
+- **FR-068**:所有闪送金额继续使用整数 TWD;蓝湖稿中的小数金额仅为视觉占位,不改变接口和数据库金额类型。
+- **FR-069**:用户端不得展示固定或伪造的附近骑手人数及预计接单分钟数,统一使用“发布后等待附近骑手接单”;本期不增加实时骑手数量或接单预测接口。
+- **FR-070**:普通配送费等于起送价格与距离费之和;普通配送的加急费为 0,加急配送的加急费等于“普通配送费 × 当前时段加急比例”四舍五入到整数 TWD 后与最低加急费取较大值。
+- **FR-071**:骑手小费由用户提交,为独立的非负整数 TWD;订单总金额等于普通配送费、加急费和骑手小费之和,小费不得参与加急费计算。
+- **FR-072**:报价和订单响应必须分别返回 `baseDeliveryFee`、`distanceFee`、`urgentFee`、`tipAmount` 和 `amount`;加急报价还必须返回命中的 `urgentRate` 和 `minimumUrgentFee`,费用名称不得混用。
 
 ### 关键实体
 
-- **闪送订单**:用户发布的取件配送任务,持有地址、路线、价格、服务类型、履约状态和关键时间快照。
-- **计价配置**:某一服务类型在一个时间范围内立即生效的起送距离、起送价格、计价距离、计价金额及版本信息。
+- **闪送订单**:用户发布的取件配送任务,持有业务场景、配送等级、物品、地址、路线、费用、履约状态和关键时间快照。
+- **计价配置**:供帮送和帮取共享、在一个时间范围内立即生效的起送距离、起送价格、计价距离、计价金额、加急比例、最低加急费及版本信息。
 - **配送凭证**:用户下单时的可选寄件图片,以及骑手取件或送达时提交的必填图片记录。
 - **订单状态日志**:记录订单每次有效状态变化的操作审计信息。
 - **共享地址**:属于单一用户、可供闪送和现有收货业务共同使用的联系人与地点信息。
 
 ## 成功标准
 
-- **SC-001**:三种服务的当前时段匹配、起送距离、0.5 公里边界、实际超出距离、整数新台币取整和加急配置报价测试全部通过。
+- **SC-001**:帮送/帮取共享运价、立即/预约时段匹配、起送距离、0.5 公里边界、实际超出距离、整数新台币取整、比例加急费、最低加急费和小费报价测试全部通过。
 - **SC-002**:地图路线成功和失败降级场景均能在一次请求内返回可用报价,并准确标记距离来源。
 - **SC-003**:至少 20 个并发抢单请求针对同一订单时,数据库中恰好只有一名骑手成功绑定。
 - **SC-004**:跨用户地址和订单的详情、修改、删除、置顶、取消及签收测试拦截率达到 100%。
@@ -227,7 +263,7 @@
 - **SC-007**:送达超过 24 小时的订单能够自动完成,且并发确认不会产生重复状态日志或重复副作用。
 - **SC-008**:修改计价配置后,新订单使用新版本,历史订单保存的价格与配置快照保持不变。
 - **SC-009**:所有新增用户可见业务错误均能通过项目支持的语言资源解析,不出现硬编码单一语言结果。
-- **SC-010**:拥有对应权限的管理员能够只通过平台页面完成三种服务价格维护和订单查询、详情、取消及完成操作,核心流程无需 Postman 或数据库操作。
+- **SC-010**:拥有对应权限的管理员能够只通过平台页面完成统一分时段运价维护和订单查询、详情、取消及完成操作,核心流程无需 Postman 或数据库操作。
 - **SC-011**:价格和订单页面的接口路径、请求方法、查询参数及权限字符串契约测试全部通过,四个前端语言文件的 `flashDelivery` key 集合完全一致。
 - **SC-012**:平台前端生产构建通过,新增页面在 1280px 及以上后台常用宽度无横向页面溢出,表格窄屏时仅在表格区域滚动。
 
@@ -236,14 +272,26 @@
 - 继续使用现有用户、骑手、token、骑手位置、图片上传和站内消息/推送基础设施。
 - 地图路线服务的具体供应商与密钥沿用项目现有配置能力;计划阶段确定现有配置键和 HTTP 调用位置。
 - 币种为新台币,接口使用稳定币种代码 `TWD`。
+- 运价属于低频管理员配置,不维护专用数据库锁表;业务接受并发新增请求极低概率同时通过重叠检查,修改仍由配置版本校验保护,读取侧仍拒绝异常多条匹配。
 - 第一阶段不根据有效骑手数量阻止下单,也不实现自动匹配、自动派单或定向派单。
-- 蓝湖页面中的附近骑手数量与预计接单时间第一阶段不作为动态业务承诺。
+- 蓝湖页面中的附近骑手数量与预计接单时间不作为动态业务承诺,用户端统一显示“发布后等待附近骑手接单”
 - 骑手第一阶段不能自行放弃已抢订单,由平台管理员介入处理异常订单。
 
 ## 非目标
 
 - 不接入支付网关、付款、退款、取消费、退回费、结算或骑手收入分账。
-- 不实现代购、垫付款、商品金额、精确重量、件数或禁寄品电子确认;禁运品、货值和尺寸限制由前端展示规则说明,后端只校验本期明确的重量档和最大路线距离。
+- 不实现代购、垫付款、商品金额或禁寄品电子确认;禁运品和货值限制由前端展示规则说明,后端校验本期明确的物品数量、重量范围、规格长度和最大路线距离。
 - 不实现自动派单、抢单推送筛选、骑手在线状态计算或接单时间预测。
 - 不修改餐饮订单 `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 仍放请求头。

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

@@ -94,3 +94,69 @@
 - 验证结果:JDK 21 定向测试 40 项全部通过(0 失败、0 错误);`mvn -pl ruoyi-admin -am -DskipTests package` 成功;`git diff --check` 清洁。
 - 另按审核结论补记 `sys_job` 闪送自动完成调度 SQL(见 `updatesql/sql.md` 2026-09-02 节),并在 `plan.md` 修订并发描述与实现一致。
 - 已有规格提交:`17888f8`(明确闪送 App 订单列表与详情契约)、`8a3ea64`(制定闪送 App 订单接口精简计划)。
+
+## Phase 12:新版蓝湖稿的收件人订单与急送独占
+
+- [x] T055 更新规格、设计、数据模型和 API 契约,固定非支付、整数 TWD、静态等待接单文案、创建时收件账号匹配及急送独占规则
+- [x] T056 先扩展 Mapper、应用服务和 Controller 测试,覆盖收件账号唯一匹配、无匹配、重复匹配、历史不追溯、`role` 列表、参与者详情/签收、收件人禁止取消,并确认旧实现红灯
+- [x] T057 新增 `receiver_user_id` 实体/XML/索引和普通用户手机号精确匹配查询,实现创建时固化、寄件人/收件人数据库分页及参与者权限
+- [x] T058 先补跨外卖/闪送接单测试,覆盖骑手级 Redisson 锁、忙碌骑手拒绝急送、急送阻止其他接单、终态释放和并发串行检查,并确认旧实现红灯
+- [x] T059 以共享锁键 `lock:delivery:rider:{riderId}` 实现 `URGENT` 跨订单独占,事务完成后释放锁,并在当前有效外卖与闪送接单入口返回一致的国际化业务错误
+- [x] T060 在 `updatesql/sql.md` 追加 `receiver_user_id` 字段和索引迁移,仅记录不执行;同步 App 接口文档和五语言错误文案
+- [ ] T061 使用 JDK 21 运行闪送、外卖接单和 i18n 定向测试、模块构建与差异检查,核对暂存范围后使用中文提交
+
+## Phase 13:设计稿费用与物品信息对齐
+
+- [x] T062 修正规格、设计、数据模型和 API 契约,确认帮送/帮取与普通/加急正交、统一分时段运价、比例加急费、最低加急费、小费、预约计价时刻、报价回传校验及完整物品字段
+- [x] T063 扩展计价器、运价 Mapper 和订单 XML 测试,覆盖比例加急费、最低值、小费、全局时段与新增快照字段;按用户要求集中实现后统一验证,未单独执行旧实现红灯
+- [x] T064 修改运价/订单实体、Mapper、XML 和计价器,移除按服务类型分价并使数据层与计价测试转绿
+- [x] T065 扩展应用服务、Controller、DTO 和独占测试,覆盖完整请求响应、立即/预约计价、前端报价逐项校验、最新报价错误、物品校验及 `deliveryType=URGENT`;按用户要求未单独执行旧实现红灯
+- [x] T066 实现报价、创建、列表、详情和独占逻辑,保存业务场景、配送等级、物品与完整费用快照,使 App 定向测试转绿
+- [x] T067 更新平台后端契约和 `foodie-admin-vue` 闪送测试,实现统一时段配置、加急比例、最低加急费、订单明细及四语言;按用户要求未单独执行旧页面红灯
+- [x] T068 在 `updatesql/sql.md` 追加迁移 SQL,不执行数据库;同步五语言后端错误、App 接入文档和 quickstart
+- [x] T069 使用 JDK 21 完成后端 69 项定向测试和模块构建、平台前端 8 项定向测试及两个仓库差异检查;用户要求立即提交后,不再执行平台 ESLint 与生产构建
+- [x] T070 核对后端与平台前端暂存范围,分别使用中文提交;SHA 与未执行项记录在最终交付中
+
+## Phase 14:移除运价专用锁表
+
+- [x] T071 记录简化设计和实施计划,确认接受低频后台并发新增的极低概率重叠风险,保留事务、重叠校验、版本校验和读取侧异常保护
+- [x] T072 先移除应用服务测试夹具对固定锁行的模拟,运行新增运价测试并确认旧实现因依赖锁行红灯
+- [x] T073 删除 Mapper 锁方法、运价增删改锁调用及内部锁断言,同步规格和删表 SQL
+- [x] T074 使用 JDK 21 运行闪送运价定向测试、模块构建和差异检查,核对暂存范围后中文提交并推送
+
+## Phase 15:物品重量改为设计稿四档范围
+
+- [x] T075 记录四档 `weightRange` 的接口、数据迁移和验收设计,并制定实施计划
+- [x] T076 修改服务、DTO 和 Mapper 契约测试,确认旧实现缺少 `weightRange` 映射的预期红灯
+- [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 联调仍未执行。

+ 59 - 0
specs/024-flash-delivery/weight-range-design.md

@@ -0,0 +1,59 @@
+# 闪送重量范围接口调整设计
+
+## 背景
+
+用户端设计稿不要求用户输入精确重量,而是从“5 公斤以内、6–10 公斤、11–15 公斤、16–20 公斤”四个范围中选择。现有接口要求提交 `totalWeightKg`,并由后端生成 `SMALL`、`MEDIUM`、`LARGE` 三档 `packageSize`,字段语义和分档均与设计稿不一致。
+
+## 决策
+
+- 报价与创建订单统一使用必填字段 `weightRange`,不再接收 `totalWeightKg`。
+- `weightRange` 只允许以下四个稳定枚举值:
+  - `UP_TO_5_KG`:大于 0 且不超过 5 公斤,前端显示“5 公斤以内”。
+  - `OVER_5_TO_10_KG`:超过 5 且不超过 10 公斤,前端显示“6–10 公斤”。
+  - `OVER_10_TO_15_KG`:超过 10 且不超过 15 公斤,前端显示“11–15 公斤”。
+  - `OVER_15_TO_20_KG`:超过 15 且不超过 20 公斤,前端显示“16–20 公斤”。
+- API 枚举按连续数值区间命名,避免 UI 整数文案在 5.1 公斤等边界上产生空档。
+- 删除 `packageSize` 的三档派生逻辑。`weightRange` 本身就是订单的重量档快照,不同时保留第二套重量档字段。
+- 本次重量范围只用于物品申报、展示和骑手载重判断,不参与基础配送费、距离费或加急费计算。
+
+## 接口变化
+
+`POST /system/flashDelivery/quote` 和 `POST /system/flashDelivery/orders` 的物品字段调整为:
+
+```json
+{
+  "packageType": "DOCUMENT",
+  "quantity": 1,
+  "weightRange": "UP_TO_5_KG",
+  "specification": "长 40cm"
+}
+```
+
+服务端在字段缺失或值不属于上述枚举时返回国际化的物品信息错误。创建订单仍重新执行与报价相同的校验,不能绕过报价直接写入非法重量档。
+
+订单详情、用户订单列表、骑手订单列表和平台订单数据统一返回 `weightRange`,不再返回 `totalWeightKg` 或 `packageSize`。本次采用前后端同步升级,不增加双字段兼容期,避免旧字段继续产生含义不真实的数据。
+
+## 数据模型与迁移
+
+- `FlashDeliveryOrder` 使用 `weightRange` 保存用户选择快照。
+- MyBatis 映射使用数据库字段 `weight_range`。
+- 在 `updatesql/sql.md` 追加人工迁移 SQL,不直接执行数据库变更。
+- 迁移时根据历史 `total_weight_kg` 将旧订单映射到四档,再将 `package_size` 调整为 `weight_range`,最后删除不再使用的 `total_weight_kg`。
+- 历史数据映射边界依次为 `<=5`、`<=10`、`<=15`、`<=20`;超过 20 公斤的异常历史数据归入最高档并由迁移前查询结果留给开发者确认。
+
+## 影响范围
+
+- 请求 DTO、订单 Entity、用户/骑手/详情响应 DTO。
+- 闪送应用服务的物品校验、订单保存和响应映射。
+- MyBatis XML、SQL 更新记录。
+- `docs/flash-delivery-app-api.md` 与 `specs/024-flash-delivery` 下的现有规格、设计、数据模型、契约、计划和任务。
+- 相关服务测试、Controller 契约测试和 Mapper 契约测试。
+
+## 验收标准
+
+1. 四个合法重量范围均可完成报价和创建订单,并在订单响应中原样返回。
+2. 缺失、空字符串、旧枚举值和未知枚举值均被拒绝。
+3. 报价与创建订单使用完全相同的重量范围校验。
+4. 费用计算结果不因重量范围变化而变化。
+5. 代码和文档不再把 `totalWeightKg` 或 `packageSize` 作为当前接口字段。
+6. SQL 只记录到 `updatesql/sql.md`,不由本次实现直接执行。

+ 105 - 0
specs/024-flash-delivery/weight-range-implementation-plan.md

@@ -0,0 +1,105 @@
+# 闪送重量范围接口调整实施计划
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** 将闪送报价、创建订单、持久化和响应中的精确重量/三档包裹尺寸统一改为设计稿对应的四档 `weightRange`,并完整更新 App API 文档。
+
+**Architecture:** `FlashDeliveryQuoteRequest` 作为报价和创建订单共用的物品输入边界,只接收四值枚举字符串;应用服务集中校验并将其作为订单快照保存。Entity、MyBatis 和各类响应 DTO 使用同名 `weightRange`,数据库通过人工 SQL 把历史精确重量映射到四档并删除旧字段。
+
+**Tech Stack:** Java 21、Spring Boot、MyBatis XML、MySQL、JUnit 5、Mockito、AssertJ。
+
+## Global Constraints
+
+- 仅修改闪送重量字段相关代码、测试、规格、API 文档和 SQL,不重构无关代码。
+- Controller 不新增 `Map` 请求参数;DTO 不使用 Bean Validation 注解,校验继续放在 Service。
+- 数据库变更只追加到 `updatesql/sql.md`,不得直接执行。
+- 枚举固定为 `UP_TO_5_KG`、`OVER_5_TO_10_KG`、`OVER_10_TO_15_KG`、`OVER_15_TO_20_KG`。
+- 不保留 `totalWeightKg`、`packageSize` 双字段兼容逻辑。
+- 所有实现完成后再统一运行定向测试、模块编译和回归测试。
+
+---
+
+### Task 1: 锁定重量范围契约测试
+
+**Files:**
+- Modify: `ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryApplicationServiceTest.java`
+- Modify: `ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryOrderViewTest.java`
+- Modify: `ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryRiderOrderListViewTest.java`
+- Modify: `ruoyi-system/src/test/java/com/ruoyi/system/mapper/flash/FlashDeliveryMapperXmlTest.java`
+
+**Interfaces:**
+- Consumes: 请求字段 `String weightRange` 和订单字段 `String weightRange`。
+- Produces: 合法四档、非法旧值、空值、保存快照以及响应字段集合的可执行契约。
+
+- [x] 将测试 Fixture 中的 `request.setTotalWeightKg(new BigDecimal("3.00"))` 改为 `request.setWeightRange("UP_TO_5_KG")`。
+- [x] 将创建订单断言改为 `assertEquals("UP_TO_5_KG", saved.getValue().getWeightRange())`,并删除 `totalWeightKg/packageSize` 断言。
+- [x] 用参数化或循环断言覆盖四个合法枚举;为空、`SMALL`、未知值时断言 `flash.delivery.item.invalid` 对应的业务异常。
+- [x] 更新用户、骑手和详情 DTO 的精确字段集合,只保留 `weightRange`。
+- [x] 更新 Mapper XML 契约断言,要求 `weightRange -> weight_range`,不再要求旧属性。
+
+### Task 2: 实现统一重量范围模型
+
+**Files:**
+- Modify: `ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryQuoteRequest.java`
+- Modify: `ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryOrderView.java`
+- Modify: `ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryUserOrderListView.java`
+- Modify: `ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryRiderOrderListView.java`
+- Modify: `ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryApplicationService.java`
+- Modify: `ruoyi-system/src/main/java/com/ruoyi/system/domain/flash/FlashDeliveryOrder.java`
+- Modify: `ruoyi-system/src/main/resources/mapper/flash/FlashDeliveryOrderMapper.xml`
+- Modify: `ruoyi-admin/src/main/resources/i18n/messages.properties`
+- Modify: `ruoyi-admin/src/main/resources/i18n/messages_en_US.properties`
+- Modify: `ruoyi-admin/src/main/resources/i18n/messages_zh_CN.properties`
+- Modify: `ruoyi-admin/src/main/resources/i18n/messages_zh_TW.properties`
+- Modify: `ruoyi-admin/src/main/resources/i18n/messages_vi.properties`
+- Modify: `ruoyi-admin/src/main/resources/i18n/messages_th_TH.properties`
+
+**Interfaces:**
+- Consumes: `FlashDeliveryQuoteRequest#getWeightRange(): String`。
+- Produces: `FlashDeliveryOrder#getWeightRange(): String` 以及各响应 DTO 的 `weightRange` 属性。
+
+- [x] 在请求 DTO 中以 `String weightRange` 替换 `BigDecimal totalWeightKg`,Javadoc 写明四个枚举和必填规则。
+- [x] 在订单 Entity、详情、用户列表和骑手列表 DTO 中以 `String weightRange` 替换 `totalWeightKg/packageSize`。
+- [x] 在应用服务定义四值不可变集合 `WEIGHT_RANGES`,`validateItem(...)` 校验 `weightRange` 必填且属于该集合,同时保留数量和规格长度校验。
+- [x] 创建订单时直接保存 `request.getWeightRange()`,删除 `scaleWeight(...)` 和 `packageSize(...)`。
+- [x] 更新详情、用户列表和骑手列表映射,原样返回订单的 `weightRange`。
+- [x] 更新 MyBatis `resultMap` 为 `<result property="weightRange" column="weight_range"/>`,删除旧重量映射。
+- [x] 更新默认、英文、简体、繁体、越南语和泰语的物品信息错误提示,将精确重量改为重量范围。
+
+### Task 3: 更新迁移 SQL、规格和 App API 文档
+
+**Files:**
+- Modify: `updatesql/sql.md`
+- Modify: `specs/024-flash-delivery/spec.md`
+- Modify: `specs/024-flash-delivery/design.md`
+- Modify: `specs/024-flash-delivery/data-model.md`
+- Modify: `specs/024-flash-delivery/contracts/api.md`
+- Modify: `specs/024-flash-delivery/plan.md`
+- Modify: `specs/024-flash-delivery/tasks.md`
+- Modify: `docs/flash-delivery-app-api.md`
+
+**Interfaces:**
+- Consumes: Task 2 的最终字段名和枚举。
+- Produces: 可由前端直接实现的请求说明以及开发者手工执行的数据库迁移。
+
+- [x] 在 `updatesql/sql.md` 追加 2026-09-07 迁移:先按 `total_weight_kg` 更新 `package_size` 为四档值,再把 `package_size` 重命名为 `weight_range VARCHAR(24) NOT NULL`,最后删除 `total_weight_kg`;另附超过 20 公斤异常数据的迁移前查询。
+- [x] 将现有规格内所有当前契约描述改为四档 `weightRange`,并在任务记录中追加本次变更及验证状态。
+- [x] 在 `docs/flash-delivery-app-api.md` 的全局规则、枚举、报价请求、创建订单请求、列表/详情响应、示例和错误处理处删除 `totalWeightKg/packageSize`,写明 `weightRange` 必填、四个枚举值、区间和 UI 文案。
+- [x] 明确报价与创建订单均使用相同的 `weightRange` 字段格式和校验规则;重量范围当前不参与费用计算。
+
+### Task 4: 统一验证与交付检查
+
+**Files:**
+- Verify: Task 1–3 的全部文件。
+
+**Interfaces:**
+- Consumes: 完整实现和文档。
+- Produces: 编译、测试、差异与旧字段扫描结果。
+
+- [x] 运行旧字段扫描:`rg -n "totalWeightKg|packageSize|total_weight_kg|package_size" ruoyi-admin/src ruoyi-system/src docs/flash-delivery-app-api.md specs/024-flash-delivery`,仅允许迁移说明或历史背景命中。
+- [x] 运行定向测试:`mvn -pl ruoyi-admin -am -Dtest=FlashDeliveryApplicationServiceTest,FlashDeliveryOrderViewTest,FlashDeliveryRiderOrderListViewTest,FlashDeliveryMapperXmlTest -Dsurefire.failIfNoSpecifiedTests=false test`。
+- [x] 使用 JDK 21 运行模块编译:`mvn -pl ruoyi-admin -am -DskipTests compile`。
+- [x] 运行完整回归:`mvn test`。
+- [x] 运行 `git diff --check` 并检查最终文件清单,确保 `.claude/homunculus/observations.jsonl` 未进入交付范围。
+
+验证结果:定向测试 50 项通过,JDK 21 模块编译成功,完整回归 401 项通过;SQL 仅写入迁移记录,未执行数据库操作。

+ 92 - 0
updatesql/sql.md

@@ -1505,4 +1505,96 @@ CREATE TABLE pos_store_self_delivery_hours (
 
 -- 2026-09-08 029 商家自配送:订单判定快照列
 ALTER TABLE pos_order ADD COLUMN self_delivery TINYINT(1) NOT NULL DEFAULT 0 COMMENT '1=商家自配送(下单时快照,不进骑手池,运费归sh_id)';
+
+## 2026-09-04 闪送收件账号绑定(024-flash-delivery)
+
+> 用途:订单创建时按收件手机号唯一匹配已注册普通用户,支持用户端“我收的”列表。只记录脚本,由开发者手动执行;不回填历史订单。
+
+```sql
+ALTER TABLE flash_delivery_order
+  ADD COLUMN receiver_user_id BIGINT NULL COMMENT '创建时匹配的收件用户ID' AFTER user_id,
+  ADD KEY idx_flash_receiver_list (receiver_user_id, create_time);
+```
+
+## 2026-09-07 闪送统一运价、加急费用与完整物品信息(024-flash-delivery)
+
+> 用途:帮送和帮取改为共享一套分时段运价;普通/加急改用独立 `delivery_type`,并补齐数量、总重量、规格、基础配送费、加急费和骑手小费快照。脚本只记录,由开发者备份并人工执行。迁移会保留优先级最高的一组旧服务运价(帮送优先),上线前必须在平台核对每个时段的加急比例和最低加急费。
+
+```sql
+CREATE TABLE flash_delivery_pricing_backup_20260907 LIKE flash_delivery_pricing;
+INSERT INTO flash_delivery_pricing_backup_20260907 SELECT * FROM flash_delivery_pricing;
+
+DELETE pricing
+FROM flash_delivery_pricing pricing
+JOIN (
+  SELECT retained_type.service_type
+  FROM (
+    SELECT DISTINCT service_type
+    FROM flash_delivery_pricing
+  ) retained_type
+  ORDER BY FIELD(retained_type.service_type, 'HELP_SEND', 'HELP_PICKUP', 'URGENT'), retained_type.service_type
+  LIMIT 1
+) retained ON retained.service_type <> pricing.service_type;
+
+ALTER TABLE flash_delivery_pricing
+  DROP INDEX idx_flash_pricing_period,
+  DROP COLUMN service_type,
+  ADD COLUMN urgent_rate DECIMAL(8,2) NOT NULL DEFAULT 0.00 COMMENT '加急比例,百分比' AFTER freight,
+  ADD COLUMN minimum_urgent_fee BIGINT NOT NULL DEFAULT 0 COMMENT '最低加急费,整数TWD' AFTER urgent_rate,
+  ADD KEY idx_flash_pricing_period (start_time, end_time);
+
+DROP TABLE IF EXISTS flash_delivery_pricing_lock;
+CREATE TABLE flash_delivery_pricing_lock (
+  lock_key VARCHAR(20) NOT NULL,
+  PRIMARY KEY (lock_key)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='闪送统一运价并发锁';
+INSERT INTO flash_delivery_pricing_lock (lock_key) VALUES ('GLOBAL');
+
+ALTER TABLE flash_delivery_order
+  ADD COLUMN delivery_type VARCHAR(16) NOT NULL DEFAULT 'NORMAL' COMMENT '配送等级:NORMAL普通、URGENT加急' AFTER service_type,
+  ADD COLUMN quantity INT NOT NULL DEFAULT 1 COMMENT '物品数量' AFTER package_type,
+  ADD COLUMN total_weight_kg DECIMAL(6,2) NOT NULL DEFAULT 1.00 COMMENT '物品总重量kg' AFTER quantity,
+  ADD COLUMN specification VARCHAR(255) NULL COMMENT '体积或规格说明' AFTER total_weight_kg,
+  ADD COLUMN base_delivery_fee BIGINT NOT NULL DEFAULT 0 COMMENT '基础配送费快照' AFTER distance_fee,
+  ADD COLUMN urgent_rate DECIMAL(8,2) NOT NULL DEFAULT 0.00 COMMENT '加急比例快照' AFTER base_delivery_fee,
+  ADD COLUMN minimum_urgent_fee BIGINT NOT NULL DEFAULT 0 COMMENT '最低加急费快照' AFTER urgent_rate,
+  ADD COLUMN urgent_fee BIGINT NOT NULL DEFAULT 0 COMMENT '加急费快照' AFTER minimum_urgent_fee,
+  ADD COLUMN tip_amount BIGINT NOT NULL DEFAULT 0 COMMENT '骑手小费快照' AFTER urgent_fee;
+
+UPDATE flash_delivery_order
+SET delivery_type = 'URGENT', service_type = 'HELP_SEND'
+WHERE service_type = 'URGENT';
+
+UPDATE flash_delivery_order
+SET base_delivery_fee = starting_fare + distance_fee,
+    amount = starting_fare + distance_fee + urgent_fee + tip_amount;
+
+ALTER TABLE flash_delivery_order
+  DROP INDEX idx_flash_available,
+  ADD KEY idx_flash_available (status, delivery_mode, scheduled_pickup_start_at, delivery_type, create_time);
+```
+
+## 2026-09-07 移除闪送运价专用锁表
+
+```sql
+-- 运价为低频管理员配置;保留事务、时段重叠检查和配置版本校验,不再维护专用锁行。
+DROP TABLE IF EXISTS flash_delivery_pricing_lock;
+```
+
+## 2026-09-07 闪送物品重量改为四档范围(024-flash-delivery)
+
+> 用途:按用户端设计稿将精确重量和旧三档 `package_size` 合并为四档 `weight_range`。当前仍处于开发阶段,不处理历史数据兼容;脚本只记录,由开发者人工执行。
+
+```sql
+UPDATE flash_delivery_order
+SET package_size = CASE
+  WHEN total_weight_kg <= 5 THEN 'UP_TO_5_KG'
+  WHEN total_weight_kg <= 10 THEN 'OVER_5_TO_10_KG'
+  WHEN total_weight_kg <= 15 THEN 'OVER_10_TO_15_KG'
+  ELSE 'OVER_15_TO_20_KG'
+END;
+
+ALTER TABLE flash_delivery_order
+  CHANGE COLUMN package_size weight_range VARCHAR(24) NOT NULL COMMENT '物品重量范围',
+  DROP COLUMN total_weight_kg;
 ```

Some files were not shown because too many files changed in this diff