Browse Source

补充闪送收件人与急送独占实施计划

qmj 1 day ago
parent
commit
b3265448f7

+ 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.
+
+- [ ] **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.
+
+- [ ] **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.
+
+- [ ] **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}
+```
+
+- [ ] **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)`.
+
+- [ ] **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`.
+
+- [ ] **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`.
+
+- [ ] **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.
+
+- [ ] **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.
+
+- [ ] **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.
+
+- [ ] **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.
+
+- [ ] **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.
+
+- [ ] **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.
+
+- [ ] **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.
+
+- [ ] **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.
+
+- [ ] **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.

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

@@ -522,6 +522,8 @@ The staged list must not contain `.claude/homunculus/observations.jsonl`、`.tmp
 
 ## 新版蓝湖稿增量计划(2026-09-04)
 
+详细文件级执行步骤见 `implementation-plan-2026-09-04.md`。
+
 ### 目标
 
 保持闪送非支付和整数 TWD 现状,新增创建时收件账号绑定与“我收的”查询,并将 `URGENT` 落实为跨外卖、闪送任务的骑手独占。附近骑手人数和接单分钟数不进入接口,用户端统一展示“发布后等待附近骑手接单”。