Browse Source

按门店分管账号路由商家通知

qmj 1 day ago
parent
commit
33005c6964

+ 13 - 2
ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentNotifyService.java

@@ -2,6 +2,8 @@ package com.ruoyi.app.omgpay;
 
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.ruoyi.app.order.DeliveryOrderNotificationService;
+import com.ruoyi.app.order.MerchantNotificationRouter;
+import com.ruoyi.app.order.dto.OrderPushBodyDto;
 import com.ruoyi.app.omgpay.dto.OmgNotifyRequest;
 import com.ruoyi.system.domain.PosOrder;
 import com.ruoyi.system.omgpay.domain.OmgPaymentAttempt;
@@ -43,6 +45,8 @@ public class OmgPaymentNotifyService {
     private IPosOrderService posOrderService;
     @Autowired(required = false)
     private DeliveryOrderNotificationService deliveryOrderNotificationService;
+    @Autowired(required = false)
+    private MerchantNotificationRouter merchantNotificationRouter;
 
     public OmgPaymentNotifyService(IOmgPaymentAttemptService attempts, OmgCheckMacSigner signer) {
         this.attempts = attempts;
@@ -172,7 +176,7 @@ public class OmgPaymentNotifyService {
 
     private void openDeliveryOrderToRiders(String ddId, Long state) {
         if (Long.valueOf(4L).equals(state) || posOrderService == null
-                || deliveryOrderNotificationService == null) {
+                || (deliveryOrderNotificationService == null && merchantNotificationRouter == null)) {
             return;
         }
         PosOrder paidOrder = posOrderService.getOne(new LambdaQueryWrapper<PosOrder>()
@@ -181,7 +185,14 @@ public class OmgPaymentNotifyService {
             return;
         }
         paidOrder.setPayStatus(1L);
-        deliveryOrderNotificationService.notifyOrderAvailable(paidOrder);
+        if (Long.valueOf(0L).equals(paidOrder.getType()) && deliveryOrderNotificationService != null) {
+            deliveryOrderNotificationService.notifyOrderAvailable(paidOrder);
+        } else if (merchantNotificationRouter != null) {
+            String orderNo = String.valueOf(paidOrder.getDdId());
+            String body = OrderPushBodyDto.getJson(orderNo, String.valueOf(paidOrder.getState()), 0);
+            merchantNotificationRouter.sendStoreNotification(paidOrder.getMdId(), paidOrder.getShId(),
+                    "no.message.push.message", "no.message.push.new.order", body, orderNo);
+        }
     }
 
     private boolean verifyTrust(OmgNotifyRequest request, OmgPaymentAttempt attempt) {

+ 28 - 3
ruoyi-admin/src/main/java/com/ruoyi/app/order/DeliveryOrderNotificationService.java

@@ -27,19 +27,28 @@ public class DeliveryOrderNotificationService {
     private final RiderPositionMapper riderPositionMapper;
     private final PushEventService pushEventService;
     private final IInfoUserService infoUserService;
+    private final MerchantNotificationRouter merchantNotificationRouter;
 
     public DeliveryOrderNotificationService(RiderPositionMapper riderPositionMapper,
                                             PushEventService pushEventService) {
-        this(riderPositionMapper, pushEventService, null);
+        this(riderPositionMapper, pushEventService, null, null);
     }
 
-    @Autowired
     public DeliveryOrderNotificationService(RiderPositionMapper riderPositionMapper,
                                             PushEventService pushEventService,
                                             IInfoUserService infoUserService) {
+        this(riderPositionMapper, pushEventService, infoUserService, null);
+    }
+
+    @Autowired
+    public DeliveryOrderNotificationService(RiderPositionMapper riderPositionMapper,
+                                            PushEventService pushEventService,
+                                            IInfoUserService infoUserService,
+                                            MerchantNotificationRouter merchantNotificationRouter) {
         this.riderPositionMapper = riderPositionMapper;
         this.pushEventService = pushEventService;
         this.infoUserService = infoUserService;
+        this.merchantNotificationRouter = merchantNotificationRouter;
     }
 
     /** 支付完成且仍待骑手接单时,通知附近可接单骑手。 */
@@ -65,8 +74,10 @@ public class DeliveryOrderNotificationService {
         runAfterCommit(() -> {
             Set<Long> recipientIds = new LinkedHashSet<>();
             recipientIds.add(order.getUserId());
-            recipientIds.add(order.getShId());
             recipientIds.add(order.getQsId());
+            if (merchantNotificationRouter == null) {
+                recipientIds.add(order.getShId());
+            }
             recipientIds.remove(null);
             recipientIds.remove(operatorId);
             if (recipientIds.isEmpty()) {
@@ -76,9 +87,23 @@ public class DeliveryOrderNotificationService {
             if (recipients != null) {
                 recipients.forEach(recipient -> sendCancelledOrder(recipient, order));
             }
+            if (merchantNotificationRouter != null && shouldNotifyMerchant(operatorId)) {
+                String ddId = String.valueOf(order.getDdId());
+                String body = OrderPushBodyDto.getJson(ddId, "4", 0);
+                merchantNotificationRouter.sendStoreNotification(order.getMdId(), order.getShId(),
+                        "no.message.push.message", "no.message.push.order.cancelled", body, ddId);
+            }
         });
     }
 
+    private boolean shouldNotifyMerchant(Long operatorId) {
+        if (operatorId == null || infoUserService == null) {
+            return true;
+        }
+        InfoUser operator = infoUserService.getById(operatorId);
+        return operator == null || "0".equals(operator.getUserType()) || "2".equals(operator.getUserType());
+    }
+
     void sendAvailableOrderToRider(RiderPosition rider, PosOrder order) {
         if (rider == null || rider.getRiderId() == null) {
             return;

+ 157 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/order/MerchantNotificationRouter.java

@@ -0,0 +1,157 @@
+package com.ruoyi.app.order;
+
+import com.ruoyi.app.user.MerchantTokenSessionService;
+import com.ruoyi.app.utils.PayPush;
+import com.ruoyi.app.utils.event.PushEventService;
+import com.ruoyi.common.utils.LocaleUtils;
+import com.ruoyi.common.utils.MessageUtils;
+import com.ruoyi.common.utils.StringUtils;
+import com.ruoyi.system.domain.InfoUser;
+import com.ruoyi.system.domain.PosStore;
+import com.ruoyi.system.domain.constants.MerchantAccountConstants;
+import com.ruoyi.system.mapper.MerchantSubaccountStoreMapper;
+import com.ruoyi.system.mapper.PosStoreMapper;
+import com.ruoyi.system.service.IInfoUserService;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Service;
+
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+
+/** Routes one store notification to online subaccounts or the merchant owner fallback. */
+@Service
+public class MerchantNotificationRouter {
+    private static final Logger log = LoggerFactory.getLogger(MerchantNotificationRouter.class);
+
+    private final MerchantSubaccountStoreMapper relationMapper;
+    private final PosStoreMapper posStoreMapper;
+    private final IInfoUserService infoUserService;
+    private final MerchantTokenSessionService tokenSessionService;
+    private final PushEventService pushEventService;
+
+    public MerchantNotificationRouter(MerchantSubaccountStoreMapper relationMapper,
+                                      PosStoreMapper posStoreMapper,
+                                      IInfoUserService infoUserService,
+                                      MerchantTokenSessionService tokenSessionService,
+                                      PushEventService pushEventService) {
+        this.relationMapper = relationMapper;
+        this.posStoreMapper = posStoreMapper;
+        this.infoUserService = infoUserService;
+        this.tokenSessionService = tokenSessionService;
+        this.pushEventService = pushEventService;
+    }
+
+    public void sendStoreNotification(Long storeId, Long ownerUserId,
+                                      String titleKey, String contentKey,
+                                      String body, String orderNo) {
+        StoreRoute route = resolveRoute(storeId, ownerUserId);
+        Long resolvedOwnerId = route.ownerUserId();
+        InfoUser owner = resolvedOwnerId == null ? null : infoUserService.getById(resolvedOwnerId);
+        boolean activeOwner = isActiveOwner(owner);
+        List<InfoUser> eligible = !route.storeValid() || !activeOwner
+                ? List.of() : relationMapper.selectEligibleSubaccountsByStoreId(storeId);
+        Map<Long, InfoUser> recipients = new LinkedHashMap<>();
+        if (eligible != null) {
+            for (InfoUser candidate : eligible) {
+                if (isSubaccountRecipient(candidate)) {
+                    recipients.putIfAbsent(candidate.getUserId(), candidate);
+                }
+            }
+        }
+        if (recipients.isEmpty()) {
+            if (activeOwner) {
+                recipients.put(owner.getUserId(), owner);
+            } else {
+                log.warn("Store notification has no active recipient, storeId={}, ownerUserId={}",
+                        storeId, resolvedOwnerId);
+            }
+        }
+
+        Set<String> pushedCids = new LinkedHashSet<>();
+        for (InfoUser recipient : recipients.values()) {
+            sendToRecipient(recipient, titleKey, contentKey, body, orderNo, pushedCids);
+        }
+    }
+
+    private StoreRoute resolveRoute(Long storeId, Long fallbackOwnerId) {
+        if (storeId == null) {
+            log.warn("Store notification missing storeId, fallbackOwnerId={}", fallbackOwnerId);
+            return new StoreRoute(fallbackOwnerId, false);
+        }
+        PosStore store = posStoreMapper.selectPosStoreById(storeId);
+        if (store == null || !MerchantAccountConstants.NOT_DELETED.equals(store.getDelFlag())) {
+            log.warn("Store notification cannot resolve active store, storeId={}, fallbackOwnerId={}",
+                    storeId, fallbackOwnerId);
+            return new StoreRoute(fallbackOwnerId, false);
+        }
+        if (fallbackOwnerId != null && !fallbackOwnerId.equals(store.getUserId())) {
+            log.warn("Store notification owner mismatch, storeId={}, requestedOwnerId={}, actualOwnerId={}",
+                    storeId, fallbackOwnerId, store.getUserId());
+        }
+        return new StoreRoute(store.getUserId(), true);
+    }
+
+    private boolean isSubaccountRecipient(InfoUser candidate) {
+        return candidate != null
+                && candidate.getUserId() != null
+                && MerchantAccountConstants.SUBACCOUNT_USER_TYPE.equals(candidate.getUserType())
+                && MerchantAccountConstants.STATUS_ENABLED.equals(candidate.getStatus())
+                && MerchantAccountConstants.STATUS_ENABLED.equals(candidate.getSubaccountStatus())
+                && MerchantAccountConstants.NOT_DELETED.equals(candidate.getDelFlag())
+                && StringUtils.isNotEmpty(candidate.getCid())
+                && tokenSessionService.hasAppSession(candidate.getUserId());
+    }
+
+    private boolean isActiveOwner(InfoUser owner) {
+        return owner != null
+                && (MerchantAccountConstants.OWNER_USER_TYPE.equals(owner.getUserType())
+                || "3".equals(owner.getUserType()) || "4".equals(owner.getUserType()))
+                && MerchantAccountConstants.STATUS_ENABLED.equals(owner.getStatus())
+                && MerchantAccountConstants.NOT_DELETED.equals(owner.getDelFlag());
+    }
+
+    private void sendToRecipient(InfoUser recipient, String titleKey, String contentKey,
+                                 String body, String orderNo, Set<String> pushedCids) {
+        String title = localize(titleKey, recipient.getUserId());
+        String content = localize(contentKey, recipient.getUserId());
+        if (StringUtils.isNotEmpty(orderNo)) {
+            content += ",NO:" + orderNo;
+        }
+        try {
+            publishMessage(recipient.getUserId(), title, content, body);
+        } catch (Exception exception) {
+            log.error("Store notification message publish failed, userId={}", recipient.getUserId(), exception);
+        }
+
+        String cid = recipient.getCid();
+        if (StringUtils.isEmpty(cid) || !pushedCids.add(cid)) {
+            return;
+        }
+        try {
+            sendExternal(cid, title, content, body);
+        } catch (Exception exception) {
+            log.error("Store notification external push failed, userId={}", recipient.getUserId(), exception);
+        }
+    }
+
+    String localize(String messageKey, Long userId) {
+        Locale locale = LocaleUtils.getUserLocale(userId);
+        return MessageUtils.message(messageKey, locale);
+    }
+
+    void publishMessage(Long userId, String title, String content, String body) {
+        pushEventService.PublisherEvent(userId, title, content, body);
+    }
+
+    void sendExternal(String cid, String title, String content, String body) {
+        new PayPush().shpush(cid, title, content, body);
+    }
+
+    private record StoreRoute(Long ownerUserId, boolean storeValid) {
+    }
+}

+ 5 - 6
ruoyi-admin/src/main/java/com/ruoyi/app/order/PosOrderController.java

@@ -129,6 +129,8 @@ public class PosOrderController extends BaseController {
     @Autowired
     private DeliveryOrderNotificationService deliveryOrderNotificationService;
     @Autowired
+    private MerchantNotificationRouter merchantNotificationRouter;
+    @Autowired
     private IPosOrderRatingService posOrderRatingService;
     @Autowired
     private IFoodStatisticsService foodStatisticsService;
@@ -353,7 +355,6 @@ public class PosOrderController extends BaseController {
                             updateUserBill(orst.getDdId(), orst.getUserId(), orst.getQsId());
                         }
                         InfoUser user = infoUserService.getById(orst.getUserId());
-                        InfoUser shu = infoUserService.getById(orst.getShId());
                         String finalUserMessage = userMessage;
                         releaseInfinally = false;
                         TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@@ -385,18 +386,16 @@ public class PosOrderController extends BaseController {
                                     });
                                 }
                                 // 给商家推送(骑手已接单时)
-                                if (!StringUtils.isEmpty(shu.getCid()) && posOrder.getState() == 3) {
+                                if (posOrder.getState() == 3) {
                                     String shTitle = "no.message.push.message";
                                     String shContent = "no.message.push.delivery.personnel.receiving.order";
                                     String shBody = OrderPushBodyDto.getJson(String.valueOf(orst.getDdId()), String.valueOf(posOrder.getState()));
-                                    Long shUserId = shu.getUserId();
-                                    String shCid = shu.getCid();
-                                    String shCidType = "";
                                     String shDdId = String.valueOf(orst.getDdId());
                                     AsyncManager.me().execute(new TimerTask() {
                                         @Override
                                         public void run() {
-                                            PayPush.shPushHandleLocal(push, pushEventService, shUserId, shCid, shTitle, shContent, shBody, shCidType, shDdId);
+                                            merchantNotificationRouter.sendStoreNotification(orst.getMdId(), orst.getShId(),
+                                                    shTitle, shContent, shBody, shDdId);
                                         }
                                     });
                                 }

+ 5 - 6
ruoyi-admin/src/main/java/com/ruoyi/app/order/PosOrderQsOprateController.java

@@ -65,6 +65,8 @@ public class PosOrderQsOprateController extends BaseController {
     private OrderLogHelper orderLogHelper;
     @Autowired
     private OrderLifecycleService orderLifecycleService;
+    @Autowired
+    private MerchantNotificationRouter merchantNotificationRouter;
 
     /**
      * 骑手接单:校验 type=0 且 afterSaleStatus=0,设置 deliveryStatus=1
@@ -207,7 +209,6 @@ public class PosOrderQsOprateController extends BaseController {
                             updateUserBill(orst.getDdId(), orst.getUserId(), orst.getQsId());
                         }
                         InfoUser user = infoUserService.getById(orst.getUserId());
-                        InfoUser shu = infoUserService.getById(orst.getShId());
                         releaseInfinally = false;
                         TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
                             @Override
@@ -238,18 +239,16 @@ public class PosOrderQsOprateController extends BaseController {
                                     });
                                 }
                                 // 给商家推送(骑手已接单时)
-                                if (shu != null && posOrder.getDeliveryStatus() != null && posOrder.getDeliveryStatus() == 1L) {
+                                if (posOrder.getDeliveryStatus() != null && posOrder.getDeliveryStatus() == 1L) {
                                     String shTitle = "no.message.push.message";
                                     String shContent = "no.message.push.rider.accepted.merchant";
                                     String shBody = OrderPushBodyDto.getJson(String.valueOf(orst.getDdId()), String.valueOf(orst.getState()));
-                                    Long shUserId = shu.getUserId();
-                                    String shCid = shu.getCid();
-                                    String shCidType = "";
                                     String shDdId = String.valueOf(orst.getDdId());
                                     AsyncManager.me().execute(new TimerTask() {
                                         @Override
                                         public void run() {
-                                            PayPush.shPushHandleLocal(push, pushEventService, shUserId, shCid, shTitle, shContent, shBody, shCidType, shDdId);
+                                            merchantNotificationRouter.sendStoreNotification(orst.getMdId(), orst.getShId(),
+                                                    shTitle, shContent, shBody, shDdId);
                                         }
                                     });
                                 }

+ 10 - 16
ruoyi-admin/src/main/java/com/ruoyi/app/order/UserOrderController.java

@@ -87,6 +87,8 @@ public class UserOrderController extends BaseController {
     private OrderLifecycleService orderLifecycleService;
     @Autowired
     private DeliveryOrderNotificationService deliveryOrderNotificationService;
+    @Autowired
+    private MerchantNotificationRouter merchantNotificationRouter;
 
     /**
      * 申请电子发票(订单完成后,客户主动申请:B2C 邮箱 / B2B 统编 / 载具)
@@ -348,22 +350,14 @@ public class UserOrderController extends BaseController {
                 deliveryOrderNotificationService.notifyOrderAvailable(posOrder);
             } else {
                 // 自取和堂食保持原流程,创建后直接通知商家。
-                InfoUser shUser = infoUserService.getById(item.getShId());
-                if (shUser != null && !StringUtils.isEmpty(shUser.getCid())) {
-                    PayPush push = new PayPush();
-                    String title = "no.message.push.message";
-                    String content = "no.message.push.new.order";
-                    String body = OrderPushBodyDto.getJson(subddId, String.valueOf(posOrder.getState()), 0);
-                    Long shUserId = shUser.getUserId();
-                    String cid = shUser.getCid();
-
-                    AsyncManager.me().execute(new TimerTask() {
-                        @Override
-                        public void run() {
-                            PayPush.shPushHandleLocal(push, pushEventService, shUserId, cid, title, content, body, "", subddId);
-                        }
-                    });
-                }
+                String body = OrderPushBodyDto.getJson(subddId, String.valueOf(posOrder.getState()), 0);
+                AsyncManager.me().execute(new TimerTask() {
+                    @Override
+                    public void run() {
+                        merchantNotificationRouter.sendStoreNotification(posOrder.getMdId(), posOrder.getShId(),
+                                "no.message.push.message", "no.message.push.new.order", body, subddId);
+                    }
+                });
             }
         }
     }

+ 7 - 9
ruoyi-admin/src/main/java/com/ruoyi/app/pay/LinePayOrderNotificationService.java

@@ -2,6 +2,7 @@ package com.ruoyi.app.pay;
 
 import com.ruoyi.app.order.dto.OrderPushBodyDto;
 import com.ruoyi.app.order.DeliveryOrderNotificationService;
+import com.ruoyi.app.order.MerchantNotificationRouter;
 import com.ruoyi.app.utils.PayPush;
 import com.ruoyi.app.utils.event.PushEventService;
 import com.ruoyi.common.utils.LocaleUtils;
@@ -27,15 +28,18 @@ public class LinePayOrderNotificationService {
     private final PushEventService pushEventService;
     private final OrderLogHelper orderLogHelper;
     private final DeliveryOrderNotificationService deliveryOrderNotificationService;
+    private final MerchantNotificationRouter merchantNotificationRouter;
 
     public LinePayOrderNotificationService(IInfoUserService infoUserService,
                                            PushEventService pushEventService,
                                            OrderLogHelper orderLogHelper,
-                                           DeliveryOrderNotificationService deliveryOrderNotificationService) {
+                                           DeliveryOrderNotificationService deliveryOrderNotificationService,
+                                           MerchantNotificationRouter merchantNotificationRouter) {
         this.infoUserService = infoUserService;
         this.pushEventService = pushEventService;
         this.orderLogHelper = orderLogHelper;
         this.deliveryOrderNotificationService = deliveryOrderNotificationService;
+        this.merchantNotificationRouter = merchantNotificationRouter;
     }
 
     public void paymentCaptured(PosOrder order) {
@@ -61,14 +65,8 @@ public class LinePayOrderNotificationService {
             if (Long.valueOf(0L).equals(order.getType())) {
                 deliveryOrderNotificationService.notifyOrderAvailable(order);
             } else {
-                InfoUser merchant = order.getShId() == null ? null : infoUserService.getById(order.getShId());
-                if (merchant != null) {
-                    Locale merchantLocale = LocaleUtils.getUserLocale(merchant.getUserId());
-                    String title = MessageUtils.message("no.message.push.message", merchantLocale);
-                    String content = MessageUtils.message("no.message.push.new.order", merchantLocale);
-                    new PayPush().shpush(merchant.getCid(), title, content, body);
-                    pushEventService.PublisherEvent(merchant.getUserId(), title, content, body);
-                }
+                merchantNotificationRouter.sendStoreNotification(order.getMdId(), order.getShId(),
+                        "no.message.push.message", "no.message.push.new.order", body, ddId);
             }
         } catch (Exception exception) {
             log.error("LINE Pay payment success push failed, ddId={}", order.getDdId(), exception);

+ 4 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/user/MerchantTokenSessionService.java

@@ -56,6 +56,10 @@ public class MerchantTokenSessionService {
                 || hasSession(CacheConstants.SH_PC_TOKEN_KEY, userId);
     }
 
+    public boolean hasAppSession(Long userId) {
+        return hasSession(CacheConstants.SH_APP_TOKEN_KEY, userId);
+    }
+
     private boolean hasSession(String prefix, Long userId) {
         Collection<String> keys = redisCache.keys(prefix + userId + ":*");
         return keys != null && !keys.isEmpty();

+ 28 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/order/DeliveryOrderNotificationServiceTest.java

@@ -1,9 +1,12 @@
 package com.ruoyi.app.order;
 
+import com.ruoyi.app.order.dto.OrderPushBodyDto;
 import com.ruoyi.app.utils.event.PushEventService;
 import com.ruoyi.system.domain.PosOrder;
 import com.ruoyi.system.domain.RiderPosition;
 import com.ruoyi.system.mapper.RiderPositionMapper;
+import com.ruoyi.system.domain.InfoUser;
+import com.ruoyi.system.service.IInfoUserService;
 import org.junit.jupiter.api.Test;
 
 import java.math.BigDecimal;
@@ -55,6 +58,31 @@ class DeliveryOrderNotificationServiceTest {
         verify(positions, never()).getAcceptRiderList(order.getLongitude(), order.getLatitude(), 20);
     }
 
+    @Test
+    void userCancellationRoutesMerchantNotificationByActualOrderStore() {
+        RiderPositionMapper positions = mock(RiderPositionMapper.class);
+        IInfoUserService users = mock(IInfoUserService.class);
+        MerchantNotificationRouter router = mock(MerchantNotificationRouter.class);
+        DeliveryOrderNotificationService service = new DeliveryOrderNotificationService(
+                positions, mock(PushEventService.class), users, router);
+        PosOrder order = availableOrder();
+        order.setMdId(9L);
+        order.setShId(101L);
+        order.setUserId(201L);
+        order.setQsId(301L);
+        InfoUser operator = new InfoUser();
+        operator.setUserId(201L);
+        operator.setUserType("0");
+        when(users.getById(201L)).thenReturn(operator);
+        when(users.listByIds(org.mockito.ArgumentMatchers.anyCollection())).thenReturn(List.of());
+
+        service.notifyOrderCancelled(order, 201L);
+
+        verify(router).sendStoreNotification(9L, 101L,
+                "no.message.push.message", "no.message.push.order.cancelled",
+                OrderPushBodyDto.getJson("DD-DELIVERY", "4", 0), "DD-DELIVERY");
+    }
+
     private static PosOrder availableOrder() {
         PosOrder order = new PosOrder();
         order.setId(10L);

+ 179 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/order/MerchantNotificationRouterTest.java

@@ -0,0 +1,179 @@
+package com.ruoyi.app.order;
+
+import com.ruoyi.app.user.MerchantTokenSessionService;
+import com.ruoyi.app.utils.event.PushEventService;
+import com.ruoyi.system.domain.InfoUser;
+import com.ruoyi.system.domain.PosStore;
+import com.ruoyi.system.mapper.MerchantSubaccountStoreMapper;
+import com.ruoyi.system.mapper.PosStoreMapper;
+import com.ruoyi.system.service.IInfoUserService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.doNothing;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+class MerchantNotificationRouterTest {
+    private MerchantSubaccountStoreMapper relations;
+    private PosStoreMapper stores;
+    private IInfoUserService users;
+    private MerchantTokenSessionService sessions;
+    private PushEventService events;
+    private MerchantNotificationRouter router;
+
+    @BeforeEach
+    void setUp() {
+        relations = mock(MerchantSubaccountStoreMapper.class);
+        stores = mock(PosStoreMapper.class);
+        users = mock(IInfoUserService.class);
+        sessions = mock(MerchantTokenSessionService.class);
+        events = mock(PushEventService.class);
+        router = spy(new MerchantNotificationRouter(relations, stores, users, sessions, events));
+        doAnswer(invocation -> invocation.getArgument(0)).when(router).localize(anyString(), anyLong());
+        doNothing().when(router).sendExternal(anyString(), anyString(), anyString(), anyString());
+        when(stores.selectPosStoreById(9L)).thenReturn(store(9, 101L));
+        when(users.getById(101L)).thenReturn(owner(101L));
+    }
+
+    @Test
+    void sendsMessageToEveryOnlineSubaccountWithoutDuplicatingOwner() {
+        InfoUser first = subaccount(501L, "cid-a");
+        InfoUser second = subaccount(502L, "cid-b");
+        when(relations.selectEligibleSubaccountsByStoreId(9L)).thenReturn(List.of(first, second));
+        when(sessions.hasAppSession(501L)).thenReturn(true);
+        when(sessions.hasAppSession(502L)).thenReturn(true);
+
+        router.sendStoreNotification(9L, 101L, "title", "content", "body", "DD-1");
+
+        verify(events).PublisherEvent(501L, "title", "content,NO:DD-1", "body");
+        verify(events).PublisherEvent(502L, "title", "content,NO:DD-1", "body");
+        verify(events, never()).PublisherEvent(101L, "title", "content,NO:DD-1", "body");
+        verify(router).sendExternal("cid-a", "title", "content,NO:DD-1", "body");
+        verify(router).sendExternal("cid-b", "title", "content,NO:DD-1", "body");
+    }
+
+    @Test
+    void storesOneMessagePerAccountButPushesSharedCidOnlyOnce() {
+        InfoUser first = subaccount(501L, "shared-cid");
+        InfoUser second = subaccount(502L, "shared-cid");
+        when(relations.selectEligibleSubaccountsByStoreId(9L)).thenReturn(List.of(first, second));
+        when(sessions.hasAppSession(anyLong())).thenReturn(true);
+
+        router.sendStoreNotification(9L, 101L, "title", "content", "body", "DD-1");
+
+        verify(events).PublisherEvent(501L, "title", "content,NO:DD-1", "body");
+        verify(events).PublisherEvent(502L, "title", "content,NO:DD-1", "body");
+        verify(router, times(1)).sendExternal("shared-cid", "title", "content,NO:DD-1", "body");
+    }
+
+    @Test
+    void fallsBackToOwnerWhenNoSubaccountHasAppSessionAndCid() {
+        InfoUser offline = subaccount(501L, "cid-a");
+        InfoUser missingCid = subaccount(502L, null);
+        when(relations.selectEligibleSubaccountsByStoreId(9L)).thenReturn(List.of(offline, missingCid));
+
+        router.sendStoreNotification(9L, 101L, "title", "content", "body", "DD-1");
+
+        verify(events).PublisherEvent(101L, "title", "content,NO:DD-1", "body");
+        verify(events, never()).PublisherEvent(501L, "title", "content,NO:DD-1", "body");
+    }
+
+    @Test
+    void disabledSubaccountAndDisabledOwnerDoNotReceiveNotification() {
+        InfoUser disabledSubaccount = subaccount(501L, "cid-a");
+        disabledSubaccount.setStatus("1");
+        InfoUser disabledOwner = owner(101L);
+        disabledOwner.setStatus("1");
+        when(users.getById(101L)).thenReturn(disabledOwner);
+        when(relations.selectEligibleSubaccountsByStoreId(9L)).thenReturn(List.of(disabledSubaccount));
+        when(sessions.hasAppSession(501L)).thenReturn(true);
+
+        router.sendStoreNotification(9L, 101L, "title", "content", "body", "DD-1");
+
+        verify(events, never()).PublisherEvent(anyLong(), anyString(), anyString(), anyString());
+        verify(relations, never()).selectEligibleSubaccountsByStoreId(9L);
+    }
+
+    @Test
+    void platformOrOwnerDisabledSubaccountsAreExcludedAndOwnerReceivesFallback() {
+        InfoUser platformDisabled = subaccount(501L, "cid-a");
+        platformDisabled.setStatus("1");
+        InfoUser ownerDisabled = subaccount(502L, "cid-b");
+        ownerDisabled.setSubaccountStatus("1");
+        when(relations.selectEligibleSubaccountsByStoreId(9L))
+                .thenReturn(List.of(platformDisabled, ownerDisabled));
+        when(sessions.hasAppSession(anyLong())).thenReturn(true);
+
+        router.sendStoreNotification(9L, 101L, "title", "content", "body", "DD-1");
+
+        verify(events).PublisherEvent(101L, "title", "content,NO:DD-1", "body");
+        verify(events, never()).PublisherEvent(501L, "title", "content,NO:DD-1", "body");
+        verify(events, never()).PublisherEvent(502L, "title", "content,NO:DD-1", "body");
+    }
+
+    @Test
+    void oneExternalFailureDoesNotBlockOtherMessagesOrPushes() {
+        InfoUser first = subaccount(501L, "cid-a");
+        InfoUser second = subaccount(502L, "cid-b");
+        when(relations.selectEligibleSubaccountsByStoreId(9L)).thenReturn(List.of(first, second));
+        when(sessions.hasAppSession(anyLong())).thenReturn(true);
+        doThrow(new IllegalStateException("push failed"))
+                .when(router).sendExternal("cid-a", "title", "content,NO:DD-1", "body");
+
+        router.sendStoreNotification(9L, 101L, "title", "content", "body", "DD-1");
+
+        verify(events).PublisherEvent(501L, "title", "content,NO:DD-1", "body");
+        verify(events).PublisherEvent(502L, "title", "content,NO:DD-1", "body");
+        verify(router).sendExternal("cid-b", "title", "content,NO:DD-1", "body");
+    }
+
+    @Test
+    void missingStoreRoutesOnlyToProvidedOwnerFallback() {
+        when(stores.selectPosStoreById(99L)).thenReturn(null);
+
+        router.sendStoreNotification(99L, 101L, "title", "content", "body", "DD-1");
+
+        verify(relations, never()).selectEligibleSubaccountsByStoreId(99L);
+        verify(events).PublisherEvent(101L, "title", "content,NO:DD-1", "body");
+    }
+
+    private static InfoUser owner(Long id) {
+        InfoUser user = new InfoUser();
+        user.setUserId(id);
+        user.setUserType("1");
+        user.setStatus("0");
+        user.setDelFlag("0");
+        user.setCid("owner-cid");
+        return user;
+    }
+
+    private static InfoUser subaccount(Long id, String cid) {
+        InfoUser user = new InfoUser();
+        user.setUserId(id);
+        user.setUserType("5");
+        user.setStatus("0");
+        user.setSubaccountStatus("0");
+        user.setDelFlag("0");
+        user.setCid(cid);
+        return user;
+    }
+
+    private static PosStore store(Integer id, Long ownerId) {
+        PosStore store = new PosStore();
+        store.setId(id);
+        store.setUserId(ownerId);
+        store.setDelFlag("0");
+        return store;
+    }
+}