Răsfoiți Sursa

feat(push): attach user scenes to delivery notifications

qmj 1 săptămână în urmă
părinte
comite
a67107db4e

+ 18 - 7
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryNotificationService.java

@@ -3,6 +3,7 @@ package com.ruoyi.app.flashdelivery.service;
 import com.ruoyi.app.order.dto.OrderPushBodyDto;
 import com.ruoyi.app.push.BusinessPushProperties;
 import com.ruoyi.app.push.NativeRiderBatchPushService;
+import com.ruoyi.app.push.UserNotificationScene;
 import com.ruoyi.app.utils.PayPush;
 import com.ruoyi.app.utils.event.PushEventService;
 import com.ruoyi.common.core.domain.entity.SysDictData;
@@ -89,10 +90,12 @@ public class FlashDeliveryNotificationService {
     }
 
     /** 向寄件用户推送指定状态文案;消息入库由通道内 PublisherEvent 完成,cid 为空时仅入库。 */
-    void sendToUser(InfoUser recipient, FlashDeliveryOrder order, String status, String contentKey) {
+    void sendToUser(InfoUser recipient, FlashDeliveryOrder order, String status, String contentKey,
+                    UserNotificationScene scene) {
         if (recipient == null || recipient.getUserId() == null) return;
         String orderNo = order.getOrderNo();
-        String body = OrderPushBodyDto.getJson(orderNo, status, PUSH_PAYLOAD_TYPE_NONE, PUSH_TYPE_FLASH);
+        // 仅用户履约消息带场景;保持闪送 pushType=3 与原有 type/state。
+        String body = OrderPushBodyDto.getUserJson(orderNo, status, PUSH_PAYLOAD_TYPE_NONE, PUSH_TYPE_FLASH, scene);
         AsyncManager.me().execute(new TimerTask() {
             @Override
             public void run() {
@@ -168,7 +171,7 @@ public class FlashDeliveryNotificationService {
     public void notifyAccepted(FlashDeliveryOrder order) {
         runAfterCommit(() -> {
             InfoUser sender = senderOf(order);
-            if (sender != null) sendToUser(sender, order, ACCEPTED, CONTENT_ACCEPTED);
+            if (sender != null) sendToUser(sender, order, ACCEPTED, CONTENT_ACCEPTED, UserNotificationScene.RIDER_ACCEPTED);
         });
     }
 
@@ -176,7 +179,7 @@ public class FlashDeliveryNotificationService {
     public void notifyPickedUp(FlashDeliveryOrder order) {
         runAfterCommit(() -> {
             InfoUser sender = senderOf(order);
-            if (sender != null) sendToUser(sender, order, PICKED_UP, CONTENT_PICKED_UP);
+            if (sender != null) sendToUser(sender, order, PICKED_UP, CONTENT_PICKED_UP, UserNotificationScene.ORDER_DELIVERING);
         });
     }
 
@@ -184,7 +187,7 @@ public class FlashDeliveryNotificationService {
     public void notifyDelivered(FlashDeliveryOrder order) {
         runAfterCommit(() -> {
             InfoUser sender = senderOf(order);
-            if (sender != null) sendToUser(sender, order, DELIVERED, CONTENT_DELIVERED);
+            if (sender != null) sendToUser(sender, order, DELIVERED, CONTENT_DELIVERED, UserNotificationScene.ORDER_DELIVERED);
         });
     }
 
@@ -198,14 +201,22 @@ public class FlashDeliveryNotificationService {
 
     /** 仅事务提交后执行推送准备;无事务同步时(单测)直接执行。 */
     static void runAfterCommit(Runnable action) {
+        // 查询收件人或投递异步任务失败同样属于通知失败,不改变已成功的履约结果。
+        Runnable safeAction = () -> {
+            try {
+                action.run();
+            } catch (Exception exception) {
+                log.error("闪送通知准备失败", exception);
+            }
+        };
         if (!TransactionSynchronizationManager.isSynchronizationActive()) {
-            action.run();
+            safeAction.run();
             return;
         }
         TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
             @Override
             public void afterCommit() {
-                action.run();
+                safeAction.run();
             }
         });
     }

+ 14 - 2
ruoyi-admin/src/main/java/com/ruoyi/app/order/PosOrderController.java

@@ -54,6 +54,7 @@ import com.ruoyi.system.utils.AuthContext;
 import com.ruoyi.system.utils.GetArea;
 import com.ruoyi.system.utils.JwtUtil;
 import com.ruoyi.system.utils.OrderLogHelper;
+import com.ruoyi.app.push.UserNotificationScene;
 import com.ruoyi.common.utils.SecurityUtils;
 import com.ruoyi.common.core.domain.entity.SysUser;
 import lombok.SneakyThrows;
@@ -379,7 +380,9 @@ public class PosOrderController extends BaseController {
                                     lock.unlock();
                                 }
                                 // 给用户推送(骑手已接单/配送中/已送达)
-                                if (businessPushService.addressKey(user) != null) {
+                                try {
+                                // 无设备地址仍生成用户消息;商家/骑手保持原协议。
+                                if (user != null) {
                                     String userTitle = "no.message.push.message";
                                     String userContent = "no.message.push.delivery.personnel.receiving.order";
                                     if (posOrder.getState() == 4) {
@@ -388,7 +391,12 @@ public class PosOrderController extends BaseController {
                                         userContent = "no.message.push.delivery.personnel.qsysd.order";
                                     }
                                     final String finalUserContent = userContent;
-                                    String userBody = OrderPushBodyDto.getJson(String.valueOf(orst.getDdId()), String.valueOf(posOrder.getState()));
+                                    UserNotificationScene scene = Long.valueOf(4L).equals(posOrder.getState())
+                                            ? UserNotificationScene.ORDER_DELIVERING
+                                            : Long.valueOf(12L).equals(posOrder.getState())
+                                            ? UserNotificationScene.ORDER_DELIVERED : UserNotificationScene.RIDER_ACCEPTED;
+                                    String userBody = OrderPushBodyDto.getUserJson(String.valueOf(orst.getDdId()),
+                                            String.valueOf(posOrder.getState()), -1, 1, scene);
                                     Long userUserId = user.getUserId();
                                     String userCid = user.getCid();
                                     String userCidType = "";
@@ -414,6 +422,10 @@ public class PosOrderController extends BaseController {
                                         }
                                     });
                                 }
+                                } catch (Exception exception) {
+                                    // 仅隔离已提交业务的通知投递异常,不改变旧状态流转。
+                                    logger.error("骑手履约通知投递失败,ddId={}", orst.getDdId(), exception);
+                                }
                             }
                         });
                         return success(MessageUtils.message("no.modify.success"), posOrder.getId());

+ 14 - 2
ruoyi-admin/src/main/java/com/ruoyi/app/order/PosOrderQsOprateController.java

@@ -30,6 +30,7 @@ import com.ruoyi.system.service.IUserBillingService;
 import com.ruoyi.system.utils.Auth;
 import com.ruoyi.system.utils.JwtUtil;
 import com.ruoyi.system.utils.OrderLogHelper;
+import com.ruoyi.app.push.UserNotificationScene;
 import org.redisson.api.RLock;
 import org.redisson.api.RedissonClient;
 import org.springframework.beans.factory.annotation.Autowired;
@@ -228,7 +229,9 @@ public class PosOrderQsOprateController extends BaseController {
                                     lock.unlock();
                                 }
                                 // 给用户推送(骑手已接单/配送中/已送达)
-                                if (user != null && businessPushService.addressKey(user) != null) {
+                                try {
+                                // 设备地址只影响外发,不应阻断用户站内消息记录。
+                                if (user != null) {
                                     String userTitle = "no.message.push.message";
                                     String userContent = "no.message.push.delivery.personnel.receiving.order";
                                     if (posOrder.getDeliveryStatus() != null && posOrder.getDeliveryStatus() == 2L) {
@@ -237,7 +240,12 @@ public class PosOrderQsOprateController extends BaseController {
                                         userContent = "no.message.push.delivery.personnel.qsysd.order";
                                     }
                                     final String finalUserContent = userContent;
-                                    String userBody = OrderPushBodyDto.getJson(String.valueOf(orst.getDdId()), String.valueOf(orst.getState()));
+                                    UserNotificationScene scene = Long.valueOf(2L).equals(posOrder.getDeliveryStatus())
+                                            ? UserNotificationScene.ORDER_DELIVERING
+                                            : Long.valueOf(3L).equals(posOrder.getDeliveryStatus())
+                                            ? UserNotificationScene.ORDER_DELIVERED : UserNotificationScene.RIDER_ACCEPTED;
+                                    String userBody = OrderPushBodyDto.getUserJson(String.valueOf(orst.getDdId()),
+                                            String.valueOf(orst.getState()), -1, 1, scene);
                                     Long userUserId = user.getUserId();
                                     String userCid = user.getCid();
                                     String userCidType = "";
@@ -263,6 +271,10 @@ public class PosOrderQsOprateController extends BaseController {
                                         }
                                     });
                                 }
+                                } catch (Exception exception) {
+                                    // 订单已提交,异步投递异常不能让成功履约响应变成失败。
+                                    logger.error("骑手履约通知投递失败,ddId={}", orst.getDdId(), exception);
+                                }
                             }
                         });
                         return success(MessageUtils.message("no.modify.success"), posOrder.getId());

+ 92 - 54
ruoyi-admin/src/main/java/com/ruoyi/app/order/PosOrderShOprateController.java

@@ -31,6 +31,9 @@ import com.ruoyi.system.utils.AuthContext;
 import com.ruoyi.system.utils.JwtUtil;
 import com.ruoyi.system.domain.dto.InvalidInvoiceDto;
 import com.ruoyi.system.utils.OrderLogHelper;
+import com.ruoyi.app.push.UserNotificationScene;
+import org.springframework.transaction.support.TransactionSynchronization;
+import org.springframework.transaction.support.TransactionSynchronizationManager;
 import org.redisson.api.RLock;
 import org.redisson.api.RedissonClient;
 import org.springframework.beans.factory.annotation.Autowired;
@@ -335,13 +338,14 @@ public class PosOrderShOprateController extends BaseController {
         PosOrder update = new PosOrder();
         update.setId(order.getId());
         update.setState(1L);
-        posOrderService.saveOrUpdate(update);
+        boolean saved = posOrderService.saveOrUpdate(update);
         InfoUser shUser = infoUserService.getOne(new LambdaQueryWrapper<InfoUser>().eq(InfoUser::getUserId, Long.valueOf(new JwtUtil().getusid(token))));
         String shName = shUser != null ? shUser.getNickName() : "";
         orderLogHelper.log(String.valueOf(order.getDdId()), 2, Long.valueOf(new JwtUtil().getusid(token)), shName, "商家" + shName + "已接单");
         // 029 FR-009:自配送单无骑手接单环节,商家接单后向用户推送备餐通知(核验修复 2026-09-08)
-        if (Integer.valueOf(1).equals(order.getSelfDelivery())) {
-            pushSelfDeliveryStatus(order, "no.message.push.merchant.accepted.content", "1");
+        if (saved && Integer.valueOf(1).equals(order.getSelfDelivery())) {
+            pushSelfDeliveryStatus(order, "no.message.push.merchant.accepted.content", "1",
+                    UserNotificationScene.MERCHANT_ACCEPTED);
         }
         return AjaxResult.success();
     }
@@ -364,12 +368,15 @@ public class PosOrderShOprateController extends BaseController {
         PosOrder update = new PosOrder();
         update.setId(order.getId());
         update.setState(2L);
-        posOrderService.saveOrUpdate(update);
+        boolean saved = posOrderService.saveOrUpdate(update);
         InfoUser shUser = infoUserService.getOne(new LambdaQueryWrapper<InfoUser>().eq(InfoUser::getUserId, Long.valueOf(new JwtUtil().getusid(token))));
         String shName = shUser != null ? shUser.getNickName() : "";
         orderLogHelper.log(String.valueOf(order.getDdId()), 2, Long.valueOf(new JwtUtil().getusid(token)), shName, "商家" + shName + "已出餐");
         // 出餐推送
-        chuCan(order, update);
+        // 持久化失败不能通知“已出餐”;本次不改变原接口及发票处理的事务边界。
+        if (saved) {
+            chuCan(order, update);
+        }
         // 出餐自动开票(010 Phase 11):按订单发票意图自动开,异常不阻断出餐,失败单由运营后台重试
         try {
             orderInvoiceService.autoIssue(order.getId());
@@ -510,7 +517,9 @@ public class PosOrderShOprateController extends BaseController {
                 }
                 orderLogHelper.log(String.valueOf(order.getDdId()), 2, merchant.getUserId(),
                         merchant.getNickName(), logContent);
-                pushSelfDeliveryStatus(order, selfDeliveryPushContent(complete), selfDeliveryPushState(complete));
+                // 允许直接确认送达时只通知送达,不补造“开始配送”事件。
+                pushSelfDeliveryStatus(order, selfDeliveryPushContent(complete), selfDeliveryPushState(complete),
+                        complete ? UserNotificationScene.ORDER_DELIVERED : UserNotificationScene.ORDER_DELIVERING);
                 return success();
             } finally {
                 if (lock.isHeldByCurrentThread()) {
@@ -555,22 +564,46 @@ public class PosOrderShOprateController extends BaseController {
     /**
      * 自配送状态推送(029):仅推用户,无骑手链路;文案 key 与透传状态由调用方给定
      */
-    private void pushSelfDeliveryStatus(PosOrder order, String contentKey, String stateStr) {
-        InfoUser user = infoUserService.getById(order.getUserId());
-        if (user == null || businessPushService.addressKey(user) == null) {
+    private void pushSelfDeliveryStatus(PosOrder order, String contentKey, String stateStr,
+                                        UserNotificationScene scene) {
+        runNotificationAfterCommit(() -> {
+            InfoUser user = infoUserService.getById(order.getUserId());
+            if (user == null) {
+                return;
+            }
+            String title = "no.message.push.message";
+            String content = contentKey;
+            String body = OrderPushBodyDto.getUserJson(String.valueOf(order.getDdId()), stateStr, -1, 1, scene);
+            String ddId = String.valueOf(order.getDdId());
+            PayPush push = new PayPush();
+            Long uUserId = user.getUserId();
+            String uCid = user.getCid();
+            AsyncManager.me().execute(new TimerTask() {
+                @Override
+                public void run() {
+                    PayPush.userPushHandleLocal(push, pushEventService, uUserId, uCid, title, content, body, "", ddId);
+                }
+            });
+        });
+    }
+
+    /** 有事务时提交后通知,无事务时仅在成功落库后调用;通知准备失败不影响业务结果。 */
+    private void runNotificationAfterCommit(Runnable action) {
+        Runnable safeAction = () -> {
+            try {
+                action.run();
+            } catch (Exception exception) {
+                logger.error("商家订单通知准备失败", exception);
+            }
+        };
+        if (!TransactionSynchronizationManager.isSynchronizationActive()) {
+            safeAction.run();
             return;
         }
-        String title = "no.message.push.message";
-        String content = contentKey;
-        String body = OrderPushBodyDto.getJson(String.valueOf(order.getDdId()), stateStr);
-        String ddId = String.valueOf(order.getDdId());
-        PayPush push = new PayPush();
-        Long uUserId = user.getUserId();
-        String uCid = user.getCid();
-        AsyncManager.me().execute(new TimerTask() {
+        TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
             @Override
-            public void run() {
-                PayPush.userPushHandleLocal(push, pushEventService, uUserId, uCid, title, content, body, "", ddId);
+            public void afterCommit() {
+                safeAction.run();
             }
         });
     }
@@ -738,48 +771,53 @@ public class PosOrderShOprateController extends BaseController {
      * 商家出餐推送:给用户和骑手推送通知
      */
     public void chuCan(PosOrder oldOrder, PosOrder posOrder) {
-        List<Long> ids = new ArrayList<>();
-        ids.add(oldOrder.getUserId());
-        if (oldOrder.getQsId() != null) {
-            ids.add(oldOrder.getQsId());
-        }
-        List<InfoUser> users = infoUserService.list(new LambdaQueryWrapper<InfoUser>().in(InfoUser::getUserId, ids));
-        Optional<InfoUser> user = users.stream().filter(x -> "0".equals(x.getUserType())).findFirst();
-        Optional<InfoUser> qsUser = users.stream().filter(x -> "2".equals(x.getUserType())).findFirst();
-
-        String title = "no.message.push.message";
-        String content = "no.message.push.merchant.ready.content";
-        Long stateVal = posOrder.getState() != null ? posOrder.getState() : oldOrder.getState();
-        String stateStr = stateVal != null ? String.valueOf(stateVal) : "2";
-        String body = OrderPushBodyDto.getJson(String.valueOf(oldOrder.getDdId()), stateStr);
-        String ddId = String.valueOf(oldOrder.getDdId());
-
-        if (user.isPresent()) {
-            InfoUser u = user.get();
-            PayPush push = new PayPush();
-            Long uUserId = u.getUserId();
-            String uCid = u.getCid();
-            AsyncManager.me().execute(new TimerTask() {
-                @Override
-                public void run() {
-                    PayPush.userPushHandleLocal(push, pushEventService, uUserId, uCid, title, content, body, "", ddId);
-                }
-            });
-        }
-        if (qsUser.isPresent()) {
-            InfoUser qs = qsUser.get();
-            if (businessPushService.addressKey(qs) != null) {
+        runNotificationAfterCommit(() -> {
+            List<Long> ids = new ArrayList<>();
+            ids.add(oldOrder.getUserId());
+            if (oldOrder.getQsId() != null) {
+                ids.add(oldOrder.getQsId());
+            }
+            List<InfoUser> users = infoUserService.list(new LambdaQueryWrapper<InfoUser>().in(InfoUser::getUserId, ids));
+            Optional<InfoUser> user = users.stream().filter(x -> "0".equals(x.getUserType())).findFirst();
+            Optional<InfoUser> qsUser = users.stream().filter(x -> "2".equals(x.getUserType())).findFirst();
+
+            String title = "no.message.push.message";
+            String content = "no.message.push.merchant.ready.content";
+            Long stateVal = posOrder.getState() != null ? posOrder.getState() : oldOrder.getState();
+            String stateStr = stateVal != null ? String.valueOf(stateVal) : "2";
+            String body = OrderPushBodyDto.getJson(String.valueOf(oldOrder.getDdId()), stateStr);
+            String ddId = String.valueOf(oldOrder.getDdId());
+
+            if (user.isPresent()) {
+                // 用户场景单独构造,下面骑手仍使用原 body。
+                String userBody = OrderPushBodyDto.getUserJson(String.valueOf(oldOrder.getDdId()), stateStr,
+                        -1, 1, UserNotificationScene.MERCHANT_READY);
+                InfoUser u = user.get();
                 PayPush push = new PayPush();
-                Long qsUserId = qs.getUserId();
-                String qsCid = qs.getCid();
+                Long uUserId = u.getUserId();
+                String uCid = u.getCid();
                 AsyncManager.me().execute(new TimerTask() {
                     @Override
                     public void run() {
-                        PayPush.qsPushHandleLocal(push, pushEventService, qsUserId, qsCid, title, content, body, "", ddId);
+                        PayPush.userPushHandleLocal(push, pushEventService, uUserId, uCid, title, content, userBody, "", ddId);
                     }
                 });
             }
-        }
+            if (qsUser.isPresent()) {
+                InfoUser qs = qsUser.get();
+                if (businessPushService.addressKey(qs) != null) {
+                    PayPush push = new PayPush();
+                    Long qsUserId = qs.getUserId();
+                    String qsCid = qs.getCid();
+                    AsyncManager.me().execute(new TimerTask() {
+                        @Override
+                        public void run() {
+                            PayPush.qsPushHandleLocal(push, pushEventService, qsUserId, qsCid, title, content, body, "", ddId);
+                        }
+                    });
+                }
+            }
+        });
     }
 
     /**

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

@@ -107,12 +107,14 @@ class FlashDeliveryMultiPickupTest {
         Fixture f = new Fixture();
         f.service.pickup(8L, 10L, proof(11L));
         Date first = f.rows.get(0).getPickedUpAt();
+        verify(f.notifications, never()).notifyPickedUp(any());
         f.service.pickup(8L, 10L, proof(11L));
         assertEquals(first, f.rows.get(0).getPickedUpAt());
         assertEquals("PENDING", f.rows.get(1).getStatus());
         verify(f.images, times(1)).insert(any(FlashDeliveryOrderImage.class));
         verify(f.logs, times(1)).insert(any(FlashDeliveryOrderLog.class));
         f.service.pickup(8L, 10L, proof(12L));
+        verify(f.notifications, times(1)).notifyPickedUp(f.order);
         assertEquals("PICKED_UP", f.order.getStatus());
         assertEquals(f.rows.get(1).getPickedUpAt(), f.order.getPickedUpAt());
         assertEquals(13L, f.service.riderDetail(8L, 10L).getCurrentTargetStopId());
@@ -413,10 +415,11 @@ class FlashDeliveryMultiPickupTest {
         final FlashDeliveryPricingMapper prices = mock(FlashDeliveryPricingMapper.class);
         final InfoUserMapper users = mock(InfoUserMapper.class);
         final FlashDeliveryRouteService routes = mock(FlashDeliveryRouteService.class);
+        final FlashDeliveryNotificationService notifications = mock(FlashDeliveryNotificationService.class);
         final FlashDeliveryApplicationService service = new FlashDeliveryApplicationService(orders, prices, images, logs, users, mock(com.ruoyi.system.mapper.InfoBankCardMapper.class),
                 routes, new FlashDeliveryPricingCalculator(), mock(RiderDeliveryLockService.class), mock(RiderDeliveryAcceptanceService.class),
                 stops, adjustments, mock(com.ruoyi.system.service.PaymentMethodGateService.class),
-                mock(FlashDeliveryNotificationService.class), mock(com.ruoyi.system.mapper.UserBillingMapper.class));
+                notifications, mock(com.ruoyi.system.mapper.UserBillingMapper.class));
         final FlashDeliveryOrder order = new FlashDeliveryOrder();
         final List<FlashDeliveryOrderStop> rows = new ArrayList<>(List.of(stop(11L, 0, "PICKUP"), stop(12L, 1, "PICKUP"), stop(13L, 2, "DELIVERY")));
         final Map<String, FlashDeliveryTargetAdjustment> records = new HashMap<>();

+ 61 - 8
ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryNotificationServiceTest.java

@@ -4,6 +4,7 @@ import com.ruoyi.app.utils.event.PushEventService;
 import com.ruoyi.app.order.dto.OrderPushBodyDto;
 import com.ruoyi.app.push.BusinessPushProperties;
 import com.ruoyi.app.push.NativeRiderBatchPushService;
+import com.ruoyi.app.push.UserNotificationScene;
 import com.ruoyi.system.domain.InfoUser;
 import com.ruoyi.system.domain.RiderPosition;
 import com.ruoyi.common.core.domain.entity.SysDictData;
@@ -31,6 +32,58 @@ import static org.mockito.Mockito.when;
 
 class FlashDeliveryNotificationServiceTest {
 
+    @Test
+    void successfulUserNotificationsCarryScenesWithoutDeviceAddressAfterCommit() {
+        InfoUserMapper users = mock(InfoUserMapper.class);
+        InfoUser sender = new InfoUser(); sender.setUserId(7L);
+        when(users.selectById(7L)).thenReturn(sender);
+        FlashDeliveryNotificationService service = new FlashDeliveryNotificationService(users, mock(PushEventService.class));
+        java.util.List<com.alibaba.fastjson.JSONObject> bodies = new java.util.ArrayList<>();
+        try (var spring = org.mockito.Mockito.mockStatic(com.ruoyi.common.utils.spring.SpringUtils.class)) {
+        spring.when(() -> com.ruoyi.common.utils.spring.SpringUtils.getBean("scheduledExecutorService"))
+                .thenReturn(mock(java.util.concurrent.ScheduledExecutorService.class));
+        com.ruoyi.framework.manager.AsyncManager manager = mock(com.ruoyi.framework.manager.AsyncManager.class);
+        org.mockito.Mockito.doAnswer(call -> { ((java.util.TimerTask) call.getArgument(0)).run(); return null; })
+                .when(manager).execute(any(java.util.TimerTask.class));
+        try (var async = org.mockito.Mockito.mockStatic(com.ruoyi.framework.manager.AsyncManager.class);
+             var push = org.mockito.Mockito.mockStatic(com.ruoyi.app.utils.PayPush.class)) {
+            async.when(com.ruoyi.framework.manager.AsyncManager::me).thenReturn(manager);
+            push.when(() -> com.ruoyi.app.utils.PayPush.userPushHandleLocal(any(), any(), eq(7L),
+                    org.mockito.ArgumentMatchers.isNull(), anyString(), anyString(), anyString(), anyString(), anyString()))
+                    .thenAnswer(call -> { bodies.add(com.alibaba.fastjson.JSON.parseObject(call.getArgument(6, String.class))); return null; });
+            org.springframework.transaction.support.TransactionSynchronizationManager.initSynchronization();
+            try {
+                service.notifyAccepted(order()); service.notifyPickedUp(order()); service.notifyDelivered(order());
+                org.junit.jupiter.api.Assertions.assertTrue(bodies.isEmpty());
+                org.springframework.transaction.support.TransactionSynchronizationManager.getSynchronizations()
+                        .forEach(sync -> sync.afterCommit());
+                org.junit.jupiter.api.Assertions.assertEquals(List.of("RIDER_ACCEPTED", "ORDER_DELIVERING", "ORDER_DELIVERED"),
+                        bodies.stream().map(body -> body.getString("eventScene")).toList());
+                bodies.forEach(body -> org.junit.jupiter.api.Assertions.assertEquals(3, body.getIntValue("pushType")));
+            } finally {
+                org.springframework.transaction.support.TransactionSynchronizationManager.clearSynchronization();
+            }
+        }
+        }
+    }
+
+    @Test
+    void rollbackSkipsLookupAndPreparationFailureDoesNotEscapeBusinessOperation() {
+        InfoUserMapper users = mock(InfoUserMapper.class);
+        FlashDeliveryNotificationService service = new FlashDeliveryNotificationService(users, mock(PushEventService.class));
+        org.springframework.transaction.support.TransactionSynchronizationManager.initSynchronization();
+        try {
+            service.notifyAccepted(order());
+            org.springframework.transaction.support.TransactionSynchronizationManager.getSynchronizations()
+                    .forEach(sync -> sync.afterCompletion(1));
+            verify(users, never()).selectById(any());
+        } finally {
+            org.springframework.transaction.support.TransactionSynchronizationManager.clearSynchronization();
+        }
+        when(users.selectById(7L)).thenThrow(new IllegalStateException("push preparation failed"));
+        org.junit.jupiter.api.Assertions.assertDoesNotThrow(() -> service.notifyAccepted(order()));
+    }
+
     @Test
     void availableOrderRoutesNewFlashOrderToNearbyEligibleRiders() {
         InfoUserMapper userMapper = mock(InfoUserMapper.class);
@@ -110,7 +163,7 @@ class FlashDeliveryNotificationServiceTest {
         InfoUserMapper userMapper = mock(InfoUserMapper.class);
         FlashDeliveryNotificationService service = spy(new FlashDeliveryNotificationService(
                 userMapper, mock(PushEventService.class)));
-        doNothing().when(service).sendToUser(any(), any(), anyString(), anyString());
+        doNothing().when(service).sendToUser(any(), any(), anyString(), anyString(), any());
         InfoUser sender = new InfoUser();
         sender.setUserId(7L);
         FlashDeliveryOrder order = order();
@@ -119,7 +172,7 @@ class FlashDeliveryNotificationServiceTest {
         service.notifyAccepted(order);
 
         verify(service).sendToUser(same(sender), same(order), eq("ACCEPTED"),
-                eq("no.message.push.delivery.personnel.receiving.order"));
+                eq("no.message.push.delivery.personnel.receiving.order"), eq(UserNotificationScene.RIDER_ACCEPTED));
     }
 
     @Test
@@ -131,7 +184,7 @@ class FlashDeliveryNotificationServiceTest {
 
         service.notifyAccepted(order());
 
-        verify(service, never()).sendToUser(any(), any(), anyString(), anyString());
+        verify(service, never()).sendToUser(any(), any(), anyString(), anyString(), any());
     }
 
     @Test
@@ -139,7 +192,7 @@ class FlashDeliveryNotificationServiceTest {
         InfoUserMapper userMapper = mock(InfoUserMapper.class);
         FlashDeliveryNotificationService service = spy(new FlashDeliveryNotificationService(
                 userMapper, mock(PushEventService.class)));
-        doNothing().when(service).sendToUser(any(), any(), anyString(), anyString());
+        doNothing().when(service).sendToUser(any(), any(), anyString(), anyString(), any());
         InfoUser sender = new InfoUser();
         sender.setUserId(7L);
         FlashDeliveryOrder order = order();
@@ -148,7 +201,7 @@ class FlashDeliveryNotificationServiceTest {
         service.notifyPickedUp(order);
 
         verify(service).sendToUser(same(sender), same(order), eq("PICKED_UP"),
-                eq("no.message.push.delivery.personnel.qspsz.order"));
+                eq("no.message.push.delivery.personnel.qspsz.order"), eq(UserNotificationScene.ORDER_DELIVERING));
     }
 
     @Test
@@ -156,7 +209,7 @@ class FlashDeliveryNotificationServiceTest {
         InfoUserMapper userMapper = mock(InfoUserMapper.class);
         FlashDeliveryNotificationService service = spy(new FlashDeliveryNotificationService(
                 userMapper, mock(PushEventService.class)));
-        doNothing().when(service).sendToUser(any(), any(), anyString(), anyString());
+        doNothing().when(service).sendToUser(any(), any(), anyString(), anyString(), any());
         InfoUser sender = new InfoUser();
         sender.setUserId(7L);
         FlashDeliveryOrder order = order();
@@ -165,7 +218,7 @@ class FlashDeliveryNotificationServiceTest {
         service.notifyDelivered(order);
 
         verify(service).sendToUser(same(sender), same(order), eq("DELIVERED"),
-                eq("no.message.push.delivery.personnel.qsysd.order"));
+                eq("no.message.push.delivery.personnel.qsysd.order"), eq(UserNotificationScene.ORDER_DELIVERED));
     }
 
     @Test
@@ -195,7 +248,7 @@ class FlashDeliveryNotificationServiceTest {
         service.notifyCancelled(order());
 
         verify(service, never()).sendToRider(any(), any(), anyString(), anyString());
-        verify(service, never()).sendToUser(any(), any(), anyString(), anyString());
+        verify(service, never()).sendToUser(any(), any(), anyString(), anyString(), any());
     }
 
     private static FlashDeliveryOrder order() {

+ 5 - 3
ruoyi-admin/src/test/java/com/ruoyi/app/order/PosOrderShOprateControllerTest.java

@@ -362,7 +362,7 @@ class PosOrderShOprateControllerTest {
     }
 
     @Test
-    void selfDeliveryStatusChecksCurrentChannelAddressEvenWithoutCid() {
+    void selfDeliveryStatusDoesNotGateInboxOnChannelAddress() {
         PosOrderShOprateController controller = new PosOrderShOprateController();
         IInfoUserService users = mock(IInfoUserService.class);
         BusinessPushService push = mock(BusinessPushService.class);
@@ -374,9 +374,11 @@ class PosOrderShOprateControllerTest {
         ReflectionTestUtils.setField(controller, "businessPushService", push);
 
         ReflectionTestUtils.invokeMethod(controller, "pushSelfDeliveryStatus",
-                selfDeliveryOrder(2L, 1L), "content.key", "2");
+                selfDeliveryOrder(2L, 1L), "content.key", "2",
+                com.ruoyi.app.push.UserNotificationScene.ORDER_DELIVERING);
 
-        verify(push).addressKey(recipient);
+        // 是否外发交给通道判断,不在用户消息入库之前读取设备地址。
+        verify(push, never()).addressKey(recipient);
     }
 
     private static SelfDeliveryOperateInput operateInput(Long id) {

+ 246 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/order/UserOrderNotificationSceneTest.java

@@ -0,0 +1,246 @@
+package com.ruoyi.app.order;
+
+import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.JSONObject;
+import com.baomidou.mybatisplus.core.MybatisConfiguration;
+import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
+import com.ruoyi.app.order.dto.SelfDeliveryOperateInput;
+import com.ruoyi.app.pay.LinePayOrderGuard;
+import com.ruoyi.app.push.BusinessPushService;
+import com.ruoyi.system.utils.AuthContext;
+import com.ruoyi.app.service.UserService;
+import com.ruoyi.app.utils.PayPush;
+import com.ruoyi.app.utils.event.PushEventService;
+import com.ruoyi.common.core.domain.model.LoginUser;
+import com.ruoyi.common.core.domain.entity.SysUser;
+import com.ruoyi.common.utils.MessageUtils;
+import com.ruoyi.common.utils.SecurityUtils;
+import com.ruoyi.framework.manager.AsyncManager;
+import com.ruoyi.system.domain.InfoUser;
+import com.ruoyi.system.domain.PosOrder;
+import com.ruoyi.system.service.IInfoUserService;
+import com.ruoyi.system.service.IPosOrderService;
+import com.ruoyi.system.utils.JwtUtil;
+import com.ruoyi.system.utils.OrderLogHelper;
+import org.apache.ibatis.builder.MapperBuilderAssistant;
+import org.junit.jupiter.api.*;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+import org.mockito.MockedStatic;
+import org.redisson.api.RLock;
+import org.redisson.api.RedissonClient;
+import org.springframework.test.util.ReflectionTestUtils;
+import org.springframework.transaction.support.TransactionSynchronizationManager;
+
+import java.util.*;
+import java.util.concurrent.TimeUnit;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+
+/** Capture real controller payloads at the push boundary; asynchronous work runs deterministically. */
+class UserOrderNotificationSceneTest {
+    private final IPosOrderService orders = mock(IPosOrderService.class);
+    private final IInfoUserService users = mock(IInfoUserService.class);
+    private final OrderLifecycleService lifecycle = mock(OrderLifecycleService.class);
+    private final MerchantNotificationRouter merchants = mock(MerchantNotificationRouter.class);
+    private final BusinessPushService channel = mock(BusinessPushService.class);
+    private final List<JSONObject> userBodies = new ArrayList<>();
+    private final List<JSONObject> riderBodies = new ArrayList<>();
+    private final List<MockedStatic<?>> statics = new ArrayList<>();
+    private final PosOrder order = new PosOrder();
+    private final InfoUser user = new InfoUser();
+    private RedissonClient redis;
+
+    @BeforeEach
+    void setUp() throws Exception {
+        TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), PosOrder.class);
+        order.setId(10L); order.setDdId("ORDER-1"); order.setUserId(7L); order.setState(2L);
+        order.setDeliveryStatus(0L); order.setMdId(20L); order.setShId(101L);
+        order.setType(0L); order.setPayStatus(1L); order.setSelfDelivery(1);
+        user.setUserId(7L); user.setUserType("0");
+        when(orders.getById(10L)).thenReturn(order);
+        when(orders.getOne(any())).thenReturn(order);
+        when(orders.saveOrUpdate(any(PosOrder.class))).thenReturn(true);
+        when(orders.update(any(PosOrder.class), any())).thenReturn(true);
+        when(orders.list(any(com.baomidou.mybatisplus.core.conditions.Wrapper.class))).thenReturn(List.of());
+        when(users.getById(7L)).thenReturn(user);
+        when(channel.addressKey(any())).thenReturn("address");
+        RLock lock = mock(RLock.class);
+        when(lock.tryLock(anyLong(), anyLong(), eq(TimeUnit.SECONDS))).thenReturn(true);
+        redis = mock(RedissonClient.class); when(redis.getLock(anyString())).thenReturn(lock);
+        MockedStatic<com.ruoyi.common.utils.spring.SpringUtils> spring = mockStatic(com.ruoyi.common.utils.spring.SpringUtils.class);
+        statics.add(spring);
+        spring.when(() -> com.ruoyi.common.utils.spring.SpringUtils.getBean("scheduledExecutorService"))
+                .thenReturn(mock(java.util.concurrent.ScheduledExecutorService.class));
+        AsyncManager manager = mock(AsyncManager.class);
+        doAnswer(call -> { ((TimerTask) call.getArgument(0)).run(); return null; }).when(manager).execute(any(TimerTask.class));
+        MockedStatic<AsyncManager> async = mockStatic(AsyncManager.class);
+        async.when(AsyncManager::me).thenReturn(manager); statics.add(async);
+        MockedStatic<PayPush> push = mockStatic(PayPush.class); statics.add(push);
+        push.when(() -> PayPush.userPushHandleLocal(any(), any(), any(), nullable(String.class),
+                anyString(), anyString(), anyString(), anyString(), anyString()))
+                .thenAnswer(call -> { userBodies.add(JSON.parseObject(call.getArgument(6, String.class))); return null; });
+        push.when(() -> PayPush.qsPushHandleLocal(any(), any(), any(), nullable(String.class),
+                anyString(), anyString(), anyString(), anyString(), anyString()))
+                .thenAnswer(call -> { riderBodies.add(JSON.parseObject(call.getArgument(6, String.class))); return null; });
+        MockedStatic<MessageUtils> messages = mockStatic(MessageUtils.class); statics.add(messages);
+        messages.when(() -> MessageUtils.message(anyString())).thenAnswer(call -> call.getArgument(0));
+        MockedStatic<SecurityUtils> security = mockStatic(SecurityUtils.class); statics.add(security);
+        LoginUser login = new LoginUser(); login.setUser(new SysUser());
+        security.when(SecurityUtils::getLoginUser).thenReturn(login);
+        MockedStatic<AuthContext> auth = mockStatic(AuthContext.class); statics.add(auth);
+        auth.when(AuthContext::requireUserId).thenReturn(101L);
+        InfoUser owner = new InfoUser(); owner.setUserId(101L); owner.setUserType("3");
+        when(users.getOne(any())).thenReturn(owner);
+        TransactionSynchronizationManager.initSynchronization();
+    }
+
+    @AfterEach
+    void tearDown() {
+        TransactionSynchronizationManager.clearSynchronization();
+        Collections.reverse(statics); statics.forEach(MockedStatic::close);
+    }
+
+    private <T> T wire(T controller) {
+        ReflectionTestUtils.setField(controller, "posOrderService", orders);
+        ReflectionTestUtils.setField(controller, "infoUserService", users);
+        ReflectionTestUtils.setField(controller, "businessPushService", channel);
+        ReflectionTestUtils.setField(controller, "pushEventService", mock(PushEventService.class));
+        ReflectionTestUtils.setField(controller, "redissonClient", redis);
+        ReflectionTestUtils.setField(controller, "orderLogHelper", mock(OrderLogHelper.class));
+        return controller;
+    }
+
+    private void commit() {
+        TransactionSynchronizationManager.getSynchronizations().forEach(sync -> sync.afterCommit());
+    }
+
+    @ParameterizedTest
+    @ValueSource(longs = {1, 2, 3})
+    void modernRiderSuccessPreservesStateAndSelectsScene(long status) {
+        PosOrderQsOprateController controller = wire(new PosOrderQsOprateController());
+        ReflectionTestUtils.setField(controller, "orderLifecycleService", lifecycle);
+        ReflectionTestUtils.setField(controller, "merchantNotificationRouter", merchants);
+        PosOrder update = new PosOrder(); update.setId(10L); update.setDeliveryStatus(status);
+        ReflectionTestUtils.invokeMethod(controller, "setOrderQsState", update, "88", new PayPush(), "rider");
+        assertTrue(userBodies.isEmpty()); commit();
+        assertEquals(1, userBodies.size());
+        assertEquals(List.of("RIDER_ACCEPTED", "ORDER_DELIVERING", "ORDER_DELIVERED").get((int) status - 1),
+                userBodies.get(0).getString("eventScene"));
+        assertEquals("2", userBodies.get(0).getString("state"));
+        if (status == 1) {
+            var body = org.mockito.ArgumentCaptor.forClass(String.class);
+            verify(merchants).sendStoreNotification(eq(20L), eq(101L), anyString(), anyString(), body.capture(), eq("ORDER-1"));
+            assertFalse(JSON.parseObject(body.getValue()).containsKey("eventScene"));
+        }
+    }
+
+    @ParameterizedTest
+    @ValueSource(longs = {3, 4, 12})
+    void legacyRiderSuccessPreservesLegacyStateAndSelectsScene(long state) {
+        PosOrderController controller = wire(new PosOrderController());
+        ReflectionTestUtils.setField(controller, "userService", mock(UserService.class));
+        ReflectionTestUtils.setField(controller, "merchantNotificationRouter", merchants);
+        PosOrder update = new PosOrder(); update.setId(10L); update.setState(state);
+        ReflectionTestUtils.invokeMethod(controller, "setOrderQsState", update, "88", new PayPush());
+        assertTrue(userBodies.isEmpty()); commit();
+        assertEquals(1, userBodies.size());
+        assertEquals(state == 3 ? "RIDER_ACCEPTED" : state == 4 ? "ORDER_DELIVERING" : "ORDER_DELIVERED",
+                userBodies.get(0).getString("eventScene"));
+        assertEquals(String.valueOf(state), userBodies.get(0).getString("state"));
+    }
+
+    @Test
+    void noAddressStillReachesUserInboxBoundaryAndRollbackDoesNotNotify() {
+        when(channel.addressKey(any())).thenReturn(null);
+        PosOrderQsOprateController controller = wire(new PosOrderQsOprateController());
+        ReflectionTestUtils.setField(controller, "orderLifecycleService", lifecycle);
+        ReflectionTestUtils.setField(controller, "merchantNotificationRouter", merchants);
+        PosOrder update = new PosOrder(); update.setId(10L); update.setDeliveryStatus(2L);
+        ReflectionTestUtils.invokeMethod(controller, "setOrderQsState", update, "88", new PayPush(), "rider");
+        TransactionSynchronizationManager.getSynchronizations().forEach(sync -> sync.afterCompletion(1));
+        assertTrue(userBodies.isEmpty());
+        // A separate successful transaction must not use device availability as an inbox prerequisite.
+        TransactionSynchronizationManager.clearSynchronization(); TransactionSynchronizationManager.initSynchronization();
+        ReflectionTestUtils.invokeMethod(controller, "setOrderQsState", update, "88", new PayPush(), "rider");
+        commit(); assertEquals(1, userBodies.size());
+    }
+
+    @Test
+    void rejectedNotificationSchedulingDoesNotEscapeCommittedRiderOperation() {
+        PosOrderQsOprateController controller = wire(new PosOrderQsOprateController());
+        ReflectionTestUtils.setField(controller, "orderLifecycleService", lifecycle);
+        ReflectionTestUtils.setField(controller, "merchantNotificationRouter", merchants);
+        AsyncManager manager = AsyncManager.me();
+        doThrow(new java.util.concurrent.RejectedExecutionException("closed executor"))
+                .when(manager).execute(any(TimerTask.class));
+        PosOrder update = new PosOrder(); update.setId(10L); update.setDeliveryStatus(2L);
+        ReflectionTestUtils.invokeMethod(controller, "setOrderQsState", update, "88", new PayPush(), "rider");
+        assertDoesNotThrow(this::commit);
+    }
+
+    @Test
+    void failedLifecycleDoesNotRegisterSuccessNotification() {
+        PosOrderQsOprateController controller = wire(new PosOrderQsOprateController());
+        ReflectionTestUtils.setField(controller, "orderLifecycleService", lifecycle);
+        doThrow(new IllegalStateException("failed update")).when(lifecycle).acceptDeliveryByRider(10L, 88L);
+        PosOrder update = new PosOrder(); update.setId(10L); update.setDeliveryStatus(1L);
+        assertThrows(IllegalStateException.class, () -> ReflectionTestUtils.invokeMethod(controller,
+                "setOrderQsState", update, "88", new PayPush(), "rider"));
+        commit(); assertTrue(userBodies.isEmpty());
+    }
+
+    @Test
+    void selfDeliveryDirectCompletionOnlyNotifiesDeliveredAfterCommit() {
+        PosOrderShOprateController controller = wire(new PosOrderShOprateController());
+        ReflectionTestUtils.setField(controller, "linePayOrderGuard", mock(LinePayOrderGuard.class));
+        ReflectionTestUtils.setField(controller, "orderService", mock(OrderService.class));
+        SelfDeliveryOperateInput input = new SelfDeliveryOperateInput(); input.setId(10L);
+        controller.selfDeliveryComplete(JwtUtil.token("101", "owner"), input);
+        assertTrue(userBodies.isEmpty()); commit();
+        assertEquals(1, userBodies.size());
+        assertEquals("ORDER_DELIVERED", userBodies.get(0).getString("eventScene"));
+        assertTrue(riderBodies.isEmpty());
+    }
+
+    @Test
+    void merchantAcceptanceAndSelfDeliveryStartHaveDistinctScenes() {
+        PosOrderShOprateController controller = wire(new PosOrderShOprateController());
+        ReflectionTestUtils.setField(controller, "linePayOrderGuard", mock(LinePayOrderGuard.class));
+        order.setState(0L);
+        controller.acceptOrder(JwtUtil.token("101", "owner"), 10L);
+        assertTrue(userBodies.isEmpty()); commit();
+        assertEquals("MERCHANT_ACCEPTED", userBodies.get(0).getString("eventScene"));
+        TransactionSynchronizationManager.clearSynchronization(); TransactionSynchronizationManager.initSynchronization();
+        userBodies.clear(); order.setState(2L);
+        SelfDeliveryOperateInput input = new SelfDeliveryOperateInput(); input.setId(10L);
+        controller.selfDeliveryStart(JwtUtil.token("101", "owner"), input);
+        assertTrue(userBodies.isEmpty()); commit();
+        assertEquals("ORDER_DELIVERING", userBodies.get(0).getString("eventScene"));
+    }
+
+    @Test
+    void failedMerchantPersistenceDoesNotNotifyAcceptedOrReady() {
+        PosOrderShOprateController controller = wire(new PosOrderShOprateController());
+        ReflectionTestUtils.setField(controller, "linePayOrderGuard", mock(LinePayOrderGuard.class));
+        ReflectionTestUtils.setField(controller, "orderInvoiceService", mock(OrderInvoiceService.class));
+        when(orders.saveOrUpdate(any(PosOrder.class))).thenReturn(false);
+        when(users.list(any(com.baomidou.mybatisplus.core.conditions.Wrapper.class))).thenReturn(List.of(user));
+        order.setState(0L); controller.acceptOrder(JwtUtil.token("101", "owner"), 10L);
+        order.setState(1L); controller.dispatchOrder(JwtUtil.token("101", "owner"), 10L);
+        commit(); assertTrue(userBodies.isEmpty());
+    }
+
+    @Test
+    void readyNotificationOnlyAddsSceneToUserAndWaitsForCommit() {
+        PosOrderShOprateController controller = wire(new PosOrderShOprateController());
+        InfoUser rider = new InfoUser(); rider.setUserId(88L); rider.setUserType("2");
+        when(users.list(any(com.baomidou.mybatisplus.core.conditions.Wrapper.class))).thenReturn(List.of(user, rider)); order.setQsId(88L);
+        controller.chuCan(order, order);
+        assertTrue(userBodies.isEmpty()); assertTrue(riderBodies.isEmpty()); commit();
+        assertEquals("MERCHANT_READY", userBodies.get(0).getString("eventScene"));
+        assertEquals(1, riderBodies.size()); assertFalse(riderBodies.get(0).containsKey("eventScene"));
+    }
+}