瀏覽代碼

feat(push): route active order and payment notifications

qmj 2 周之前
父節點
當前提交
4f0276ea70

+ 6 - 3
ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentNotifyService.java

@@ -4,8 +4,9 @@ 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.push.BusinessPushService;
+import com.ruoyi.app.push.RecipientApp;
 import com.ruoyi.app.omgpay.dto.OmgNotifyRequest;
-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;
@@ -60,6 +61,8 @@ public class OmgPaymentNotifyService {
     private IInfoUserService infoUserService;
     @Autowired(required = false)
     private PushEventService pushEventService;
+    @Autowired(required = false)
+    private BusinessPushService businessPushService;
 
     public OmgPaymentNotifyService(IOmgPaymentAttemptService attempts, OmgCheckMacSigner signer) {
         this.attempts = attempts;
@@ -231,8 +234,8 @@ public class OmgPaymentNotifyService {
                 String content = MessageUtils.message("no.message.push.payment.success", userLocale);
                 String body = OrderPushBodyDto.getJson(order.getDdId(), String.valueOf(order.getState()), 0);
                 pushEventService.PublisherEvent(user.getUserId(), title, content, body);
-                if (user.getCid() != null && !user.getCid().trim().isEmpty()) {
-                    new PayPush().apppush(user.getCid(), title, content, body);
+                if (businessPushService != null) {
+                    businessPushService.send(RecipientApp.USER, user.getUserId(), user.getCid(), title, content, body);
                 }
             } catch (Exception exception) {
                 log.error("OMG payment success push failed, ddId={}", order.getDdId(), exception);

+ 14 - 13
ruoyi-admin/src/main/java/com/ruoyi/app/order/MerchantNotificationRouter.java

@@ -1,7 +1,8 @@
 package com.ruoyi.app.order;
 
 import com.ruoyi.app.user.MerchantTokenSessionService;
-import com.ruoyi.app.utils.PayPush;
+import com.ruoyi.app.push.BusinessPushService;
+import com.ruoyi.app.push.RecipientApp;
 import com.ruoyi.app.utils.event.PushEventService;
 import com.ruoyi.common.utils.LocaleUtils;
 import com.ruoyi.common.utils.MessageUtils;
@@ -33,17 +34,20 @@ public class MerchantNotificationRouter {
     private final IInfoUserService infoUserService;
     private final MerchantTokenSessionService tokenSessionService;
     private final PushEventService pushEventService;
+    private final BusinessPushService businessPushService;
 
     public MerchantNotificationRouter(MerchantSubaccountStoreMapper relationMapper,
                                       PosStoreMapper posStoreMapper,
                                       IInfoUserService infoUserService,
                                       MerchantTokenSessionService tokenSessionService,
-                                      PushEventService pushEventService) {
+                                      PushEventService pushEventService,
+                                      BusinessPushService businessPushService) {
         this.relationMapper = relationMapper;
         this.posStoreMapper = posStoreMapper;
         this.infoUserService = infoUserService;
         this.tokenSessionService = tokenSessionService;
         this.pushEventService = pushEventService;
+        this.businessPushService = businessPushService;
     }
 
     public void sendStoreNotification(Long storeId, Long ownerUserId,
@@ -72,9 +76,9 @@ public class MerchantNotificationRouter {
             }
         }
 
-        Set<String> pushedCids = new LinkedHashSet<>();
+        Set<String> pushedAddresses = new LinkedHashSet<>();
         for (InfoUser recipient : recipients.values()) {
-            sendToRecipient(recipient, titleKey, contentKey, body, orderNo, pushedCids);
+            sendToRecipient(recipient, titleKey, contentKey, body, orderNo, pushedAddresses);
         }
     }
 
@@ -103,7 +107,7 @@ public class MerchantNotificationRouter {
                 && MerchantAccountConstants.STATUS_ENABLED.equals(candidate.getStatus())
                 && MerchantAccountConstants.STATUS_ENABLED.equals(candidate.getSubaccountStatus())
                 && MerchantAccountConstants.NOT_DELETED.equals(candidate.getDelFlag())
-                && StringUtils.isNotEmpty(candidate.getCid())
+                && businessPushService.addressKey(candidate) != null
                 && tokenSessionService.hasAppSession(candidate.getUserId());
     }
 
@@ -116,7 +120,7 @@ public class MerchantNotificationRouter {
     }
 
     private void sendToRecipient(InfoUser recipient, String titleKey, String contentKey,
-                                 String body, String orderNo, Set<String> pushedCids) {
+                                 String body, String orderNo, Set<String> pushedAddresses) {
         String title = localize(titleKey, recipient.getUserId());
         String content = localize(contentKey, recipient.getUserId());
         if (StringUtils.isNotEmpty(orderNo)) {
@@ -128,12 +132,13 @@ public class MerchantNotificationRouter {
             log.error("Store notification message publish failed, userId={}", recipient.getUserId(), exception);
         }
 
-        String cid = recipient.getCid();
-        if (StringUtils.isEmpty(cid) || !pushedCids.add(cid)) {
+        String address = businessPushService.addressKey(recipient);
+        if (address == null || !pushedAddresses.add(address)) {
             return;
         }
         try {
-            sendExternal(cid, title, content, body);
+            businessPushService.send(RecipientApp.MERCHANT, recipient.getUserId(), recipient.getCid(),
+                    title, content, body);
         } catch (Exception exception) {
             log.error("Store notification external push failed, userId={}", recipient.getUserId(), exception);
         }
@@ -148,10 +153,6 @@ public class MerchantNotificationRouter {
         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) {
     }
 }

+ 4 - 1
ruoyi-admin/src/main/java/com/ruoyi/app/order/PosOrderController.java

@@ -18,6 +18,7 @@ import com.ruoyi.app.order.dto.AdminOrderActionRequest;
 import com.ruoyi.app.order.dto.AdminOrderStatusContext;
 import com.ruoyi.app.pay.LinePayService;
 import com.ruoyi.app.pay.LinePayRefundService;
+import com.ruoyi.app.push.BusinessPushService;
 import com.ruoyi.app.order.dto.AdminOrderStatusUpdateRequest;
 import com.ruoyi.app.service.UserService;
 import com.ruoyi.app.service.WalletService;
@@ -139,6 +140,8 @@ public class PosOrderController extends BaseController {
     @Autowired
     private PushEventService pushEventService;
     @Autowired
+    private BusinessPushService businessPushService;
+    @Autowired
     private DeliveryOrderNotificationService deliveryOrderNotificationService;
     @Autowired
     private MerchantNotificationRouter merchantNotificationRouter;
@@ -374,7 +377,7 @@ public class PosOrderController extends BaseController {
                                     lock.unlock();
                                 }
                                 // 给用户推送(骑手已接单/配送中/已送达)
-                                if (!StringUtils.isEmpty(user.getCid())) {
+                                if (businessPushService.addressKey(user) != null) {
                                     String userTitle = "no.message.push.message";
                                     String userContent = "no.message.push.delivery.personnel.receiving.order";
                                     if (posOrder.getState() == 4) {

+ 4 - 1
ruoyi-admin/src/main/java/com/ruoyi/app/order/PosOrderQsOprateController.java

@@ -6,6 +6,7 @@ import com.alibaba.fastjson.JSONObject;
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
 import com.ruoyi.app.order.dto.OrderPushBodyDto;
+import com.ruoyi.app.push.BusinessPushService;
 import com.ruoyi.app.service.UserService;
 import com.ruoyi.app.utils.PayPush;
 import com.ruoyi.app.utils.event.PushEventService;
@@ -58,6 +59,8 @@ public class PosOrderQsOprateController extends BaseController {
     @Autowired
     private PushEventService pushEventService;
     @Autowired
+    private BusinessPushService businessPushService;
+    @Autowired
     private RedissonClient redissonClient;
     @Autowired
     private UserService userService;
@@ -225,7 +228,7 @@ public class PosOrderQsOprateController extends BaseController {
                                     lock.unlock();
                                 }
                                 // 给用户推送(骑手已接单/配送中/已送达)
-                                if (user != null && !StringUtils.isEmpty(user.getCid())) {
+                                if (user != null && businessPushService.addressKey(user) != null) {
                                     String userTitle = "no.message.push.message";
                                     String userContent = "no.message.push.delivery.personnel.receiving.order";
                                     if (posOrder.getDeliveryStatus() != null && posOrder.getDeliveryStatus() == 2L) {

+ 5 - 2
ruoyi-admin/src/main/java/com/ruoyi/app/order/PosOrderShOprateController.java

@@ -14,6 +14,7 @@ import com.ruoyi.app.order.dto.OrderPushBodyDto;
 import com.ruoyi.app.order.dto.SelfDeliveryOperateInput;
 import com.ruoyi.app.pay.LinePayOrderGuard;
 import com.ruoyi.app.pay.LinePayRefundService;
+import com.ruoyi.app.push.BusinessPushService;
 import com.ruoyi.app.utils.PayPush;
 import com.ruoyi.app.utils.event.PushEventService;
 import com.ruoyi.common.annotation.Anonymous;
@@ -58,6 +59,8 @@ public class PosOrderShOprateController extends BaseController {
     @Autowired
     private PushEventService pushEventService;
     @Autowired
+    private BusinessPushService businessPushService;
+    @Autowired
     private OrderLogHelper orderLogHelper;
     @Autowired
     private IPosStoreService posStoreService;
@@ -550,7 +553,7 @@ public class PosOrderShOprateController extends BaseController {
      */
     private void pushSelfDeliveryStatus(PosOrder order, String contentKey, String stateStr) {
         InfoUser user = infoUserService.getById(order.getUserId());
-        if (user == null || StringUtils.isEmpty(user.getCid())) {
+        if (user == null || businessPushService.addressKey(user) == null) {
             return;
         }
         String title = "no.message.push.message";
@@ -761,7 +764,7 @@ public class PosOrderShOprateController extends BaseController {
         }
         if (qsUser.isPresent()) {
             InfoUser qs = qsUser.get();
-            if (!StringUtils.isEmpty(qs.getCid())) {
+            if (businessPushService.addressKey(qs) != null) {
                 PayPush push = new PayPush();
                 Long qsUserId = qs.getUserId();
                 String qsCid = qs.getCid();

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

@@ -3,7 +3,8 @@ 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.push.BusinessPushService;
+import com.ruoyi.app.push.RecipientApp;
 import com.ruoyi.app.utils.event.PushEventService;
 import com.ruoyi.common.utils.LocaleUtils;
 import com.ruoyi.common.utils.MessageUtils;
@@ -29,17 +30,20 @@ public class LinePayOrderNotificationService {
     private final OrderLogHelper orderLogHelper;
     private final DeliveryOrderNotificationService deliveryOrderNotificationService;
     private final MerchantNotificationRouter merchantNotificationRouter;
+    private final BusinessPushService businessPushService;
 
     public LinePayOrderNotificationService(IInfoUserService infoUserService,
                                            PushEventService pushEventService,
                                            OrderLogHelper orderLogHelper,
                                            DeliveryOrderNotificationService deliveryOrderNotificationService,
-                                           MerchantNotificationRouter merchantNotificationRouter) {
+                                           MerchantNotificationRouter merchantNotificationRouter,
+                                           BusinessPushService businessPushService) {
         this.infoUserService = infoUserService;
         this.pushEventService = pushEventService;
         this.orderLogHelper = orderLogHelper;
         this.deliveryOrderNotificationService = deliveryOrderNotificationService;
         this.merchantNotificationRouter = merchantNotificationRouter;
+        this.businessPushService = businessPushService;
     }
 
     public void paymentCaptured(PosOrder order) {
@@ -59,8 +63,8 @@ public class LinePayOrderNotificationService {
                 Locale userLocale = LocaleUtils.getUserLocale(user.getUserId());
                 String title = MessageUtils.message("no.message.push.message", userLocale);
                 String content = MessageUtils.message("no.message.push.payment.success", userLocale);
-                new PayPush().apppush(user.getCid(), title, content, body);
                 pushEventService.PublisherEvent(user.getUserId(), title, content, body);
+                businessPushService.send(RecipientApp.USER, user.getUserId(), user.getCid(), title, content, body);
             }
             if (Long.valueOf(0L).equals(order.getType())) {
                 deliveryOrderNotificationService.notifyOrderAvailable(order);

+ 8 - 1
ruoyi-admin/src/test/java/com/ruoyi/app/omgpay/OmgPaymentNotifyServiceTest.java

@@ -1,6 +1,8 @@
 package com.ruoyi.app.omgpay;
 
 import com.ruoyi.app.order.DeliveryOrderNotificationService;
+import com.ruoyi.app.push.BusinessPushService;
+import com.ruoyi.app.push.RecipientApp;
 import com.ruoyi.app.omgpay.dto.OmgNotifyField;
 import com.ruoyi.app.omgpay.dto.OmgNotifyRequest;
 import com.ruoyi.app.utils.event.PushEventService;
@@ -182,20 +184,25 @@ class OmgPaymentNotifyServiceTest {
     void firstPaidCallbackPushesPaymentSuccessToOrderUser() {
         IInfoUserService users = mock(IInfoUserService.class);
         PushEventService pushEvents = mock(PushEventService.class);
+        BusinessPushService push = mock(BusinessPushService.class);
         ReflectionTestUtils.setField(service, "infoUserService", users);
         ReflectionTestUtils.setField(service, "pushEventService", pushEvents);
+        ReflectionTestUtils.setField(service, "businessPushService", push);
         OmgPaymentOrderSnapshot order = order(0L, 0L);
         order.setUserId(9L);
         when(attempts.selectOrderForUpdate("DD-1")).thenReturn(order);
         when(attempts.markPaid(any())).thenReturn(1);
         when(attempts.markOrderPaid("DD-1")).thenReturn(1);
         InfoUser user = new InfoUser();
-        user.setUserId(9L); // cid 为空 → 跳过 iOS 推送外呼,仅入消息中心
+        user.setUserId(9L); // cid 为空时仍需走统一路由,由当前模式决定能否外发
+        user.setCidType("ios");
+        user.setDeviceToken("native-token");
         when(users.getById(9L)).thenReturn(user);
 
         assertTrue(service.process(signedRequest(1, 0, 100)));
 
         verify(pushEvents).PublisherEvent(eq(9L), anyString(), anyString(), anyString());
+        verify(push).send(eq(RecipientApp.USER), eq(9L), eq(null), anyString(), anyString(), anyString());
     }
 
     @Test

+ 32 - 8
ruoyi-admin/src/test/java/com/ruoyi/app/order/MerchantNotificationRouterTest.java

@@ -1,5 +1,7 @@
 package com.ruoyi.app.order;
 
+import com.ruoyi.app.push.BusinessPushService;
+import com.ruoyi.app.push.RecipientApp;
 import com.ruoyi.app.user.MerchantTokenSessionService;
 import com.ruoyi.app.utils.event.PushEventService;
 import com.ruoyi.system.domain.InfoUser;
@@ -13,9 +15,10 @@ import org.junit.jupiter.api.Test;
 import java.util.List;
 
 import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
 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;
@@ -30,6 +33,7 @@ class MerchantNotificationRouterTest {
     private IInfoUserService users;
     private MerchantTokenSessionService sessions;
     private PushEventService events;
+    private BusinessPushService pushService;
     private MerchantNotificationRouter router;
 
     @BeforeEach
@@ -39,9 +43,11 @@ class MerchantNotificationRouterTest {
         users = mock(IInfoUserService.class);
         sessions = mock(MerchantTokenSessionService.class);
         events = mock(PushEventService.class);
-        router = spy(new MerchantNotificationRouter(relations, stores, users, sessions, events));
+        pushService = mock(BusinessPushService.class);
+        when(pushService.addressKey(any(InfoUser.class)))
+                .thenAnswer(invocation -> ((InfoUser) invocation.getArgument(0)).getCid());
+        router = spy(new MerchantNotificationRouter(relations, stores, users, sessions, events, pushService));
         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));
     }
@@ -59,8 +65,8 @@ class MerchantNotificationRouterTest {
         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");
+        verify(pushService).send(RecipientApp.MERCHANT, 501L, "cid-a", "title", "content,NO:DD-1", "body");
+        verify(pushService).send(RecipientApp.MERCHANT, 502L, "cid-b", "title", "content,NO:DD-1", "body");
     }
 
     @Test
@@ -74,7 +80,8 @@ class MerchantNotificationRouterTest {
 
         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");
+        verify(pushService, times(1)).send(eq(RecipientApp.MERCHANT), anyLong(), eq("shared-cid"),
+                eq("title"), eq("content,NO:DD-1"), eq("body"));
     }
 
     @Test
@@ -87,6 +94,23 @@ class MerchantNotificationRouterTest {
 
         verify(events).PublisherEvent(101L, "title", "content,NO:DD-1", "body");
         verify(events, never()).PublisherEvent(501L, "title", "content,NO:DD-1", "body");
+        verify(pushService).send(RecipientApp.MERCHANT, 101L, "owner-cid", "title", "content,NO:DD-1", "body");
+    }
+
+    @Test
+    void nativeTokenOnlySubaccountReceivesOrderWithoutDuplicatingOwner() {
+        InfoUser subaccount = subaccount(501L, null);
+        subaccount.setCidType("android");
+        subaccount.setDeviceToken("native-token");
+        when(relations.selectEligibleSubaccountsByStoreId(9L)).thenReturn(List.of(subaccount));
+        when(sessions.hasAppSession(501L)).thenReturn(true);
+        when(pushService.addressKey(subaccount)).thenReturn("android:native-token");
+
+        router.sendStoreNotification(9L, 101L, "title", "content", "body", "DD-1");
+
+        verify(events).PublisherEvent(501L, "title", "content,NO:DD-1", "body");
+        verify(events, never()).PublisherEvent(101L, "title", "content,NO:DD-1", "body");
+        verify(pushService).send(RecipientApp.MERCHANT, 501L, null, "title", "content,NO:DD-1", "body");
     }
 
     @Test
@@ -129,13 +153,13 @@ class MerchantNotificationRouterTest {
         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");
+                .when(pushService).send(RecipientApp.MERCHANT, 501L, "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");
+        verify(pushService).send(RecipientApp.MERCHANT, 502L, "cid-b", "title", "content,NO:DD-1", "body");
     }
 
     @Test

+ 19 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/order/PosOrderShOprateControllerTest.java

@@ -5,6 +5,7 @@ import com.ruoyi.app.order.dto.OrderCreateInput;
 import com.ruoyi.app.order.dto.SelfDeliveryOperateInput;
 import com.ruoyi.app.pay.LinePayOrderGuard;
 import com.ruoyi.app.pay.LinePayRefundService;
+import com.ruoyi.app.push.BusinessPushService;
 import com.ruoyi.common.exception.ServiceException;
 import com.ruoyi.common.utils.MessageUtils;
 import com.ruoyi.system.domain.InfoUser;
@@ -356,6 +357,24 @@ class PosOrderShOprateControllerTest {
         return order;
     }
 
+    @Test
+    void selfDeliveryStatusChecksCurrentChannelAddressEvenWithoutCid() {
+        PosOrderShOprateController controller = new PosOrderShOprateController();
+        IInfoUserService users = mock(IInfoUserService.class);
+        BusinessPushService push = mock(BusinessPushService.class);
+        InfoUser recipient = user(7L, "0", null);
+        recipient.setCidType("android");
+        recipient.setDeviceToken("native-token");
+        when(users.getById(7L)).thenReturn(recipient);
+        ReflectionTestUtils.setField(controller, "infoUserService", users);
+        ReflectionTestUtils.setField(controller, "businessPushService", push);
+
+        ReflectionTestUtils.invokeMethod(controller, "pushSelfDeliveryStatus",
+                selfDeliveryOrder(2L, 1L), "content.key", "2");
+
+        verify(push).addressKey(recipient);
+    }
+
     private static SelfDeliveryOperateInput operateInput(Long id) {
         SelfDeliveryOperateInput input = new SelfDeliveryOperateInput();
         input.setId(id);

+ 62 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/pay/LinePayOrderNotificationServiceTest.java

@@ -0,0 +1,62 @@
+package com.ruoyi.app.pay;
+
+import com.ruoyi.app.order.DeliveryOrderNotificationService;
+import com.ruoyi.app.order.MerchantNotificationRouter;
+import com.ruoyi.app.push.BusinessPushService;
+import com.ruoyi.app.push.RecipientApp;
+import com.ruoyi.app.utils.event.PushEventService;
+import com.ruoyi.common.utils.LocaleUtils;
+import com.ruoyi.common.utils.MessageUtils;
+import com.ruoyi.system.domain.InfoUser;
+import com.ruoyi.system.domain.PosOrder;
+import com.ruoyi.system.service.IInfoUserService;
+import com.ruoyi.system.utils.OrderLogHelper;
+import org.junit.jupiter.api.Test;
+import org.mockito.MockedStatic;
+
+import java.util.Locale;
+
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.mockStatic;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+class LinePayOrderNotificationServiceTest {
+    @Test
+    void paymentSuccessWithNoCidStillRoutesNativeEligibleUserOnlyOnce() {
+        IInfoUserService users = mock(IInfoUserService.class);
+        PushEventService events = mock(PushEventService.class);
+        OrderLogHelper logs = mock(OrderLogHelper.class);
+        DeliveryOrderNotificationService delivery = mock(DeliveryOrderNotificationService.class);
+        MerchantNotificationRouter merchants = mock(MerchantNotificationRouter.class);
+        BusinessPushService push = mock(BusinessPushService.class);
+        LinePayOrderNotificationService service = new LinePayOrderNotificationService(
+                users, events, logs, delivery, merchants, push);
+        InfoUser user = new InfoUser();
+        user.setUserId(9L);
+        user.setCidType("ios");
+        user.setDeviceToken("native-token");
+        when(users.getById(9L)).thenReturn(user);
+        PosOrder order = new PosOrder();
+        order.setDdId("DD-1");
+        order.setUserId(9L);
+        order.setType(1L);
+        order.setState(0L);
+
+        try (MockedStatic<LocaleUtils> locale = mockStatic(LocaleUtils.class);
+             MockedStatic<MessageUtils> messages = mockStatic(MessageUtils.class)) {
+            locale.when(() -> LocaleUtils.getUserLocale(9L)).thenReturn(Locale.US);
+            messages.when(() -> MessageUtils.message("no.message.push.message", Locale.US)).thenReturn("title");
+            messages.when(() -> MessageUtils.message("no.message.push.payment.success", Locale.US))
+                    .thenReturn("paid");
+
+            service.paymentCaptured(order);
+        }
+
+        verify(events).PublisherEvent(eq(9L), eq("title"), eq("paid"), anyString());
+        verify(push).send(eq(RecipientApp.USER), eq(9L), eq(null), eq("title"), eq("paid"), anyString());
+        verify(merchants).sendStoreNotification(eq(null), eq(null), anyString(), anyString(), anyString(), eq("DD-1"));
+    }
+}