Explorar o código

feat(push): batch native rider new-order alerts

qmj hai 2 semanas
pai
achega
f525e79938

+ 1 - 0
docs/order-push-overview.md

@@ -11,6 +11,7 @@
 
 - 目前服務端普通業務推送**預設仍走 uniPush**;現有測試環境的 iOS 通知欄與 App「消息中心」按原流程核對。消息中心由原業務路徑寫入,外部推送投遞成功與否不能只憑消息記錄判定。
 - 服務端已有 Android FCM / iOS APNs 原生通道,但**尚未啟用**,也未包含 App 端 token 上報改造;不要把原生通知欄接收當成本文件目前的既有結果。啟用條件與真機驗收見 `specs/033-native-push-routing/quickstart.md`。
+- 外賣與閃送新單對多名騎手的通知:預設 uni 模式維持原本逐人發送;切到原生模式後,Android 騎手按相同語言文案及訂單資料走騎手端 FCM multicast,iOS 騎手仍逐設備走 APNs。每人的消息中心記錄保持獨立。
 - 商家門店訂單通知沿用「已登入且有當前通道可用地址的分管帳號優先;沒有符合條件的分管帳號才由主帳號兜底」的規則,兩類帳號不會同時通知。原生模式用當前 `deviceToken` 判斷,預設 uni 模式用 `cid`。
 - 推送語言**跟隨接收人的語言設置**(簡中 / 越南語 / 英文 / 繁中),同一場景不同語言賬號看到的文案不同。
 - 正文中的 `{訂單號}` 是實際訂單編號,會替換成真實號碼。

+ 23 - 2
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryNotificationService.java

@@ -1,6 +1,8 @@
 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.utils.PayPush;
 import com.ruoyi.app.utils.event.PushEventService;
 import com.ruoyi.common.core.domain.entity.SysDictData;
@@ -50,17 +52,28 @@ public class FlashDeliveryNotificationService {
     private final InfoUserMapper userMapper;
     private final PushEventService pushEventService;
     private final RiderPositionMapper riderPositionMapper;
+    private final BusinessPushProperties pushProperties;
+    private final NativeRiderBatchPushService nativeRiderBatch;
 
     public FlashDeliveryNotificationService(InfoUserMapper userMapper, PushEventService pushEventService) {
         this(userMapper, pushEventService, null);
     }
 
-    @Autowired
     public FlashDeliveryNotificationService(InfoUserMapper userMapper, PushEventService pushEventService,
                                             RiderPositionMapper riderPositionMapper) {
+        this(userMapper, pushEventService, riderPositionMapper, null, null);
+    }
+
+    @Autowired
+    public FlashDeliveryNotificationService(InfoUserMapper userMapper, PushEventService pushEventService,
+                                            RiderPositionMapper riderPositionMapper,
+                                            BusinessPushProperties pushProperties,
+                                            NativeRiderBatchPushService nativeRiderBatch) {
         this.userMapper = userMapper;
         this.pushEventService = pushEventService;
         this.riderPositionMapper = riderPositionMapper;
+        this.pushProperties = pushProperties;
+        this.nativeRiderBatch = nativeRiderBatch;
     }
 
     /** 按订单取寄件用户;订单或用户缺失时静默跳过。 */
@@ -138,7 +151,15 @@ public class FlashDeliveryNotificationService {
                     order.getPickupLongitude(), order.getPickupLatitude(), vehicleType,
                     order.getDeliveryType(), newTaskDistanceLimit(), RIDER_NOTIFICATION_LIMIT);
             if (riders != null) {
-                riders.forEach(rider -> sendAvailableOrderToRider(rider, order));
+                if (pushProperties != null && pushProperties.getMode() == BusinessPushProperties.Mode.NATIVE
+                        && nativeRiderBatch != null) {
+                    String orderNo = order.getOrderNo();
+                    String body = OrderPushBodyDto.getJson(orderNo, WAITING_ACCEPTANCE, 0, PUSH_TYPE_FLASH);
+                    nativeRiderBatch.sendNewOrder(riders, TITLE_KEY, CONTENT_AVAILABLE, body, orderNo,
+                            rider -> sendAvailableOrderToRider(rider, order));
+                } else {
+                    riders.forEach(rider -> sendAvailableOrderToRider(rider, order));
+                }
             }
         });
     }

+ 26 - 1
ruoyi-admin/src/main/java/com/ruoyi/app/order/DeliveryOrderNotificationService.java

@@ -1,6 +1,8 @@
 package com.ruoyi.app.order;
 
 import com.ruoyi.app.order.dto.OrderPushBodyDto;
+import com.ruoyi.app.push.BusinessPushProperties;
+import com.ruoyi.app.push.NativeRiderBatchPushService;
 import com.ruoyi.app.utils.PayPush;
 import com.ruoyi.app.utils.event.PushEventService;
 import com.ruoyi.framework.manager.AsyncManager;
@@ -28,6 +30,8 @@ public class DeliveryOrderNotificationService {
     private final PushEventService pushEventService;
     private final IInfoUserService infoUserService;
     private final MerchantNotificationRouter merchantNotificationRouter;
+    private final BusinessPushProperties pushProperties;
+    private final NativeRiderBatchPushService nativeRiderBatch;
 
     public DeliveryOrderNotificationService(RiderPositionMapper riderPositionMapper,
                                             PushEventService pushEventService) {
@@ -45,10 +49,22 @@ public class DeliveryOrderNotificationService {
                                             PushEventService pushEventService,
                                             IInfoUserService infoUserService,
                                             MerchantNotificationRouter merchantNotificationRouter) {
+        this(riderPositionMapper, pushEventService, infoUserService, merchantNotificationRouter, null, null);
+    }
+
+    @Autowired
+    public DeliveryOrderNotificationService(RiderPositionMapper riderPositionMapper,
+                                            PushEventService pushEventService,
+                                            IInfoUserService infoUserService,
+                                            MerchantNotificationRouter merchantNotificationRouter,
+                                            BusinessPushProperties pushProperties,
+                                            NativeRiderBatchPushService nativeRiderBatch) {
         this.riderPositionMapper = riderPositionMapper;
         this.pushEventService = pushEventService;
         this.infoUserService = infoUserService;
         this.merchantNotificationRouter = merchantNotificationRouter;
+        this.pushProperties = pushProperties;
+        this.nativeRiderBatch = nativeRiderBatch;
     }
 
     /**
@@ -67,7 +83,16 @@ public class DeliveryOrderNotificationService {
             List<RiderPosition> riders = riderPositionMapper.getAcceptRiderList(
                     order.getLongitude(), order.getLatitude(), RIDER_NOTIFICATION_LIMIT);
             if (riders != null) {
-                riders.forEach(rider -> sendAvailableOrderToRider(rider, order));
+                if (pushProperties != null && pushProperties.getMode() == BusinessPushProperties.Mode.NATIVE
+                        && nativeRiderBatch != null) {
+                    String ddId = String.valueOf(order.getDdId());
+                    String body = OrderPushBodyDto.getJson(ddId, String.valueOf(order.getState()), 0);
+                    nativeRiderBatch.sendNewOrder(riders, "no.message.push.message",
+                            "no.message.push.new.food.order", body, ddId,
+                            rider -> sendAvailableOrderToRider(rider, order));
+                } else {
+                    riders.forEach(rider -> sendAvailableOrderToRider(rider, order));
+                }
             }
         });
     }

+ 64 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/push/FcmPushSender.java

@@ -8,11 +8,14 @@ import com.google.firebase.FirebaseApp;
 import com.google.firebase.FirebaseOptions;
 import com.google.firebase.messaging.AndroidConfig;
 import com.google.firebase.messaging.AndroidNotification;
+import com.google.firebase.messaging.BatchResponse;
 import com.google.firebase.messaging.FirebaseMessaging;
 import com.google.firebase.messaging.FirebaseMessagingException;
 import com.google.firebase.messaging.Message;
 import com.google.firebase.messaging.MessagingErrorCode;
+import com.google.firebase.messaging.MulticastMessage;
 import com.google.firebase.messaging.Notification;
+import com.google.firebase.messaging.SendResponse;
 import jakarta.annotation.PostConstruct;
 import jakarta.annotation.PreDestroy;
 import org.slf4j.Logger;
@@ -101,6 +104,67 @@ public class FcmPushSender {
         }
     }
 
+    public List<DeliveryResult> sendBatch(RecipientApp app, List<String> tokens,
+                                          String title, String content, String payload) {
+        FirebaseMessaging client = messaging.get(app);
+        if (client == null) {
+            throw new IllegalStateException("FCM_NOT_INITIALIZED");
+        }
+        if (tokens == null || tokens.isEmpty()) {
+            return List.of();
+        }
+        try {
+            BatchResponse response = client.sendEachForMulticast(buildMulticastMessage(app, tokens,
+                    title, content, payload));
+            List<DeliveryResult> results = new ArrayList<>();
+            for (SendResponse item : response.getResponses()) {
+                MessagingErrorCode code = item.isSuccessful() || item.getException() == null
+                        ? null : item.getException().getMessagingErrorCode();
+                results.add(new DeliveryResult(item.isSuccessful(), code == null ? "UNKNOWN" : code.name()));
+            }
+            return results;
+        } catch (FirebaseMessagingException exception) {
+            MessagingErrorCode code = exception.getMessagingErrorCode();
+            throw new IllegalStateException("FCM_" + (code == null ? "UNKNOWN" : code.name()));
+        }
+    }
+
+    public record DeliveryResult(boolean successful, String category) { }
+
+    static MulticastMessage buildMulticastMessage(RecipientApp app, List<String> tokens,
+                                                 String title, String content, String payload) {
+        String safeTitle = title == null ? "" : title;
+        String safeContent = content == null ? "" : content;
+        AndroidNotification.Builder androidNotification = AndroidNotification.builder().setDefaultSound(true);
+        if (app == RecipientApp.RIDER) {
+            androidNotification.setChannelId("qsRing");
+        }
+        MulticastMessage.Builder message = MulticastMessage.builder()
+                .addAllTokens(tokens)
+                .setNotification(Notification.builder().setTitle(safeTitle).setBody(safeContent).build())
+                .setAndroidConfig(AndroidConfig.builder().setPriority(AndroidConfig.Priority.HIGH)
+                        .setNotification(androidNotification.build()).build())
+                .putData("title", safeTitle)
+                .putData("body", safeContent);
+        if (payload != null) {
+            message.putData("payload", payload);
+            try {
+                JSONObject fields = JSON.parseObject(payload);
+                if (fields != null) {
+                    for (String field : BUSINESS_FIELDS) {
+                        Object value = fields.get(field);
+                        if (value != null) {
+                            message.putData(field, String.valueOf(value));
+                        }
+                    }
+                }
+            } catch (RuntimeException exception) {
+                log.warn("FCM payload is not JSON; forwarding its original text");
+            }
+        }
+        return message.build();
+    }
+
     static Message buildMessage(RecipientApp app, String token, String title, String content, String payload) {
         String safeTitle = title == null ? "" : title;
         String safeContent = content == null ? "" : content;

+ 104 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/push/NativeRiderBatchPushService.java

@@ -0,0 +1,104 @@
+package com.ruoyi.app.push;
+
+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.RiderPosition;
+import com.ruoyi.system.service.IInfoUserService;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.scheduling.annotation.Async;
+import org.springframework.stereotype.Service;
+
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.function.Consumer;
+
+/** Batches identical Android rider alerts without changing the existing uni or iOS paths. */
+@Service
+public class NativeRiderBatchPushService {
+    private static final Logger log = LoggerFactory.getLogger(NativeRiderBatchPushService.class);
+
+    private final BusinessPushProperties properties;
+    private final IInfoUserService users;
+    private final FcmPushSender fcm;
+    private final PushEventService events;
+
+    public NativeRiderBatchPushService(BusinessPushProperties properties, IInfoUserService users,
+                                       FcmPushSender fcm, PushEventService events) {
+        this.properties = properties;
+        this.users = users;
+        this.fcm = fcm;
+        this.events = events;
+    }
+
+    @Async("threadPoolTaskExecutor")
+    public void sendNewOrder(List<RiderPosition> riders, String titleKey, String contentKey,
+                             String payload, String orderNo, Consumer<RiderPosition> perRiderFallback) {
+        if (riders == null || riders.isEmpty()) {
+            return;
+        }
+        Map<Content, List<RiderTarget>> groups = new LinkedHashMap<>();
+        for (RiderPosition rider : riders) {
+            if (rider == null || rider.getRiderId() == null) {
+                continue;
+            }
+            InfoUser current;
+            try {
+                current = users.getById(rider.getRiderId());
+            } catch (RuntimeException exception) {
+                perRiderFallback.accept(rider);
+                continue;
+            }
+            if (properties.getMode() != BusinessPushProperties.Mode.NATIVE || current == null
+                    || current.getCidType() == null || !"android".equalsIgnoreCase(current.getCidType().trim())
+                    || current.getDeviceToken() == null || current.getDeviceToken().isBlank()) {
+                perRiderFallback.accept(rider);
+                continue;
+            }
+            try {
+                Long userId = rider.getRiderId();
+                java.util.Locale locale = LocaleUtils.getUserLocale(userId);
+                String title = MessageUtils.message(titleKey, locale);
+                String content = MessageUtils.message(contentKey, locale) + ",NO:" + orderNo;
+                events.PublisherEvent(userId, title, content, payload);
+                groups.computeIfAbsent(new Content(title, content, payload), ignored -> new ArrayList<>())
+                        .add(new RiderTarget(userId, current.getDeviceToken()));
+            } catch (RuntimeException exception) {
+                log.warn("Native rider alert preparation failed, userId={}, category={}",
+                        rider.getRiderId(), exception.getClass().getSimpleName());
+            }
+        }
+        for (Map.Entry<Content, List<RiderTarget>> entry : groups.entrySet()) {
+            List<RiderTarget> targets = entry.getValue();
+            Content content = entry.getKey();
+            try {
+                List<FcmPushSender.DeliveryResult> results = fcm.sendBatch(RecipientApp.RIDER,
+                        targets.stream().map(RiderTarget::token).toList(),
+                        content.title(), content.body(), content.payload());
+                for (int index = 0; index < targets.size(); index++) {
+                    if (index >= results.size() || !results.get(index).successful()) {
+                        String category = index >= results.size() ? "RESULT_MISSING" : results.get(index).category();
+                        log.warn("Native rider alert failed, userId={}, category={}",
+                                targets.get(index).userId(), category);
+                    }
+                }
+            } catch (RuntimeException exception) {
+                String message = exception.getMessage();
+                String category = exception instanceof IllegalStateException && message != null
+                        && message.matches("FCM_[A-Z0-9_]{1,64}")
+                        ? message : exception.getClass().getSimpleName();
+                for (RiderTarget target : targets) {
+                    log.warn("Native rider alert failed, userId={}, category={}", target.userId(), category);
+                }
+            }
+        }
+    }
+
+    private record Content(String title, String body, String payload) { }
+
+    private record RiderTarget(Long userId, String token) { }
+}

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

@@ -1,6 +1,9 @@
 package com.ruoyi.app.flashdelivery.service;
 
 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.system.domain.InfoUser;
 import com.ruoyi.system.domain.RiderPosition;
 import com.ruoyi.common.core.domain.entity.SysDictData;
@@ -22,6 +25,7 @@ import static org.mockito.Mockito.doNothing;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.never;
 import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.timeout;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
 
@@ -58,6 +62,49 @@ class FlashDeliveryNotificationServiceTest {
         verify(service).sendAvailableOrderToRider(rider, order);
     }
 
+    @Test
+    void nativeAvailableFlashOrderUsesRiderBatchAndUniKeepsPerRiderPath() {
+        InfoUserMapper userMapper = mock(InfoUserMapper.class);
+        RiderPositionMapper positions = mock(RiderPositionMapper.class);
+        BusinessPushProperties properties = new BusinessPushProperties();
+        NativeRiderBatchPushService batches = mock(NativeRiderBatchPushService.class);
+        FlashDeliveryNotificationService service = spy(new FlashDeliveryNotificationService(
+                userMapper, mock(PushEventService.class), positions, properties, batches));
+        FlashDeliveryOrder order = order();
+        order.setStatus("WAITING_ACCEPTANCE");
+        order.setIsDisplay(true);
+        order.setVehicleType(1);
+        order.setDeliveryType("NORMAL");
+        order.setPickupLongitude(new BigDecimal("121.5000"));
+        order.setPickupLatitude(new BigDecimal("25.0300"));
+        RiderPosition rider = new RiderPosition();
+        rider.setRiderId(9L);
+        List<RiderPosition> riders = List.of(rider);
+        SysDictData distance = new SysDictData();
+        distance.setDictValue("5");
+        when(positions.getFlashRiderNotificationList(order.getPickupLongitude(), order.getPickupLatitude(),
+                1, "NORMAL", 5, 20)).thenReturn(riders);
+        doNothing().when(service).sendAvailableOrderToRider(rider, order);
+
+        try (MockedStatic<DictUtils> dictionary = org.mockito.Mockito.mockStatic(DictUtils.class)) {
+            dictionary.when(() -> DictUtils.getDictCache("sys_qs_newtask_distance"))
+                    .thenReturn(List.of(distance));
+            properties.setMode(BusinessPushProperties.Mode.NATIVE);
+            service.notifyAvailable(order);
+            verify(batches, timeout(3000)).sendNewOrder(org.mockito.ArgumentMatchers.eq(riders),
+                    org.mockito.ArgumentMatchers.eq("no.message.push.message"),
+                    org.mockito.ArgumentMatchers.eq("no.message.push.new.flash.order"),
+                    org.mockito.ArgumentMatchers.eq(OrderPushBodyDto.getJson(order.getOrderNo(),
+                            "WAITING_ACCEPTANCE", 0, 3)),
+                    org.mockito.ArgumentMatchers.eq(order.getOrderNo()), org.mockito.ArgumentMatchers.any());
+            verify(service, never()).sendAvailableOrderToRider(rider, order);
+
+            properties.setMode(BusinessPushProperties.Mode.UNI);
+            service.notifyAvailable(order);
+            verify(service).sendAvailableOrderToRider(rider, order);
+        }
+    }
+
     @Test
     void acceptedNotificationRoutesToSender() {
         InfoUserMapper userMapper = mock(InfoUserMapper.class);

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

@@ -1,6 +1,8 @@
 package com.ruoyi.app.order;
 
 import com.ruoyi.app.order.dto.OrderPushBodyDto;
+import com.ruoyi.app.push.BusinessPushProperties;
+import com.ruoyi.app.push.NativeRiderBatchPushService;
 import com.ruoyi.app.utils.event.PushEventService;
 import com.ruoyi.system.domain.PosOrder;
 import com.ruoyi.system.domain.RiderPosition;
@@ -16,6 +18,7 @@ import static org.mockito.Mockito.doNothing;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.never;
 import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.timeout;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
 
@@ -45,6 +48,40 @@ class DeliveryOrderNotificationServiceTest {
         verify(service).sendAvailableOrderToRider(rider, order);
     }
 
+    @Test
+    void nativeNewOrderUsesOneRiderBatchWhileUniKeepsPerRiderPath() {
+        RiderPositionMapper positions = mock(RiderPositionMapper.class);
+        NativeRiderBatchPushService batches = mock(NativeRiderBatchPushService.class);
+        BusinessPushProperties properties = new BusinessPushProperties();
+        DeliveryOrderNotificationService service = spy(new DeliveryOrderNotificationService(
+                positions, mock(PushEventService.class), null, null, properties, batches));
+        PosOrder order = availableOrder();
+        RiderPosition first = new RiderPosition();
+        first.setRiderId(88L);
+        RiderPosition second = new RiderPosition();
+        second.setRiderId(89L);
+        List<RiderPosition> riders = List.of(first, second);
+        when(positions.getAcceptRiderList(order.getLongitude(), order.getLatitude(), 20)).thenReturn(riders);
+        doNothing().when(service).sendAvailableOrderToRider(first, order);
+        doNothing().when(service).sendAvailableOrderToRider(second, order);
+
+        properties.setMode(BusinessPushProperties.Mode.NATIVE);
+        service.notifyOrderAvailable(order);
+
+        verify(batches, timeout(3000)).sendNewOrder(org.mockito.ArgumentMatchers.eq(riders),
+                org.mockito.ArgumentMatchers.eq("no.message.push.message"),
+                org.mockito.ArgumentMatchers.eq("no.message.push.new.food.order"),
+                org.mockito.ArgumentMatchers.eq(OrderPushBodyDto.getJson("DD-DELIVERY", "0", 0)),
+                org.mockito.ArgumentMatchers.eq("DD-DELIVERY"), org.mockito.ArgumentMatchers.any());
+        verify(service, never()).sendAvailableOrderToRider(first, order);
+        verify(service, never()).sendAvailableOrderToRider(second, order);
+
+        properties.setMode(BusinessPushProperties.Mode.UNI);
+        service.notifyOrderAvailable(order);
+        verify(service).sendAvailableOrderToRider(first, order);
+        verify(service).sendAvailableOrderToRider(second, order);
+    }
+
     @Test
     void ignoresOrdersThatAreNotOpenForRiderAssignment() {
         RiderPositionMapper positions = mock(RiderPositionMapper.class);

+ 38 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/push/FcmPushSenderTest.java

@@ -2,8 +2,11 @@ package com.ruoyi.app.push;
 
 import com.google.firebase.messaging.FirebaseMessaging;
 import com.google.firebase.messaging.FirebaseMessagingException;
+import com.google.firebase.messaging.BatchResponse;
 import com.google.firebase.messaging.Message;
 import com.google.firebase.messaging.MessagingErrorCode;
+import com.google.firebase.messaging.MulticastMessage;
+import com.google.firebase.messaging.SendResponse;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.io.TempDir;
 
@@ -11,6 +14,7 @@ import java.lang.reflect.Field;
 import java.nio.file.Files;
 import java.nio.file.Path;
 import java.util.EnumMap;
+import java.util.List;
 import java.util.Map;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
@@ -59,6 +63,40 @@ class FcmPushSenderTest {
         }
     }
 
+    @Test
+    void riderMulticastUsesRiderProjectAndReportsEachTokenResult() throws Exception {
+        StubSender sender = new StubSender(nativeProperties());
+        sender.initialize();
+        FirebaseMessaging riderClient = sender.messaging.get(RecipientApp.RIDER);
+        BatchResponse response = mock(BatchResponse.class);
+        SendResponse accepted = mock(SendResponse.class);
+        SendResponse rejected = mock(SendResponse.class);
+        FirebaseMessagingException failure = mock(FirebaseMessagingException.class);
+        when(accepted.isSuccessful()).thenReturn(true);
+        when(rejected.getException()).thenReturn(failure);
+        when(failure.getMessagingErrorCode()).thenReturn(MessagingErrorCode.UNREGISTERED);
+        when(response.getResponses()).thenReturn(List.of(accepted, rejected));
+        when(riderClient.sendEachForMulticast(any(MulticastMessage.class))).thenReturn(response);
+
+        List<FcmPushSender.DeliveryResult> results = sender.sendBatch(RecipientApp.RIDER,
+                List.of("rider-token-1", "rider-token-2"), "New order", "Order body",
+                "{\"ddId\":123,\"state\":0,\"type\":0,\"pushType\":\"order\"}");
+
+        org.mockito.ArgumentCaptor<MulticastMessage> captured =
+                org.mockito.ArgumentCaptor.forClass(MulticastMessage.class);
+        verify(riderClient).sendEachForMulticast(captured.capture());
+        assertEquals(List.of("rider-token-1", "rider-token-2"), field(captured.getValue(), "tokens"));
+        @SuppressWarnings("unchecked")
+        Map<String, String> data = (Map<String, String>) field(captured.getValue(), "data");
+        assertEquals("123", data.get("ddId"));
+        assertEquals("order", data.get("pushType"));
+        Object androidNotification = field(field(captured.getValue(), "androidConfig"), "notification");
+        assertEquals("qsRing", field(androidNotification, "channelId"));
+        assertTrue(results.get(0).successful());
+        assertFalse(results.get(1).successful());
+        assertEquals("UNREGISTERED", results.get(1).category());
+    }
+
     @Test
     void classifiesUnregisteredTokenWithoutIncludingTokenInError() throws Exception {
         StubSender sender = new StubSender(nativeProperties());

+ 144 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/push/NativeRiderBatchPushServiceTest.java

@@ -0,0 +1,144 @@
+package com.ruoyi.app.push;
+
+import ch.qos.logback.classic.Logger;
+import ch.qos.logback.classic.spi.ILoggingEvent;
+import ch.qos.logback.core.read.ListAppender;
+import com.ruoyi.app.utils.event.PushEventService;
+import com.ruoyi.common.utils.LocaleUtils;
+import com.ruoyi.common.utils.spring.SpringUtils;
+import com.ruoyi.system.domain.InfoUser;
+import com.ruoyi.system.domain.RiderPosition;
+import com.ruoyi.system.service.IInfoUserService;
+import org.junit.jupiter.api.Test;
+import org.mockito.MockedStatic;
+import org.slf4j.LoggerFactory;
+import org.springframework.context.MessageSource;
+import org.springframework.context.support.StaticMessageSource;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+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 NativeRiderBatchPushServiceTest {
+
+    @Test
+    void groupsAndroidRidersByLocalizedTextAndLeavesIosOnExistingPath() {
+        BusinessPushProperties properties = new BusinessPushProperties();
+        properties.setMode(BusinessPushProperties.Mode.NATIVE);
+        IInfoUserService users = mock(IInfoUserService.class);
+        FcmPushSender fcm = mock(FcmPushSender.class);
+        PushEventService events = mock(PushEventService.class);
+        NativeRiderBatchPushService service = new NativeRiderBatchPushService(properties, users, fcm, events);
+        when(users.getById(1L)).thenReturn(user(1L, "android", "android-1"));
+        when(users.getById(2L)).thenReturn(user(2L, "android", "android-2"));
+        when(users.getById(3L)).thenReturn(user(3L, "ios", "ios-3"));
+        when(users.getById(4L)).thenReturn(user(4L, "android", "android-4"));
+        when(fcm.sendBatch(RecipientApp.RIDER, List.of("android-1", "android-2"),
+                "New order", "Nearby order,NO:FOOD-1", "{\"ddId\":\"FOOD-1\"}"))
+                .thenReturn(List.of(new FcmPushSender.DeliveryResult(true, "UNKNOWN"),
+                        new FcmPushSender.DeliveryResult(false, "UNREGISTERED")));
+        when(fcm.sendBatch(RecipientApp.RIDER, List.of("android-4"),
+                "新订单", "附近订单,NO:FOOD-1", "{\"ddId\":\"FOOD-1\"}"))
+                .thenReturn(List.of(new FcmPushSender.DeliveryResult(true, "UNKNOWN")));
+        StaticMessageSource messages = new StaticMessageSource();
+        messages.addMessage("title.key", Locale.ENGLISH, "New order");
+        messages.addMessage("content.key", Locale.ENGLISH, "Nearby order");
+        messages.addMessage("title.key", Locale.CHINESE, "新订单");
+        messages.addMessage("content.key", Locale.CHINESE, "附近订单");
+        List<RiderPosition> fallback = new ArrayList<>();
+        Logger logger = (Logger) LoggerFactory.getLogger(NativeRiderBatchPushService.class);
+        ListAppender<ILoggingEvent> captured = new ListAppender<>();
+        captured.start();
+        logger.addAppender(captured);
+
+        try (MockedStatic<LocaleUtils> locales = mockStatic(LocaleUtils.class);
+             MockedStatic<SpringUtils> beans = mockStatic(SpringUtils.class)) {
+            locales.when(() -> LocaleUtils.getUserLocale(1L)).thenReturn(Locale.ENGLISH);
+            locales.when(() -> LocaleUtils.getUserLocale(2L)).thenReturn(Locale.ENGLISH);
+            locales.when(() -> LocaleUtils.getUserLocale(4L)).thenReturn(Locale.CHINESE);
+            beans.when(() -> SpringUtils.getBean(MessageSource.class)).thenReturn(messages);
+
+            service.sendNewOrder(List.of(rider(1L), rider(2L), rider(3L), rider(4L)),
+                    "title.key", "content.key", "{\"ddId\":\"FOOD-1\"}", "FOOD-1", fallback::add);
+        } finally {
+            logger.detachAppender(captured);
+            captured.stop();
+        }
+
+        verify(fcm).sendBatch(RecipientApp.RIDER, List.of("android-1", "android-2"),
+                "New order", "Nearby order,NO:FOOD-1", "{\"ddId\":\"FOOD-1\"}");
+        verify(fcm).sendBatch(RecipientApp.RIDER, List.of("android-4"),
+                "新订单", "附近订单,NO:FOOD-1", "{\"ddId\":\"FOOD-1\"}");
+        verify(events).PublisherEvent(1L, "New order", "Nearby order,NO:FOOD-1", "{\"ddId\":\"FOOD-1\"}");
+        verify(events).PublisherEvent(2L, "New order", "Nearby order,NO:FOOD-1", "{\"ddId\":\"FOOD-1\"}");
+        verify(events).PublisherEvent(4L, "新订单", "附近订单,NO:FOOD-1", "{\"ddId\":\"FOOD-1\"}");
+        assertEquals(List.of(3L), fallback.stream().map(RiderPosition::getRiderId).toList());
+        List<String> logs = captured.list.stream().map(ILoggingEvent::getFormattedMessage).toList();
+        assertTrue(logs.stream().anyMatch(line -> line.contains("userId=2")
+                && line.contains("category=UNREGISTERED")));
+        assertFalse(logs.stream().anyMatch(line -> line.contains("userId=1")
+                || line.contains("android-1") || line.contains("android-2")));
+    }
+
+    @Test
+    void multicastTransportFailureIdentifiesEachRiderAndSafeProviderCategory() {
+        BusinessPushProperties properties = new BusinessPushProperties();
+        properties.setMode(BusinessPushProperties.Mode.NATIVE);
+        IInfoUserService users = mock(IInfoUserService.class);
+        FcmPushSender fcm = mock(FcmPushSender.class);
+        when(users.getById(1L)).thenReturn(user(1L, "android", "sensitive-1"));
+        when(users.getById(2L)).thenReturn(user(2L, "android", "sensitive-2"));
+        when(fcm.sendBatch(RecipientApp.RIDER, List.of("sensitive-1", "sensitive-2"),
+                "New order", "Nearby order,NO:FOOD-1", "{}"))
+                .thenThrow(new IllegalStateException("FCM_UNAVAILABLE"));
+        StaticMessageSource messages = new StaticMessageSource();
+        messages.addMessage("title.key", Locale.ENGLISH, "New order");
+        messages.addMessage("content.key", Locale.ENGLISH, "Nearby order");
+        Logger logger = (Logger) LoggerFactory.getLogger(NativeRiderBatchPushService.class);
+        ListAppender<ILoggingEvent> captured = new ListAppender<>();
+        captured.start();
+        logger.addAppender(captured);
+        try (MockedStatic<LocaleUtils> locales = mockStatic(LocaleUtils.class);
+             MockedStatic<SpringUtils> beans = mockStatic(SpringUtils.class)) {
+            locales.when(() -> LocaleUtils.getUserLocale(1L)).thenReturn(Locale.ENGLISH);
+            locales.when(() -> LocaleUtils.getUserLocale(2L)).thenReturn(Locale.ENGLISH);
+            beans.when(() -> SpringUtils.getBean(MessageSource.class)).thenReturn(messages);
+
+            new NativeRiderBatchPushService(properties, users, fcm, mock(PushEventService.class))
+                    .sendNewOrder(List.of(rider(1L), rider(2L)), "title.key", "content.key", "{}", "FOOD-1",
+                            ignored -> { });
+        } finally {
+            logger.detachAppender(captured);
+            captured.stop();
+        }
+
+        List<String> logs = captured.list.stream().map(ILoggingEvent::getFormattedMessage).toList();
+        assertTrue(logs.stream().anyMatch(line -> line.contains("userId=1")
+                && line.contains("category=FCM_UNAVAILABLE")));
+        assertTrue(logs.stream().anyMatch(line -> line.contains("userId=2")
+                && line.contains("category=FCM_UNAVAILABLE")));
+        assertFalse(logs.stream().anyMatch(line -> line.contains("sensitive-1") || line.contains("sensitive-2")));
+    }
+
+    private static InfoUser user(Long id, String platform, String token) {
+        InfoUser user = new InfoUser();
+        user.setUserId(id);
+        user.setCidType(platform);
+        user.setDeviceToken(token);
+        return user;
+    }
+
+    private static RiderPosition rider(Long id) {
+        RiderPosition rider = new RiderPosition();
+        rider.setRiderId(id);
+        return rider;
+    }
+}

+ 8 - 0
specs/033-native-push-routing/plan.md

@@ -142,3 +142,11 @@ PowerShell 中仅对当前命令临时设置 `$env:JAVA_HOME='C:\Users\99029\.jd
 - [x] 从用户给出的三端文件各复制一份 FCM JSON、一份普通 `.p12`、一份配置 JSON 到资源目录,名字含接收端;`ios.p12` 与 `iosdev.p12` 内容相同,不重复提交,不使用 VoIP 证书。
 - [x] 保留外部文件路径兼容,增加 classpath InputStream 加载;`application.yml` 默认指向按端命名资源且模式为 `uni`。不更改并行地图配置。
 - [x] JDK 21 运行定向测试、模块构建,核对 JAR 中的九个资源和 Git 暂存范围;文档说明凭据入库风险及真机未验证,形成单独提交。
+
+## Task 8: 原生多骑手新单批量发送(2026-09-29 追加)
+
+外卖和闪送仍沿用各自的骑手筛选、最多 20 人及事务提交后通知时机。仅 `native` 分支异步处理筛选结果:读取骑手当前 `cidType/deviceToken`,Android 按翻译后的标题、正文与订单 payload 分组,调用骑手端 FCM `sendEachForMulticast`,并按返回顺序记录每个失败类别;iOS、无有效原生地址的骑手保留原有逐人路径。每位骑手的 `PushEvent` 仍各发布一次;不改 `uni` 分支、旧批量方法、其他业务通知或数据库。
+
+先用 `FcmPushSenderTest`、`NativeRiderBatchPushServiceTest` 及外卖/闪送通知测试证明缺少批量路由,再完成实现;最后运行受影响测试、模块回归和构建。真机投递仍需部署环境验证。
+
+本次 JDK 21 全模块回归共 938 个测试,0 失败/错误/跳过;`mvn -pl ruoyi-admin -am -DskipTests package` 退出码为 0。真实 FCM/APNs 多设备投递未验证。

+ 2 - 0
specs/033-native-push-routing/quickstart.md

@@ -38,3 +38,5 @@ APNs 配置 JSON 使用原文件的 `packetName` 作为 topic、`pwd` 解锁同
 生产切换须先完成 App 端配套和真机验收。使用包含上述资源的受控构建,将 `BUSINESS_PUSH_MODE` 设为 `native`,按实例逐一重启并检查启动及推送错误日志;一条通知只会选择当前实例的一个通道,滚动重启期间不同实例可能短暂处于不同模式。若原生投递异常,改回 `BUSINESS_PUSH_MODE=uni` 并逐一重启;之后的新通知重新使用 `cid`,已失败的通知不会由服务端自动补发。回退时确认 App 仍维护有效的 uniPush CID。
 
 自动化测试验证三端 classpath 凭据可加载、路由、请求构造和错误隔离;真实设备 token、FCM/APNs 外部投递、通知展示与回退操作仍需部署环境验证。
+
+外卖和闪送新单通知附近最多 20 名骑手时,`native` 模式会按骑手当前设备类型分流:Android 骑手按相同语言文案和订单 payload 使用骑手端 FCM multicast,逐 token 处理返回结果;iOS 骑手继续逐设备走 APNs。每位骑手的消息中心事件保持独立,批量失败不回退 uniPush。默认 `uni` 模式及其逐骑手新单通知完全不变,旧的 uni 批量方法未接入此流程。

+ 1 - 0
specs/033-native-push-routing/spec.md

@@ -107,6 +107,7 @@
 - **FR-010**: 对现有会入库的推送,通道切换 MUST NOT 增加或漏掉 `push_message` 记录;消息中心记录与外部发送结果保持解耦。
 - **FR-011**: 实现范围 MUST 排除音视频通话推送、已废弃订单/支付入口及 App 端代码;不新增推送业务场景或改变登录互斥规则。
 - **FR-012**: 本功能 MUST 沿用现有 `info_user` 单设备字段,不新增数据库字段或设备表。
+- **FR-013**: 外卖和闪送新单向多名骑手开放时,仅在 `native` 模式对 Android 骑手按相同标题、正文及 payload 分组,使用骑手端 FCM multicast 发送;iOS 骑手保留逐设备 APNs 请求,`uni` 模式保留原有逐骑手发送方式,不接入旧批量方法。各骑手的语言、消息中心记录和发送失败处理 MUST 保持独立,原生失败不得回退 uniPush。
 
 ### Key Entities *(include if feature involves data)*
 

+ 6 - 0
specs/033-native-push-routing/tasks.md

@@ -34,3 +34,9 @@ T001–T002 → T003/T004 → T005–T006 → T007 → T008–T009。FCM 与 APN
 - [x] T010 更新三端资源命名和配置:每端一份独立 Firebase `server.json`、一份 APNs 普通证书、一份 `packetName/pwd` 配置,名称含 `user`/`merchant`/`rider`;实际文件提交 Git,默认 `uni` 不加载。
 - [x] T011 测试先行支持 classpath 读取 FCM JSON、APNs 配置和 `.p12`,保持原有外部路径兼容;开发/正式只切换 APNs 网关。
 - [x] T012 更新部署说明并运行 JDK 21 定向测试、模块构建和资源打包检查;仅暂存本功能代码、文档与三端凭据后提交,真机外发仍单独验证。
+
+## 2026-09-29 追加:仅原生通道的多骑手新单批量
+
+- [x] T013 先写失败测试:骑手端 FCM multicast 保留业务 payload 与逐 token 结果;原生外卖、闪送新单进入批量分支,uni 仍逐骑手发送。
+- [x] T014 Android 骑手按已本地化文案和 payload 分组批量发送,逐人发布消息中心事件;iOS 保留原有逐设备异步发送,失败不回退 uni。
+- [x] T015 更新操作文档,运行 JDK 21 定向与回归测试、模块构建,核对变更及暂存范围。