Просмотр исходного кода

feat: 闪送订单状态变更消息推送

qmj 47 минут назад
Родитель
Сommit
1b3c6b6e09

+ 2 - 2
.specify/feature.json

@@ -1,3 +1,3 @@
 {
-  "feature_directory": "specs/027-offline-transfer-payment"
-}
+  "feature_directory": "specs/028-flash-delivery-push"
+}

+ 1 - 1
CLAUDE.md

@@ -204,5 +204,5 @@ Strong success criteria let you loop independently. Weak criteria ("make it work
 
 <!-- SPECKIT START -->
 For additional context about technologies to be used, project structure,
-shell commands, and other important information, read the current plan: `specs/027-offline-transfer-payment/plan.md`
+shell commands, and other important information, read the current plan: `specs/028-flash-delivery-push/plan.md`
 <!-- SPECKIT END -->

+ 16 - 1
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryApplicationService.java

@@ -72,6 +72,7 @@ public class FlashDeliveryApplicationService {
     private final FlashDeliveryPricingCalculator pricingCalculator;
     private final RiderDeliveryLockService riderDeliveryLockService;
     private final RiderDeliveryExclusivityService riderDeliveryExclusivityService;
+    private final FlashDeliveryNotificationService notificationService;
 
     public FlashDeliveryApplicationService(FlashDeliveryOrderMapper orderMapper,
                                            FlashDeliveryPricingMapper pricingMapper,
@@ -81,7 +82,8 @@ public class FlashDeliveryApplicationService {
                                            FlashDeliveryRouteService routeService,
                                            FlashDeliveryPricingCalculator pricingCalculator,
                                            RiderDeliveryLockService riderDeliveryLockService,
-                                           RiderDeliveryExclusivityService riderDeliveryExclusivityService) {
+                                           RiderDeliveryExclusivityService riderDeliveryExclusivityService,
+                                           FlashDeliveryNotificationService notificationService) {
         this.orderMapper = orderMapper;
         this.pricingMapper = pricingMapper;
         this.imageMapper = imageMapper;
@@ -91,6 +93,7 @@ public class FlashDeliveryApplicationService {
         this.pricingCalculator = pricingCalculator;
         this.riderDeliveryLockService = riderDeliveryLockService;
         this.riderDeliveryExclusivityService = riderDeliveryExclusivityService;
+        this.notificationService = notificationService;
     }
 
     /** 闪送首页:返回开放的业务场景、配送等级,以及当前时段各服务的公开运价摘要。 */
@@ -395,6 +398,8 @@ public class FlashDeliveryApplicationService {
             throw fail("flash.delivery.state.changed");
         }
         writeLog(orderId, order.getStatus(), CANCELLED, "USER", userId, reason, now);
+        // 取消成功后通知接单骑手;未接单订单由通知服务内部跳过。
+        notificationService.notifyCancelled(order);
     }
 
     /** 用户(寄件人或收件人)确认收货:已送达推进为已完成。 */
@@ -483,6 +488,7 @@ public class FlashDeliveryApplicationService {
                 throw fail("flash.delivery.order.already.accepted");
             }
             writeLog(orderId, WAITING_ACCEPTANCE, ACCEPTED, "RIDER", riderId, null, now);
+            notificationService.notifyAccepted(target);
             return participantDetail(requireOrder(orderId), false, false);
         });
     }
@@ -620,6 +626,8 @@ public class FlashDeliveryApplicationService {
             throw fail("flash.delivery.state.changed");
         }
         writeLog(orderId, order.getStatus(), CANCELLED, "ADMIN", adminId, reason, now);
+        // 平台取消同样只通知接单骑手;寄件用户按规格不推送。
+        notificationService.notifyCancelled(order);
     }
 
     /** 将已送达订单推进为已完成;平台手动与系统自动完成共用此入口,条件更新失败返回 false。 */
@@ -779,6 +787,13 @@ public class FlashDeliveryApplicationService {
         }
         saveImages(orderId, proofType, "RIDER", riderId, urls, now);
         writeLog(orderId, expected, next, "RIDER", riderId, null, now);
+        // 状态推进成功后通知寄件用户;重读订单以携带最新归属信息。
+        FlashDeliveryOrder latest = requireOrder(orderId);
+        if (PICKED_UP.equals(next)) {
+            notificationService.notifyPickedUp(latest);
+        } else if (DELIVERED.equals(next)) {
+            notificationService.notifyDelivered(latest);
+        }
     }
 
     /** 状态、图片和日志处于同一事务;任一图片写入失败会回滚本次业务操作。 */

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

@@ -0,0 +1,144 @@
+package com.ruoyi.app.flashdelivery.service;
+
+import com.ruoyi.app.order.dto.OrderPushBodyDto;
+import com.ruoyi.app.utils.PayPush;
+import com.ruoyi.app.utils.event.PushEventService;
+import com.ruoyi.framework.manager.AsyncManager;
+import com.ruoyi.system.domain.InfoUser;
+import com.ruoyi.system.domain.flash.FlashDeliveryOrder;
+import com.ruoyi.system.mapper.InfoUserMapper;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.support.TransactionSynchronization;
+import org.springframework.transaction.support.TransactionSynchronizationManager;
+
+import java.util.TimerTask;
+
+import static com.ruoyi.system.domain.flash.FlashDeliveryStatus.ACCEPTED;
+import static com.ruoyi.system.domain.flash.FlashDeliveryStatus.CANCELLED;
+import static com.ruoyi.system.domain.flash.FlashDeliveryStatus.DELIVERED;
+import static com.ruoyi.system.domain.flash.FlashDeliveryStatus.PICKED_UP;
+
+/**
+ * 闪送订单状态变更后的消息推送:履约事件(接单、取件、送达)推寄件用户,取消事件推接单骑手。
+ * 推送统一在事务提交后异步执行,失败仅记日志,不影响订单状态流转本身。
+ */
+@Service
+public class FlashDeliveryNotificationService {
+    private static final Logger log = LoggerFactory.getLogger(FlashDeliveryNotificationService.class);
+
+    private static final String TITLE_KEY = "no.message.push.message";
+    private static final String CONTENT_ACCEPTED = "no.message.push.delivery.personnel.receiving.order";
+    private static final String CONTENT_PICKED_UP = "no.message.push.delivery.personnel.qspsz.order";
+    private static final String CONTENT_DELIVERED = "no.message.push.delivery.personnel.qsysd.order";
+    private static final String CONTENT_CANCELLED = "no.message.push.order.cancelled";
+    /** 推送 payload 的业务类型:1=外卖、2=充值,3=闪送。 */
+    private static final int PUSH_TYPE_FLASH = 3;
+    /** -1 表示非语音提醒(0 仅用于外卖骑手新单语音)。 */
+    private static final int PUSH_PAYLOAD_TYPE_NONE = -1;
+
+    private final InfoUserMapper userMapper;
+    private final PushEventService pushEventService;
+
+    @Autowired
+    public FlashDeliveryNotificationService(InfoUserMapper userMapper, PushEventService pushEventService) {
+        this.userMapper = userMapper;
+        this.pushEventService = pushEventService;
+    }
+
+    /** 按订单取寄件用户;订单或用户缺失时静默跳过。 */
+    InfoUser senderOf(FlashDeliveryOrder order) {
+        if (order == null || order.getUserId() == null) return null;
+        return userMapper.selectById(order.getUserId());
+    }
+
+    /** 按订单取接单骑手;未接单(riderId 为空)或骑手缺失时静默跳过。 */
+    InfoUser riderOf(FlashDeliveryOrder order) {
+        if (order == null || order.getRiderId() == null) return null;
+        return userMapper.selectById(order.getRiderId());
+    }
+
+    /** 向寄件用户推送指定状态文案;消息入库由通道内 PublisherEvent 完成,cid 为空时仅入库。 */
+    void sendToUser(InfoUser recipient, FlashDeliveryOrder order, String status, String contentKey) {
+        if (recipient == null || recipient.getUserId() == null) return;
+        String orderNo = order.getOrderNo();
+        String body = OrderPushBodyDto.getJson(orderNo, status, PUSH_PAYLOAD_TYPE_NONE, PUSH_TYPE_FLASH);
+        AsyncManager.me().execute(new TimerTask() {
+            @Override
+            public void run() {
+                try {
+                    PayPush.userPushHandleLocal(new PayPush(), pushEventService, recipient.getUserId(),
+                            recipient.getCid(), TITLE_KEY, contentKey, body, "", orderNo);
+                } catch (Exception e) {
+                    log.error("闪送用户推送失败,userId:{}, orderNo:{}", recipient.getUserId(), orderNo, e);
+                }
+            }
+        });
+    }
+
+    /** 向接单骑手推送指定状态文案;与用户端一致,失败仅记日志。 */
+    void sendToRider(InfoUser recipient, FlashDeliveryOrder order, String status, String contentKey) {
+        if (recipient == null || recipient.getUserId() == null) return;
+        String orderNo = order.getOrderNo();
+        String body = OrderPushBodyDto.getJson(orderNo, status, PUSH_PAYLOAD_TYPE_NONE, PUSH_TYPE_FLASH);
+        AsyncManager.me().execute(new TimerTask() {
+            @Override
+            public void run() {
+                try {
+                    PayPush.qsPushHandleLocal(new PayPush(), pushEventService, recipient.getUserId(),
+                            recipient.getCid(), TITLE_KEY, contentKey, body, "", orderNo);
+                } catch (Exception e) {
+                    log.error("闪送骑手推送失败,riderId:{}, orderNo:{}", recipient.getUserId(), orderNo, e);
+                }
+            }
+        });
+    }
+
+    /** 骑手抢单成功后通知寄件用户;未解析到寄件用户时跳过。 */
+    public void notifyAccepted(FlashDeliveryOrder order) {
+        runAfterCommit(() -> {
+            InfoUser sender = senderOf(order);
+            if (sender != null) sendToUser(sender, order, ACCEPTED, CONTENT_ACCEPTED);
+        });
+    }
+
+    /** 骑手取件成功后通知寄件用户;未解析到寄件用户时跳过。 */
+    public void notifyPickedUp(FlashDeliveryOrder order) {
+        runAfterCommit(() -> {
+            InfoUser sender = senderOf(order);
+            if (sender != null) sendToUser(sender, order, PICKED_UP, CONTENT_PICKED_UP);
+        });
+    }
+
+    /** 骑手送达后通知寄件用户;未解析到寄件用户时跳过。 */
+    public void notifyDelivered(FlashDeliveryOrder order) {
+        runAfterCommit(() -> {
+            InfoUser sender = senderOf(order);
+            if (sender != null) sendToUser(sender, order, DELIVERED, CONTENT_DELIVERED);
+        });
+    }
+
+    /** 订单取消后通知接单骑手;未接单(无骑手归属)或骑手缺失时跳过,寄件用户不推。 */
+    public void notifyCancelled(FlashDeliveryOrder order) {
+        runAfterCommit(() -> {
+            InfoUser rider = riderOf(order);
+            if (rider != null) sendToRider(rider, order, CANCELLED, CONTENT_CANCELLED);
+        });
+    }
+
+    /** 仅事务提交后执行推送准备;无事务同步时(单测)直接执行。 */
+    static void runAfterCommit(Runnable action) {
+        if (!TransactionSynchronizationManager.isSynchronizationActive()) {
+            action.run();
+            return;
+        }
+        TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
+            @Override
+            public void afterCommit() {
+                action.run();
+            }
+        });
+    }
+}

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

@@ -11,6 +11,7 @@ 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.FlashDeliveryReasonRequest;
 import com.ruoyi.app.flashdelivery.dto.FlashDeliveryDeliverRequest;
 import com.ruoyi.app.flashdelivery.dto.FlashDeliveryRiderOrderPageView;
 import com.ruoyi.app.flashdelivery.exception.FlashDeliveryQuoteChangedException;
@@ -68,6 +69,7 @@ import static org.mockito.Mockito.when;
 import static org.mockito.Mockito.doAnswer;
 import static org.mockito.Mockito.inOrder;
 import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.verifyNoInteractions;
 
 class FlashDeliveryApplicationServiceTest {
     private static ConfigurableListableBeanFactory originalBeanFactory;
@@ -691,6 +693,163 @@ class FlashDeliveryApplicationServiceTest {
         verify(fixture.orderMapper, never()).accept(any(), any(), any());
     }
 
+    @Test
+    void acceptSuccessNotifiesSenderOfOrder() {
+        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);
+
+        verify(fixture.notificationService).notifyAccepted(waiting);
+    }
+
+    @Test
+    void acceptFailureDoesNotTriggerNotification() {
+        Fixture fixture = new Fixture();
+        rider(fixture, 8L);
+        when(fixture.orderMapper.selectById(10L)).thenReturn(order(10L, "WAITING_ACCEPTANCE"));
+        when(fixture.orderMapper.accept(eq(10L), eq(8L), any(Date.class))).thenReturn(0);
+
+        assertThrows(ServiceException.class, () -> fixture.service.accept(8L, 10L));
+
+        verify(fixture.notificationService, never()).notifyAccepted(any());
+    }
+
+    @Test
+    void pickupSuccessNotifiesSenderOfOrder() {
+        Fixture fixture = new Fixture();
+        rider(fixture, 8L);
+        FlashDeliveryOrder accepted = order(10L, ACCEPTED);
+        accepted.setRiderId(8L);
+        when(fixture.orderMapper.selectById(10L)).thenReturn(accepted);
+        when(fixture.orderMapper.transitionByRider(eq(10L), eq(8L), eq("ACCEPTED"),
+                eq("PICKED_UP"), eq("picked_up_at"), any())).thenReturn(1);
+        FlashDeliveryProofRequest request = new FlashDeliveryProofRequest();
+        request.setImageUrls(List.of("https://example.com/pickup.jpg"));
+
+        fixture.service.pickup(8L, 10L, request);
+
+        verify(fixture.notificationService).notifyPickedUp(accepted);
+    }
+
+    @Test
+    void deliverSuccessNotifiesSenderOfOrder() {
+        Fixture fixture = new Fixture();
+        rider(fixture, 8L);
+        FlashDeliveryOrder pickedUp = order(10L, "PICKED_UP");
+        pickedUp.setRiderId(8L);
+        when(fixture.orderMapper.selectById(10L)).thenReturn(pickedUp);
+        when(fixture.orderMapper.transitionByRider(eq(10L), eq(8L), eq("PICKED_UP"),
+                eq("DELIVERED"), eq("delivered_at"), any())).thenReturn(1);
+        FlashDeliveryDeliverRequest request = new FlashDeliveryDeliverRequest();
+        request.setPinCode("4821");
+        request.setImageUrls(List.of("https://example.com/delivery.jpg"));
+
+        fixture.service.deliver(8L, 10L, request);
+
+        verify(fixture.notificationService).notifyDelivered(pickedUp);
+    }
+
+    @Test
+    void proofTransitionFailureDoesNotTriggerNotification() {
+        Fixture fixture = new Fixture();
+        rider(fixture, 8L);
+        FlashDeliveryOrder accepted = order(10L, ACCEPTED);
+        accepted.setRiderId(8L);
+        when(fixture.orderMapper.selectById(10L)).thenReturn(accepted);
+        when(fixture.orderMapper.transitionByRider(eq(10L), eq(8L), eq("ACCEPTED"),
+                eq("PICKED_UP"), eq("picked_up_at"), any())).thenReturn(0);
+        FlashDeliveryProofRequest request = new FlashDeliveryProofRequest();
+        request.setImageUrls(List.of("https://example.com/pickup.jpg"));
+
+        assertThrows(ServiceException.class, () -> fixture.service.pickup(8L, 10L, request));
+
+        verify(fixture.notificationService, never()).notifyPickedUp(any());
+        verify(fixture.notificationService, never()).notifyDelivered(any());
+    }
+
+    @Test
+    void userCancelSuccessNotifiesAssignedRider() {
+        Fixture fixture = new Fixture();
+        FlashDeliveryOrder accepted = order(10L, ACCEPTED);
+        accepted.setRiderId(9L);
+        when(fixture.orderMapper.selectById(10L)).thenReturn(accepted);
+        when(fixture.orderMapper.cancel(eq(10L), eq("ACCEPTED"), eq("USER"), eq(7L),
+                anyString(), any(Date.class))).thenReturn(1);
+
+        fixture.service.userCancel(7L, 10L, reason("改行程"));
+
+        verify(fixture.notificationService).notifyCancelled(accepted);
+    }
+
+    @Test
+    void adminCancelSuccessNotifiesAssignedRider() {
+        Fixture fixture = new Fixture();
+        FlashDeliveryOrder pickedUp = order(10L, "PICKED_UP");
+        pickedUp.setRiderId(9L);
+        when(fixture.orderMapper.selectById(10L)).thenReturn(pickedUp);
+        when(fixture.orderMapper.cancel(eq(10L), eq("PICKED_UP"), eq("ADMIN"), eq(1L),
+                anyString(), any(Date.class))).thenReturn(1);
+
+        fixture.service.adminCancel(1L, 10L, reason("异常收口"));
+
+        verify(fixture.notificationService).notifyCancelled(pickedUp);
+    }
+
+    @Test
+    void cancelFailureDoesNotTriggerNotification() {
+        Fixture fixture = new Fixture();
+        FlashDeliveryOrder accepted = order(10L, ACCEPTED);
+        accepted.setRiderId(9L);
+        when(fixture.orderMapper.selectById(10L)).thenReturn(accepted);
+        when(fixture.orderMapper.cancel(eq(10L), eq("ACCEPTED"), eq("USER"), eq(7L),
+                anyString(), any(Date.class))).thenReturn(0);
+
+        assertThrows(ServiceException.class, () -> fixture.service.userCancel(7L, 10L, reason("改行程")));
+
+        verify(fixture.notificationService, never()).notifyCancelled(any());
+    }
+
+    @Test
+    void excludedEventsNeverTriggerNotification() {
+        // FR-010:创建、加小费、完成、过期预约自动取消四类事件不产生任何推送。
+        Fixture fixture = new Fixture();
+        when(fixture.pricingMapper.selectAtTime(anyString(), anyInt())).thenReturn(List.of(pricing()));
+        when(fixture.routeService.calculate(any(), any(), anyInt())).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());
+
+        editableOrder(fixture);
+        when(fixture.orderMapper.updateWaitingTip(any(), eq(2))).thenReturn(1);
+        fixture.service.addTip(7L, 10L, tipRequest(20L, 2));
+
+        when(fixture.orderMapper.transitionByStatus(eq(10L), eq("DELIVERED"), eq("COMPLETED"),
+                eq("completed_at"), any(Date.class))).thenReturn(1);
+        assertTrue(fixture.service.complete(1L, "ADMIN", 10L));
+
+        when(fixture.orderMapper.cancelExpiredScheduled(eq(10L), any(Date.class))).thenReturn(1);
+        assertTrue(fixture.service.cancelExpiredScheduledOrder(10L, new Date()));
+
+        verifyNoInteractions(fixture.notificationService);
+    }
+
+    private FlashDeliveryReasonRequest reason(String text) {
+        FlashDeliveryReasonRequest request = new FlashDeliveryReasonRequest();
+        request.setReason(text);
+        return request;
+    }
+
     @Test
     void oversizedProofUrlIsRejectedBeforeStateTransition() {
         Fixture fixture = new Fixture();
@@ -1205,6 +1364,7 @@ class FlashDeliveryApplicationServiceTest {
         return image;
     }
 
+
     @Test
     void missingOrInvalidBodyOrderIdIsRejectedBeforeDatabaseAccess() {
         Fixture fixture = new Fixture();
@@ -1513,9 +1673,11 @@ class FlashDeliveryApplicationServiceTest {
         final FlashDeliveryRouteService routeService = mock(FlashDeliveryRouteService.class);
         final RiderDeliveryLockService lockService = mock(RiderDeliveryLockService.class);
         final RiderDeliveryExclusivityService exclusivityService = mock(RiderDeliveryExclusivityService.class);
+        final FlashDeliveryNotificationService notificationService = mock(FlashDeliveryNotificationService.class);
         final FlashDeliveryApplicationService service = new FlashDeliveryApplicationService(
                 orderMapper, pricingMapper, imageMapper, logMapper, userMapper,
-                routeService, new FlashDeliveryPricingCalculator(), lockService, exclusivityService);
+                routeService, new FlashDeliveryPricingCalculator(), lockService, exclusivityService,
+                notificationService);
 
         Fixture() {
             when(lockService.withLock(any(), any())).thenAnswer(invocation -> {

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

@@ -0,0 +1,122 @@
+package com.ruoyi.app.flashdelivery.service;
+
+import com.ruoyi.app.utils.event.PushEventService;
+import com.ruoyi.system.domain.InfoUser;
+import com.ruoyi.system.domain.flash.FlashDeliveryOrder;
+import com.ruoyi.system.mapper.InfoUserMapper;
+import org.junit.jupiter.api.Test;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.ArgumentMatchers.same;
+import static org.mockito.Mockito.doNothing;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+class FlashDeliveryNotificationServiceTest {
+
+    @Test
+    void acceptedNotificationRoutesToSender() {
+        InfoUserMapper userMapper = mock(InfoUserMapper.class);
+        FlashDeliveryNotificationService service = spy(new FlashDeliveryNotificationService(
+                userMapper, mock(PushEventService.class)));
+        doNothing().when(service).sendToUser(any(), any(), anyString(), anyString());
+        InfoUser sender = new InfoUser();
+        sender.setUserId(7L);
+        FlashDeliveryOrder order = order();
+        when(userMapper.selectById(7L)).thenReturn(sender);
+
+        service.notifyAccepted(order);
+
+        verify(service).sendToUser(same(sender), same(order), eq("ACCEPTED"),
+                eq("no.message.push.delivery.personnel.receiving.order"));
+    }
+
+    @Test
+    void missingSenderSkipsNotification() {
+        InfoUserMapper userMapper = mock(InfoUserMapper.class);
+        FlashDeliveryNotificationService service = spy(new FlashDeliveryNotificationService(
+                userMapper, mock(PushEventService.class)));
+        when(userMapper.selectById(7L)).thenReturn(null);
+
+        service.notifyAccepted(order());
+
+        verify(service, never()).sendToUser(any(), any(), anyString(), anyString());
+    }
+
+    @Test
+    void pickedUpNotificationRoutesToSender() {
+        InfoUserMapper userMapper = mock(InfoUserMapper.class);
+        FlashDeliveryNotificationService service = spy(new FlashDeliveryNotificationService(
+                userMapper, mock(PushEventService.class)));
+        doNothing().when(service).sendToUser(any(), any(), anyString(), anyString());
+        InfoUser sender = new InfoUser();
+        sender.setUserId(7L);
+        FlashDeliveryOrder order = order();
+        when(userMapper.selectById(7L)).thenReturn(sender);
+
+        service.notifyPickedUp(order);
+
+        verify(service).sendToUser(same(sender), same(order), eq("PICKED_UP"),
+                eq("no.message.push.delivery.personnel.qspsz.order"));
+    }
+
+    @Test
+    void deliveredNotificationRoutesToSender() {
+        InfoUserMapper userMapper = mock(InfoUserMapper.class);
+        FlashDeliveryNotificationService service = spy(new FlashDeliveryNotificationService(
+                userMapper, mock(PushEventService.class)));
+        doNothing().when(service).sendToUser(any(), any(), anyString(), anyString());
+        InfoUser sender = new InfoUser();
+        sender.setUserId(7L);
+        FlashDeliveryOrder order = order();
+        when(userMapper.selectById(7L)).thenReturn(sender);
+
+        service.notifyDelivered(order);
+
+        verify(service).sendToUser(same(sender), same(order), eq("DELIVERED"),
+                eq("no.message.push.delivery.personnel.qsysd.order"));
+    }
+
+    @Test
+    void cancelledNotificationRoutesToAssignedRider() {
+        InfoUserMapper userMapper = mock(InfoUserMapper.class);
+        FlashDeliveryNotificationService service = spy(new FlashDeliveryNotificationService(
+                userMapper, mock(PushEventService.class)));
+        doNothing().when(service).sendToRider(any(), any(), anyString(), anyString());
+        InfoUser rider = new InfoUser();
+        rider.setUserId(9L);
+        FlashDeliveryOrder order = order();
+        order.setRiderId(9L);
+        when(userMapper.selectById(9L)).thenReturn(rider);
+
+        service.notifyCancelled(order);
+
+        verify(service).sendToRider(same(rider), same(order), eq("CANCELLED"),
+                eq("no.message.push.order.cancelled"));
+    }
+
+    @Test
+    void cancelledNotificationSkipsWhenNoRiderAssigned() {
+        InfoUserMapper userMapper = mock(InfoUserMapper.class);
+        FlashDeliveryNotificationService service = spy(new FlashDeliveryNotificationService(
+                userMapper, mock(PushEventService.class)));
+
+        service.notifyCancelled(order());
+
+        verify(service, never()).sendToRider(any(), any(), anyString(), anyString());
+        verify(service, never()).sendToUser(any(), any(), anyString(), anyString());
+    }
+
+    private static FlashDeliveryOrder order() {
+        FlashDeliveryOrder order = new FlashDeliveryOrder();
+        order.setId(10L);
+        order.setOrderNo("FL-20260813-021");
+        order.setUserId(7L);
+        return order;
+    }
+}

+ 38 - 0
specs/028-flash-delivery-push/checklists/requirements.md

@@ -0,0 +1,38 @@
+# Specification Quality Checklist: 闪送订单状态变更消息推送
+
+**Purpose**: Validate specification completeness and quality before proceeding to planning
+**Created**: 2026-09-17
+**Feature**: [spec.md](../spec.md)
+
+## Content Quality
+
+- [x] No implementation details (languages, frameworks, APIs)
+- [x] Focused on user value and business needs
+- [x] Written for non-technical stakeholders
+- [x] All mandatory sections completed
+
+## Requirement Completeness
+
+- [x] No [NEEDS CLARIFICATION] markers remain
+- [x] Requirements are testable and unambiguous
+- [x] Success criteria are measurable
+- [x] Success criteria are technology-agnostic (no implementation details)
+- [x] All acceptance scenarios are defined
+- [x] Edge cases are identified
+- [x] Scope is clearly bounded
+- [x] Dependencies and assumptions identified
+
+## Feature Readiness
+
+- [x] All functional requirements have clear acceptance criteria
+- [x] User scenarios cover primary flows
+- [x] Feature meets measurable outcomes defined in Success Criteria
+- [x] No implementation details leak into specification
+
+## Notes
+
+- 校验说明:
+  - 实现细节检查:spec 仅引用业务状态名(WAITING_ACCEPTANCE 等,为产品已有订单状态术语)与"现有推送通道/消息中心机制"的存在性约束,未规定语言、框架、接口形态;具体通道选型留给 plan 阶段。
+  - 范围边界:FR-010 明确列出四类不推送事件,双向(要什么/不要什么)都已成文。
+  - 无 [NEEDS CLARIFICATION]:用户输入已明确事件范围与排除项;其余未决点(平台取消是否通知用户、自动完成是否推送)均按用户口径落入 FR-005/FR-010 与 Assumptions,采用合理默认。
+  - 可测性:FR-001~FR-011 均可映射到 User Story 的 Given/When/Then 场景逐一验证;SC-001~SC-004 为可度量指标。

+ 41 - 0
specs/028-flash-delivery-push/contracts/push.md

@@ -0,0 +1,41 @@
+# Push Payload Contract: 闪送订单状态变更消息推送
+
+**Date**: 2026-09-17 | **Spec**: [spec.md](../spec.md)
+
+本特性**不新增 HTTP 接口端点**。对外契约只有一份:推送给客户端的 payload 形状(经 uni 云函数透传给 App)。
+
+## Payload(OrderPushBodyDto JSON)
+
+```json
+{
+  "ddId": "FD7A3F9C2E1B4D5A6F7C8D9E",
+  "state": "ACCEPTED",
+  "type": -1,
+  "pushType": 3
+}
+```
+
+| 字段 | 取值 | 说明 |
+|------|------|------|
+| `ddId` | 闪送 `orderNo`(`FD` + 24 位大写十六进制) | 订单标识;沿用历史字段名以兼容客户端既有解析 |
+| `state` | `ACCEPTED` / `PICKED_UP` / `DELIVERED` / `CANCELLED` | 推送对应的目标状态(闪送状态枚举字符串,非外卖数字状态) |
+| `type` | `-1` | -1=非语音提醒(0 仅用于外卖骑手新单语音) |
+| `pushType` | `3` | **新增取值**:3=闪送订单(既有:1=外卖、2=充值)。客户端按此路由到闪送订单详情页 |
+
+## 通知正文
+
+| 事件 | 接收端 | title | content(渲染后) |
+|------|--------|-------|------------------|
+| 骑手抢单 | 用户 App | `消息`(`no.message.push.message`) | `骑手已接单,NO:{orderNo}` |
+| 骑手取件 | 用户 App | 同上 | `骑手配送中,NO:{orderNo}` |
+| 骑手送达 | 用户 App | 同上 | `骑手已送达,NO:{orderNo}` |
+| 订单取消 | 骑手 App | 同上 | `订单已取消,NO:{orderNo}` |
+
+- 文案按接收方 locale 渲染(vi/zh/tw/en/th,复用既有 6 份 i18n 资源)。
+- 取消通知只发给接单骑手;寄件用户不发(spec FR-004/FR-005)。
+- 每条推送同时经 `PushEventService` 写入 `push_message`(消息中心可回看)。
+
+## 客户端适配注意(本仓库外)
+
+- 客户端收到 `pushType=3` 的推送时,用 `ddId`(orderNo)跳转闪送订单详情;`state` 为闪送状态枚举字符串,与外卖的数字 `state` 语义不同,勿混用。
+- 客户端适配不在本仓库范围(spec Assumptions)。

+ 45 - 0
specs/028-flash-delivery-push/data-model.md

@@ -0,0 +1,45 @@
+# Data Model: 闪送订单状态变更消息推送
+
+**Date**: 2026-09-17 | **Spec**: [spec.md](spec.md)
+
+本特性**不新增任何表、字段或实体**。数据面完全复用既有结构:
+
+## 消费的既有实体
+
+### FlashDeliveryOrder(闪送订单,既有)
+
+推送触发与接收人判定只读以下字段:
+
+| 字段 | 类型 | 用途 |
+|------|------|------|
+| `orderNo` | String | 推送正文 `,NO:` 后缀与 payload `ddId`(FR-007) |
+| `userId` | Long | 寄件用户 → ACCEPTED/PICKED_UP/DELIVERED 推送对象 |
+| `riderId` | Long | 接单骑手 → CANCELLED 推送对象;null 表示未接单,跳过推送 |
+| `status` | String | 仅用于触发点判定(由状态流转代码自身保证,推送不重复判状态) |
+
+### InfoUser(用户,既有)
+
+| 字段 | 用途 |
+|------|------|
+| `cid` | 设备推送标识;为空时云端推送跳过、消息记录仍生成 |
+| 语言设置 | 经 `PayPush.*HandleLocal` → `LocaleUtils.getUserLocale` 决定文案语言 |
+
+### push_message(消息记录表,既有,不修改)
+
+`PayPush` 通道内 `PushEventService.PublisherEvent(userId, title, content, body)` 落库,即 spec FR-008 的"消息中心记录"。通知服务不直接操作该表。
+
+## 触发事件矩阵(状态流转 → 推送)
+
+| 状态流转 | 触发方法(FlashDeliveryApplicationService) | 通知调用 | 接收人 | content key |
+|----------|--------------------------------------------|---------|--------|-------------|
+| WAITING_ACCEPTANCE → ACCEPTED | `accept`(锁内条件更新成功后) | `notifyAccepted(order)` | userId | ...receiving.order |
+| ACCEPTED → PICKED_UP | `pickup` → `transitionWithProof` | `notifyPickedUp(order)` | userId | ...qspsz.order |
+| PICKED_UP → DELIVERED | `deliver` → `transitionWithProof` | `notifyDelivered(order)` | userId | ...qsysd.order |
+| → CANCELLED(用户取消) | `userCancel` | `notifyCancelled(order)`(riderId 非空时) | riderId | order.cancelled |
+| → CANCELLED(平台取消) | `adminCancel` | `notifyCancelled(order)`(riderId 非空时) | riderId | order.cancelled |
+
+**明确不触发**(FR-010):订单创建、加小费(`tipAdd`)、DELIVERED → COMPLETED(`complete` / 自动完成 task)、WAITING_ACCEPTANCE 过期自动取消(`cancelExpiredScheduledOrder`)。
+
+## 传递给推送通道的 payload 契约
+
+见 [contracts/push.md](contracts/push.md)。

+ 93 - 0
specs/028-flash-delivery-push/plan.md

@@ -0,0 +1,93 @@
+# Implementation Plan: 闪送订单状态变更消息推送
+
+**Branch**: `028-flash-delivery-push` | **Date**: 2026-09-17 | **Spec**: [spec.md](spec.md)
+
+**Input**: Feature specification from `/specs/028-flash-delivery-push/spec.md`
+
+## Summary
+
+在闪送订单五个状态变更点(骑手抢单、取件、送达、用户取消、平台取消)接入既有推送通道:新建 `FlashDeliveryNotificationService`(镜像 `DeliveryOrderNotificationService` 的 afterCommit + AsyncManager + PayPush 模式),由 `FlashDeliveryApplicationService` 在条件更新成功后调用。履约事件推寄件用户、取消事件推接单骑手;复用现有 i18n 文案 key 与 `OrderPushBodyDto` payload(pushType=3)。零新表、零新接口、零新翻译(详见 [research.md](research.md))。
+
+## Technical Context
+
+**Language/Version**: Java 21(Spring Boot + MyBatis-Plus)
+
+**Primary Dependencies**: Spring 事务同步(`TransactionSynchronizationManager`)、`AsyncManager`、`PayPush`(uni 云函数推送)、`PushEventService`(push_message 入库)、fastjson(payload 序列化)
+
+**Storage**: MySQL(只读 `flash_delivery_order`、`info_user`;只写既有 `push_message`,经由 PushEventService)
+
+**Testing**: JUnit 5 + Mockito(`spy`+`doNothing` 打断网络发送,验证路由决策;仓库先例 `DeliveryOrderNotificationServiceTest`)
+
+**Target Platform**: Windows 开发 / Linux 部署的 Spring Boot 服务
+
+**Project Type**: web-service(多模块 Maven:`ruoyi-admin` → `ruoyi-system`)
+
+**Performance Goals**: 推送异步执行,不增加订单接口耗时(SC-004 零传导)
+
+**Constraints**: 推送失败不得影响状态流转(FR-009);仅事务提交后触发(FR-011);不新增 Android 通道
+
+**Scale/Scope**: 1 个新服务类 + 1 个应用服务的 5 个方法加钩子 + 2 个测试类
+
+## Constitution Check
+
+*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
+
+`.specify/memory/constitution.md` 为未定制的占位模板,无项目级原则可引用;改按仓库 `CLAUDE.md` 约束逐条核对:
+
+| 约束 | 结论 |
+|------|------|
+| Controller 参数规范 | 不涉及(无新 Controller/接口改动) |
+| 模块边界:集成代码放 ruoyi-admin | ✅ 通知服务放 `ruoyi-admin` 的 `com.ruoyi.app.flashdelivery.service`,不进 ruoyi-system |
+| 数据库变更须写 updatesql/sql.md | ✅ 本特性零数据库变更 |
+| 全栈字段清单 | ✅ 无新字段 |
+| Java 注释安全(无 `*/` 序列) | ✅ 实现时遵守 |
+| 前端 i18n 规范 | 不涉及(复用后端既有文案 key,无前端改动) |
+| CRLF 保留、不格式化整文件 | ✅ 实现时遵守 |
+
+无违规,无需 Complexity Tracking 条目。
+
+## Project Structure
+
+### Documentation (this feature)
+
+```text
+specs/028-flash-delivery-push/
+├── plan.md              # This file (/speckit-plan command output)
+├── research.md          # Phase 0 output (/speckit-plan command)
+├── data-model.md        # Phase 1 output (/speckit-plan command)
+├── quickstart.md        # Phase 1 output (/speckit-plan command)
+├── contracts/
+│   └── push.md          # 推送 payload 契约(Phase 1 output)
+└── tasks.md             # Phase 2 output (/speckit-tasks command - NOT created by /speckit-plan)
+```
+
+### Source Code (repository root)
+
+```text
+ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/
+└── service/
+    ├── FlashDeliveryApplicationService.java   # 修改:构造器 + 5 个方法的成功路径加通知钩子
+    └── FlashDeliveryNotificationService.java  # 新增:推送路由(afterCommit + AsyncManager + PayPush)
+
+ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/
+└── service/
+    ├── FlashDeliveryApplicationServiceTest.java       # 修改:构造器新参 + 推送触发/不触发断言
+    └── FlashDeliveryNotificationServiceTest.java      # 新增:接收人判定与跳过逻辑
+```
+
+**Structure Decision**: 完全落在闪送模块既有目录结构内,不新增包、不动 `ruoyi-system`、不动 Controller 层。
+
+## 关键设计点(供 tasks 生成引用)
+
+1. **通知服务 API**:`notifyAccepted(order)` / `notifyPickedUp(order)` / `notifyDelivered(order)` / `notifyCancelled(order)`;入参用事务前读到的 `FlashDeliveryOrder`(只消费 orderNo/userId/riderId 等不可变字段,不依赖其 status 快照)。
+2. **钩子位置**:
+   - `accept`:`withLock` 内 `writeLog(...)` 之后、`return` 之前;
+   - `pickup`/`deliver`:共用 `transitionWithProof` 的 `writeLog(...)` 之后,按 `next` 参数分派(PICKED_UP/DELIVERED);
+   - `userCancel`/`adminCancel`:`writeLog(...)` 之后,`notifyCancelled` 内部判 `riderId` 判空。
+3. **构造器**:`FlashDeliveryApplicationService` 追加第 10 个依赖 `FlashDeliveryNotificationService`;同步更新既有测试的构造调用。
+4. **发送实现**:`runAfterCommit` → `userMapper.selectById` 取 cid → `AsyncManager.me().execute` → `PayPush.userPushHandleLocal / qsPushHandleLocal`(title=`no.message.push.message`,content key 见 research.md D2,body=`OrderPushBodyDto.getJson(orderNo, status, -1, 3)`)。
+5. **不触发清单**(对应 FR-010):`create`、`tipAdd`、`complete`、`FlashDeliveryAutoCompleteTask`、`cancelExpiredScheduledOrder` 一律不调用通知服务。
+
+## Complexity Tracking
+
+> 无 Constitution Check 违规,本节为空。

+ 41 - 0
specs/028-flash-delivery-push/quickstart.md

@@ -0,0 +1,41 @@
+# Quickstart: 闪送订单状态变更消息推送
+
+**Date**: 2026-09-17 | **Spec**: [spec.md](spec.md)
+
+## 前置条件
+
+- JDK 21 构建(本机用 `C:\Users\qmj\.jdks\graalvm-jdk-21.0.7`,仅命令内临时设置 `JAVA_HOME`/`PATH`,不改全局)。
+- 无需数据库变更、无需新增 i18n key;服务端部署后即生效。
+
+## 自动验证(单测)
+
+```bash
+# 仓库根目录,Git Bash
+export JAVA_HOME="C:/Users/qmj/.jdks/graalvm-jdk-21.0.7"
+export PATH="$JAVA_HOME/bin:$PATH"
+mvn -pl ruoyi-admin -am test -Dtest='FlashDeliveryNotificationServiceTest,FlashDeliveryApplicationServiceTest'
+```
+
+预期:全部通过。覆盖点:
+
+- 三类履约事件(抢单/取件/送达)成功时调用对应用户推送;操作失败(条件更新 0 行)不推送。
+- 用户/平台取消且 `riderId` 非空时调用骑手推送;`riderId` 为空跳过。
+- 排除事件(complete、cancelExpiredScheduledOrder、加小费)零推送调用。
+
+## 手动端到端验证(联调环境)
+
+1. 用户 App(寄件人)与骑手 App 各登录一个测试账号;骑手账号具备闪送(FLASH)配送资质。
+2. 寄件人创建立即单(货到付款或沙箱支付),记下 `orderNo`。
+3. 骑手在任务列表抢单 → 寄件人应收到「骑手已接单,NO:{orderNo}」推送,消息中心出现对应记录。
+4. 骑手提交取件凭证 → 寄件人收到「骑手配送中,NO:{orderNo}」。
+5. 骑手提交送达凭证(含 PIN 时输入 PIN)→ 寄件人收到「骑手已送达,NO:{orderNo}」。
+6. 另起一单:骑手接单后由寄件人取消 → 骑手收到「订单已取消,NO:{orderNo}」;寄件人不收到取消推送。
+7. 再起一单:骑手接单后由平台后台取消 → 骑手收到取消推送。
+8. 反例:待接单状态直接平台取消(无人接单)→ 无任何骑手推送。
+9. 反例:等待超过自动完成时限(或平台手动完成)→ 用户无「已完成」推送(FR-010)。
+10. 切换寄件人 App 语言(vi/en/tw/zh)重复第 3 步 → 推送文案语言随之变化。
+
+## 判定
+
+- 第 3~7 步全部按期收到、第 8~9 步确实无推送、第 10 步语言正确 → 特性验证通过(对应 spec SC-001~SC-003)。
+- 推送期间人为停掉推送云函数 → 订单操作仍全部成功(SC-004 零传导)。

+ 78 - 0
specs/028-flash-delivery-push/research.md

@@ -0,0 +1,78 @@
+# Research: 闪送订单状态变更消息推送
+
+**Date**: 2026-09-17 | **Spec**: [spec.md](spec.md)
+
+研究方式:直接阅读现有代码与 i18n 资源(无外部依赖需要调研)。所有决策均基于仓库内已验证的事实。
+
+## D1: 通知服务的形态与放置
+
+**Decision**: 新建 `FlashDeliveryNotificationService`(`ruoyi-admin` 的 `com.ruoyi.app.flashdelivery.service` 包),完整镜像 `DeliveryOrderNotificationService` 的模式:`TransactionSynchronizationManager` 的 afterCommit 回调 + `AsyncManager.me().execute(TimerTask)` 异步执行 + `PayPush.userPushHandleLocal / qsPushHandleLocal` 发送。
+
+**Rationale**:
+- `DeliveryOrderNotificationService`(`ruoyi-admin/.../order/DeliveryOrderNotificationService.java`)是外送订单推送的现行实现,模式经过生产验证。
+- afterCommit 保证只在事务提交后触发(满足 FR-011:状态变更实际成功才推送);无事务同步时直接执行(便于测试)。
+- AsyncManager 异步执行保证推送 HTTP 调用不阻塞、失败不上抛(满足 FR-009)。
+- `PayPush.*HandleLocal` 系列封装了 locale 解析、`MessageUtils.message(...)` 多语言渲染、`PublisherEvent` 入库(满足 FR-006/FR-008)与 cid 判空跳过。
+- 放 `ruoyi-admin` 符合 CLAUDE.md 模块边界(依赖外部 HTTP 的集成代码不进 ruoyi-system)。
+
+**Alternatives considered**:
+- 在 Controller 层发推送:现有闪存 Controller 无业务逻辑,且 accept 等操作在应用服务锁内完成,Controller 拿不到可靠的成功信号;放弃。
+- 直接在 `FlashDeliveryApplicationService` 里调 PayPush:把推送基础设施混进 1200+ 行的应用服务,且无法单测路由;放弃。
+- Spring `@TransactionalEventListener`:项目无此用法先例,与现有模式不一致;放弃。
+
+## D2: 推送文案——复用现有 i18n key,不新增
+
+**Decision**: 全部复用现有 key(已验证 4 个 key × 6 个语言文件全部齐备,含 th_TH):
+
+| 事件 | title key | content key | zh_CN 文案 |
+|------|-----------|-------------|-----------|
+| 骑手抢单 (→ACCEPTED) | `no.message.push.message` | `no.message.push.delivery.personnel.receiving.order` | 骑手已接单 |
+| 骑手取件 (→PICKED_UP) | `no.message.push.message` | `no.message.push.delivery.personnel.qspsz.order` | 骑手配送中 |
+| 骑手送达 (→DELIVERED) | `no.message.push.message` | `no.message.push.delivery.personnel.qsysd.order` | 骑手已送达 |
+| 订单取消 (→CANCELLED) | `no.message.push.message` | `no.message.push.order.cancelled` | 订单已取消 |
+
+**Rationale**:
+- 语义与闪送场景一一对应,且与外卖订单推送文案保持一致(同一 App 内统一体验)。
+- 6 个文件(messages.properties=vi 默认、vi、zh_CN、zh_TW、en_US、th_TH)均已有这些 key,i18n 工作量为零,规避漏翻风险。
+- `PayPush.*HandleLocal` 会在 content 后拼接 `,NO:{订单号}`(满足 FR-007)。
+
+**Alternatives considered**: 新增 `flash.delivery.push.*` 专属 key(如"閃送騎手已取件"):文案可更精确区分闪送与外卖,但要新增 4×6=24 条翻译且收益有限;如后续需要区分再追加。
+
+## D3: 推送 payload——复用 OrderPushBodyDto,pushType 新增 3=闪送
+
+**Decision**: payload 用 `OrderPushBodyDto.getJson(orderNo, status, -1, 3)`:`ddId` 字段承载闪送 `orderNo`(形如 `FD` + 24 位),`state` 承载状态枚举字符串(如 `ACCEPTED`),`type=-1`(非语音提醒),`pushType=3` 标识闪送订单(现有取值 1=外卖、2=充值)。
+
+**Rationale**: 客户端已按 `OrderPushBodyDto` 的形状解析 payload 并按 `pushType` 路由;复用形状让客户端只需新增一个 pushType 分支。`orderNo` 是面向用户的订单标识(与外卖 `ddId` 同角色)。
+
+**Alternatives considered**: 新建 `FlashDeliveryPushBodyDto`:字段更语义化但客户端要适配两种形状;放弃。
+
+## D4: 各事件的接收人判定
+
+**Decision**:
+- ACCEPTED / PICKED_UP / DELIVERED → 寄件用户 `FlashDeliveryOrder.userId`(`userPushHandleLocal`)。
+- CANCELLED → `order.riderId != null` 时推骑手(`qsPushHandleLocal`),否则跳过。
+- 用户取消按状态机只可能发生在 WAITING_ACCEPTANCE/ACCEPTED;发生在 ACCEPTED 时必有骑手。平台取消可发生在任意非终态,WAITING_ACCEPTANCE 时 riderId 为空自然跳过(spec 边界场景 3)。
+
+**Rationale**: 与 spec FR-001~FR-005 一一对应;riderId 判空即覆盖"未接单取消不推骑手"。
+
+## D5: 用户信息获取
+
+**Decision**: 通知服务注入 `InfoUserMapper`,用 `selectById` 取 `cid`。
+
+**Rationale**: 闪送模块(`FlashDeliveryApplicationService`)现用 `InfoUserMapper` 而非 `IInfoUserService`,模块内保持一致。
+
+## D6: 测试策略
+
+**Decision**: 镜像 `DeliveryOrderNotificationServiceTest` 的手法:
+- 通知服务单测:`spy` 服务 + `doNothing` 打断包级可见的 send 方法(其内部是真实 HTTP 调用,不可单测),验证接收人判定与跳过逻辑;无事务同步时 `runAfterCommit` 同步执行,无需 Spring 上下文。
+- 应用服务单测:mock `FlashDeliveryNotificationService` 注入,verify 各状态流转方法成功路径调用对应 notify、失败路径与排除事件(complete、cancelExpiredScheduledOrder、加小费)`never()` 调用。仓库已有 `MockedStatic` 先例(`FlashDeliveryApplicationServiceTest`)可参考构造方式。
+
+**Rationale**: 与项目现有测试风格完全一致,不引入新测试设施。
+
+## D7: 消息入库与无 cid 行为
+
+**Decision**: 不额外开发——`PayPush.userPush/qsPush` 内部先 `pushEventService.PublisherEvent(...)`(写 `push_message`,即 FR-008 的消息记录)再判 cid;cid 为空只记 error 日志并跳过云端推送。两个行为均由复用通道免费获得。
+
+## 结论
+
+无 NEEDS CLARIFICATION 遗留;无新表、无新 i18n key、无新接口端点。实现落点集中在:1 个新通知服务 + `FlashDeliveryApplicationService` 5 个方法内的钩子调用 + 构造器新增依赖 + 测试。

+ 103 - 0
specs/028-flash-delivery-push/spec.md

@@ -0,0 +1,103 @@
+# Feature Specification: 闪送订单状态变更消息推送
+
+**Feature Branch**: `028-flash-delivery-push`
+
+**Created**: 2026-09-17
+
+**Status**: Draft
+
+**Input**: User description: "闪送订单状态变更消息推送:骑手抢单、已取件、已送达推送给用户端;用户/平台取消推送给骑手端。新任务发布(待抢单)不推送,加小费不推送。"
+
+## User Scenarios & Testing *(mandatory)*
+
+### User Story 1 - 寄件用户收到骑手接单通知 (Priority: P1)
+
+寄件用户下单后处于等待状态,骑手抢单成功的瞬间,用户收到一条推送,告知订单已被骑手接单、骑手正在前来取件。
+
+**Why this priority**: 用户下单后最焦虑的时段是"有没有人接单";接单通知是整个推送体系里价值最高的单条消息,没有它用户只能反复刷新订单页。
+
+**Independent Test**: 创建一笔待接单的闪送订单,骑手账号执行抢单,验证寄件用户收到"骑手已接单"推送。
+
+**Acceptance Scenarios**:
+
+1. **Given** 订单状态为 WAITING_ACCEPTANCE,**When** 骑手抢单成功(状态转为 ACCEPTED),**Then** 寄件用户在短时间内收到推送,内容表明骑手已接单并包含订单标识。
+2. **Given** 抢单因并发失败(订单已被他人抢走或已取消),**When** 抢单请求落空,**Then** 不产生任何推送。
+
+---
+
+### User Story 2 - 寄件用户收到取件与送达通知 (Priority: P1)
+
+履约中段,骑手确认取件、确认送达两个节点,寄件用户分别收到推送,掌握物品在途与签收进度。
+
+**Why this priority**: 与接单通知共同构成完整的履约进度感知;送达通知同时提示用户可去确认收货。
+
+**Independent Test**: 对一笔已接单订单依次执行取件(附凭证)、送达(附凭证),验证寄件用户先后收到两条对应推送。
+
+**Acceptance Scenarios**:
+
+1. **Given** 订单状态为 ACCEPTED,**When** 骑手确认取件成功(状态转为 PICKED_UP),**Then** 寄件用户收到"骑手已取件"推送。
+2. **Given** 订单状态为 PICKED_UP,**When** 骑手确认送达成功(状态转为 DELIVERED),**Then** 寄件用户收到"已送达"推送。
+3. **Given** 取件或送达操作因校验失败未改变订单状态,**When** 操作报错返回,**Then** 不产生推送。
+
+---
+
+### User Story 3 - 骑手收到订单取消通知 (Priority: P2)
+
+订单被取消时,已接单的骑手收到推送,及时停止履约,避免白跑。
+
+**Why this priority**: 取消是低频事件,但漏通知会导致骑手空跑、产生纠纷;相比履约进度通知优先级略低。
+
+**Independent Test**: 对一笔已接单订单分别走用户取消、平台取消两条路径,验证骑手收到取消推送;对未接单订单执行取消,验证不产生骑手推送。
+
+**Acceptance Scenarios**:
+
+1. **Given** 订单已被骑手接单(ACCEPTED/PICKED_UP),**When** 寄件用户取消订单成功(状态转为 CANCELLED),**Then** 该骑手收到"订单已取消"推送,内容含订单标识。
+2. **Given** 订单已被骑手接单,**When** 平台取消订单成功,**Then** 该骑手收到取消推送。
+3. **Given** 订单尚未被任何骑手接单(WAITING_ACCEPTANCE,含过期预约单自动取消),**When** 订单被取消,**Then** 不产生骑手推送(无推送对象)。
+4. **Given** 用户取消请求因状态校验失败,**When** 取消未生效,**Then** 不产生推送。
+
+### Edge Cases
+
+- 接收方没有有效的设备推送标识(未登录过 App / 标识为空)时:跳过云端推送,但消息记录仍正常生成,且不影响订单操作结果。
+- 推送服务调用失败或超时:只记录日志,不重试阻断,订单状态流转结果不受影响。
+- 接收方语言设置为越南语/繁中/英语/简中之外或未设置:按平台默认语言回落(与现有订单推送行为一致)。
+- 骑手抢单与用户取消并发(一方成功另一方必然失败):只对实际改变了订单状态的一方产生推送。
+- 平台连续取消多笔订单:每笔独立推送,互不影响。
+
+## Requirements *(mandatory)*
+
+### Functional Requirements
+
+- **FR-001**: 骑手抢单成功(WAITING_ACCEPTANCE → ACCEPTED)后,系统 MUST 向寄件用户发送"骑手已接单"推送通知。
+- **FR-002**: 骑手确认取件成功(ACCEPTED → PICKED_UP)后,系统 MUST 向寄件用户发送"骑手已取件"推送通知。
+- **FR-003**: 骑手确认送达成功(PICKED_UP → DELIVERED)后,系统 MUST 向寄件用户发送"已送达"推送通知。
+- **FR-004**: 寄件用户取消订单成功(状态转为 CANCELLED)且订单已有接单骑手时,系统 MUST 向该骑手发送"订单已取消"推送通知。
+- **FR-005**: 平台取消订单成功且订单已有接单骑手时,系统 MUST 向该骑手发送"订单已取消"推送通知。
+- **FR-006**: 所有推送文案 MUST 支持四种语言(vi、zh、tw、en),按接收方用户的语言设置呈现。
+- **FR-007**: 每条推送内容 MUST 包含订单标识(订单号),使接收方无需打开 App 搜索即可定位订单。
+- **FR-008**: 每条推送 MUST 同步生成消息记录(复用现有消息中心机制),用户/骑手可在消息列表中回看。
+- **FR-009**: 推送环节的任何失败(无设备标识、推送服务异常)MUST NOT 影响订单状态流转本身,也 MUST NOT 向操作方返回错误。
+- **FR-010**: 以下事件明确不推送:新订单发布(待抢单)不向骑手推送;加小费不产生推送;超时自动完成(DELIVERED → COMPLETED)不推送;过期预约单自动取消不推送。
+- **FR-011**: 推送 MUST 仅在对应状态变更实际成功后触发;操作失败或未改变状态时不得产生推送。
+
+### Key Entities *(include if feature involves data)*
+
+- **推送通知(Push Notification)**: 一次状态变更事件产生的一条通知,属性包括接收方(寄件用户或接单骑手)、事件类型(接单/取件/送达/取消)、标题与正文(按语言)、关联订单标识;复用现有消息记录实体落地,不新增独立数据模型。
+
+## Success Criteria *(mandatory)*
+
+### Measurable Outcomes
+
+- **SC-001**: 任一在推送范围内的事件(抢单、取件、送达、取消)成功发生后,接收方在 10 秒内收到推送(推送服务正常的前提下)。
+- **SC-002**: 四种语言设置的用户各抽检一批推送,文案语言与用户设置一致的比例为 100%。
+- **SC-003**: 抽检的全部推送均含订单号,接收方可凭推送内容直接定位订单,达标率 100%。
+- **SC-004**: 推送服务整体不可用时,抢单/取件/送达/取消接口的成功率与推送上线前持平(推送故障零传导)。
+
+## Assumptions
+
+- 复用项目现有推送通道(App 端云函数推送 + 消息入库),不新建推送基础设施,不新增 Android 通道。
+- 推送在订单状态变更成功后异步触发,不放在订单操作的事务内。
+- 平台取消按需求仅推送骑手端;寄件用户通过订单列表感知取消结果,如需同时通知用户可后续追加需求。
+- 超时自动完成、过期预约单自动取消、新单发布、加小费均在范围外(FR-010)。
+- 推送点击后的客户端跳转(payload 路由到闪送订单详情页)由客户端处理;服务端只保证推送携带订单标识与业务类型。
+- 报文文案沿用现有订单推送的文案风格(简短状态描述 + 订单号后缀)。

+ 155 - 0
specs/028-flash-delivery-push/tasks.md

@@ -0,0 +1,155 @@
+---
+description: "Task list for feature 028-flash-delivery-push"
+---
+
+# Tasks: 闪送订单状态变更消息推送
+
+**Input**: Design documents from `/specs/028-flash-delivery-push/`
+
+**Prerequisites**: plan.md (required), spec.md (required for user stories), research.md, data-model.md, contracts/push.md
+
+**Tests**: quickstart.md「自动验证」明确要求单测(`FlashDeliveryNotificationServiceTest` 新建 + `FlashDeliveryApplicationServiceTest` 扩展),故包含测试任务且先行(红→绿)。
+
+**Organization**: Tasks are grouped by user story to enable independent implementation and testing of each story.
+
+## Format: `[ID] [P?] [Story] Description`
+
+- **[P]**: Can run in parallel (different files, no dependencies)
+- **[Story]**: Which user story this task belongs to (e.g., US1, US2, US3)
+- Include exact file paths in descriptions
+
+## Path Conventions
+
+- 主代码:`ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/`
+- 测试:`ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/`
+- 构建命令(Git Bash):`export JAVA_HOME="C:/Users/qmj/.jdks/graalvm-jdk-21.0.7" && export PATH="$JAVA_HOME/bin:$PATH" && mvn -pl ruoyi-admin -am test -Dtest='...'`
+
+---
+
+## Phase 1: Setup
+
+**Purpose**: 确认基线全绿,避免把存量问题带进本特性
+
+- [x] T001 运行既有闪送单测确认基线全绿:`mvn -pl ruoyi-admin -am test -Dtest='FlashDeliveryApplicationServiceTest,FlashDeliveryStateMachineTest,FlashDeliveryControllerContractTest'`(JDK21 环境变量见 Path Conventions)——基线 79 测试 1 存量失败(`createRejectsUnsupportedPayType`,源于在途 payType 白名单注释改动,非本特性范围,全程保持不新增失败即可)
+
+---
+
+## Phase 2: Foundational (Blocking Prerequisites)
+
+**Purpose**: 通知服务基础设施 + 应用服务装配,所有 user story 依赖
+
+- [x] T002 新建 `ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryNotificationService.java`:`@Service`;构造器注入 `InfoUserMapper`(`com.ruoyi.system.mapper`)与 `PushEventService`(`com.ruoyi.app.utils.event`);私有 `runAfterCommit(Runnable)`(镜像 `DeliveryOrderNotificationService.java:156-167`);包级可见 `sendToUser(FlashDeliveryOrder, String contentKey)` / `sendToRider(FlashDeliveryOrder, String contentKey)`:`runAfterCommit` → `userMapper.selectById` 取 `InfoUser` → `AsyncManager.me().execute(TimerTask)` → `PayPush.userPushHandleLocal` / `qsPushHandleLocal`,title 固定 `no.message.push.message`,body=`OrderPushBodyDto.getJson(order.getOrderNo(), 状态串, -1, 3)`,ddId=`order.getOrderNo()`;推送异常捕获仅记日志不上抛
+- [x] T003 `FlashDeliveryApplicationService.java:76-94` 构造器追加第 10 个依赖 `FlashDeliveryNotificationService` 并存字段;全仓更新 `new FlashDeliveryApplicationService(` 调用点(`ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryApplicationServiceTest.java` 等,先 grep 定位)补 mock 参数;编译并运行 T001 测试集保持全绿
+
+**Checkpoint**: 通知服务骨架就绪且零行为变化(尚无 notify 入口被调用)
+
+---
+
+## Phase 3: User Story 1 - 寄件用户收到骑手接单通知 (Priority: P1) 🎯 MVP
+
+**Goal**: 骑手抢单成功后,寄件用户收到「骑手已接单,NO:{orderNo}」推送与消息中心记录
+
+**Independent Test**: 对一笔 WAITING_ACCEPTANCE 订单执行 `accept` 成功 → 验证 `notifyAccepted` 被调用;抢单失败(条件更新 0 行)→ 不调用
+
+### Tests for User Story 1(先行,跑红)
+
+- [x] T004 [US1] 新建 `ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryNotificationServiceTest.java`:spy 服务 + `doNothing` 打断 `sendToUser`/`sendToRider`(镜像 `DeliveryOrderNotificationServiceTest.java` 手法),断言 `notifyAccepted(order)` 走 `sendToUser(order, "no.message.push.delivery.personnel.receiving.order")`;同时在 `FlashDeliveryApplicationServiceTest.java` 增补:accept 成功路径 verify `notifyAccepted`、`orderMapper.accept` 返回 0 时 never 调用
+
+### Implementation for User Story 1
+
+- [x] T005 [US1] 在 `FlashDeliveryNotificationService.java` 实现 `notifyAccepted(FlashDeliveryOrder)`(runAfterCommit 后推 userId,状态串 `ACCEPTED`);在 `FlashDeliveryApplicationService.java` `accept`(约 :485 `writeLog` 之后、`return` 之前,`withLock` lambda 内)调用 `notificationService.notifyAccepted(target)`
+- [x] T006 [US1] 运行 `mvn -pl ruoyi-admin -am test -Dtest='FlashDeliveryNotificationServiceTest,FlashDeliveryApplicationServiceTest'` 确认由红转绿
+
+**Checkpoint**: MVP——抢单通知可独立演示(quickstart.md 手动场景 3)
+
+---
+
+## Phase 4: User Story 2 - 寄件用户收到取件与送达通知 (Priority: P1)
+
+**Goal**: 骑手取件、送达成功后,寄件用户分别收到「骑手配送中」「骑手已送达」推送
+
+**Independent Test**: 对 ACCEPTED 订单 `pickup` 成功 → `notifyPickedUp`;对 PICKED_UP 订单 `deliver` 成功 → `notifyDelivered`;任一失败 → 不调用
+
+### Tests for User Story 2(先行,跑红)
+
+- [x] T007 [US2] `FlashDeliveryNotificationServiceTest.java` 增补:`notifyPickedUp` 走 `sendToUser(order, "no.message.push.delivery.personnel.qspsz.order")`、`notifyDelivered` 走 `sendToUser(order, "no.message.push.delivery.personnel.qsysd.order")`;`FlashDeliveryApplicationServiceTest.java` 增补:`transitionWithProof` 成功按 next 分派对应 notify、`orderMapper.transitionByRider` 返回 0 时两者均 never
+
+### Implementation for User Story 2
+
+- [x] T008 [US2] `FlashDeliveryNotificationService.java` 实现 `notifyPickedUp` / `notifyDelivered`(状态串 `PICKED_UP` / `DELIVERED`);`FlashDeliveryApplicationService.java` `transitionWithProof`(约 :781 `writeLog` 之后)按 `next` 参数分派调用(仅 PICKED_UP/DELIVERED 两值,其它忽略)
+- [x] T009 [US2] 运行 Story 2 测试集确认由红转绿
+
+**Checkpoint**: 履约进度通知完整(quickstart.md 手动场景 4、5)
+
+---
+
+## Phase 5: User Story 3 - 骑手收到订单取消通知 (Priority: P2)
+
+**Goal**: 用户/平台取消成功且已有接单骑手时,骑手收到「订单已取消,NO:{orderNo}」推送;未接单取消零推送
+
+**Independent Test**: ACCEPTED 状态 `userCancel`/`adminCancel` 成功 → `notifyCancelled`;WAITING_ACCEPTANCE 取消(riderId 空)→ 跳过;取消失败 → 不调用
+
+### Tests for User Story 3(先行,跑红)
+
+- [x] T010 [US3] `FlashDeliveryNotificationServiceTest.java` 增补:`notifyCancelled(order)` 在 riderId 非空时走 `sendToRider(order, "no.message.push.order.cancelled")`、riderId 为空时零交互;`FlashDeliveryApplicationServiceTest.java` 增补:`userCancel`/`adminCancel` 成功路径 verify `notifyCancelled`、`orderMapper.cancel` 返回 0 时 never
+
+### Implementation for User Story 3
+
+- [x] T011 [US3] `FlashDeliveryNotificationService.java` 实现 `notifyCancelled`(riderId 判空跳过,状态串 `CANCELLED`,推骑手端);`FlashDeliveryApplicationService.java` 在 `userCancel`(约 :397)与 `adminCancel`(约 :622)的 `writeLog` 之后调用
+- [x] T012 [US3] 运行 Story 3 测试集确认由红转绿
+
+**Checkpoint**: 取消通知完整(quickstart.md 手动场景 6、7、8)
+
+---
+
+## Phase 6: Polish & Cross-Cutting Concerns
+
+**Purpose**: 排除事件防回归 + 全量验证
+
+- [x] T013 反例断言(FR-010):`FlashDeliveryApplicationServiceTest.java` 对 `complete`、`cancelExpiredScheduledOrder`、`tipAdd`、`create` 增加 notificationService 零交互(`verifyNoInteractions` 或 `never`)断言;`FlashDeliveryAutoCompleteTaskTest.java` 如涉及应用服务 mock 一并核对
+- [x] T014 全量回归:`mvn -pl ruoyi-admin -am test` 455 测试仅 1 个已知存量失败(`createRejectsUnsupportedPayType`,在途 payType 特性所致);编译通过;git status 确认本特性改动仅 4 个代码文件(2 新建 2 增量)+ specs/028 + CLAUDE.md + feature.json,其余为并行在途特性(预约单窗口)的既有改动`mvn -pl ruoyi-admin -am test`(全部模块测试)+ `mvn -pl ruoyi-admin -am compile` 通过;grep 确认闪送模块外无文件改动(`git status` 仅闪送相关文件与 specs/028)
+- [x] T015 快照核对:本特性无 `updatesql/sql.md` 新条目(零数据库变更)、无 i18n 文件改动(零新 key,全部复用 `no.message.push.*`)、`ruoyi-system` 零改动;工作树中 i18n/sql.md/system 变更均属并行在途特性,与 028 无关确认无 `updatesql/sql.md` 新条目需求(零数据库变更)、无 i18n 文件改动(零新 key)、`ruoyi-system` 零改动;如有偏差回填 `specs/028-flash-delivery-push/research.md`
+
+---
+
+## Dependencies & Execution Order
+
+### Phase Dependencies
+
+- **Setup (Phase 1)**: 无依赖,立即开始
+- **Foundational (Phase 2)**: 依赖 Phase 1;**阻塞全部 user story**(T003 之后才能写各 story 的钩子与 verify 断言)
+- **User Stories (Phase 3-5)**: 依赖 Phase 2;三个 story 共享同一对文件(`FlashDeliveryApplicationService.java` 与两个测试类),**必须按 US1 → US2 → US3 顺序串行执行**,不可并行
+- **Polish (Phase 6)**: 依赖 Phase 3-5 全部完成
+
+### Within Each User Story
+
+- 测试任务先行并跑红 → 实现任务 → 验证任务跑绿
+- 每 story 收尾可独立演示(见各 Checkpoint 对应 quickstart.md 场景)
+
+### Parallel Opportunities
+
+- 本特性文件高度集中,仅 T002/T003 与测试内容天然串行;无安全并行窗口(除 Phase 1 与文档工作)
+
+---
+
+## Implementation Strategy
+
+### MVP First (User Story 1 Only)
+
+1. Phase 1 基线 → Phase 2 基础设施(零行为变化)
+2. Phase 3 US1:抢单通知(最高价值单条消息)
+3. **STOP and VALIDATE**:quickstart.md 场景 3 手动验证 + 单测绿
+4. 继续 US2(取件/送达)、US3(取消)
+
+### Incremental Delivery
+
+每个 story 完成即独立可测、可部署的增量;US1 单独上线即可解决"用户不知是否被接单"的核心痛点。
+
+---
+
+## Notes
+
+- [P] 标记:本特性因文件集中,实际无可并行任务
+- 提交节奏:每 Phase 完成后提交一次(`/speckit-git-commit`)
+- 遵守 plan.md 关键设计点 1-5(不可变字段消费、钩子位置、pushType=3、排除清单)
+- Java 注释不得含 `*/` 序列;保留 CRLF;不格式化整文件