Browse Source

商家原生新订单推送按订单类型选择语音

qmj 3 hours ago
parent
commit
ef8e32e0d7

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

@@ -233,8 +233,8 @@ public class OmgPaymentNotifyService {
         } 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);
+            merchantNotificationRouter.sendStoreNewOrder(paidOrder.getMdId(), paidOrder.getShId(),
+                    "no.message.push.message", "no.message.push.new.order", body, orderNo, paidOrder.getType());
         }
     }
 

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

@@ -109,9 +109,9 @@ public class DeliveryOrderNotificationService {
                 || activeOrders.isEmpty()) throw new IllegalArgumentException("Invalid multi-store notification");
         for (PosOrder order : activeOrders) {
             if (merchantNotificationRouter != null) {
-                merchantNotificationRouter.sendStoreNotification(order.getMdId(), order.getShId(),
+                merchantNotificationRouter.sendStoreNewOrder(order.getMdId(), order.getShId(),
                         "no.message.push.message", "no.message.push.new.order",
-                        multiStoreBody(order, groupId, eventId), order.getDdId());
+                        multiStoreBody(order, groupId, eventId), order.getDdId(), order.getType());
             }
         }
         if (!waiting) return;
@@ -245,8 +245,8 @@ public class DeliveryOrderNotificationService {
         runAfterCommit(() -> {
             String ddId = String.valueOf(order.getDdId());
             String body = OrderPushBodyDto.getJson(ddId, String.valueOf(order.getState()), 0);
-            merchantNotificationRouter.sendStoreNotification(order.getMdId(), order.getShId(),
-                    "no.message.push.message", "no.message.push.new.order", body, ddId);
+            merchantNotificationRouter.sendStoreNewOrder(order.getMdId(), order.getShId(),
+                    "no.message.push.message", "no.message.push.new.order", body, ddId, order.getType());
         });
     }
 

+ 27 - 3
ruoyi-admin/src/main/java/com/ruoyi/app/order/MerchantNotificationRouter.java

@@ -1,5 +1,7 @@
 package com.ruoyi.app.order;
 
+import com.alibaba.fastjson2.JSON;
+import com.alibaba.fastjson2.JSONObject;
 import com.ruoyi.app.user.MerchantTokenSessionService;
 import com.ruoyi.app.push.BusinessPushService;
 import com.ruoyi.app.push.RecipientApp;
@@ -53,6 +55,20 @@ public class MerchantNotificationRouter {
     public void sendStoreNotification(Long storeId, Long ownerUserId,
                                       String titleKey, String contentKey,
                                       String body, String orderNo) {
+        sendStoreNotification(storeId, ownerUserId, titleKey, contentKey, body, orderNo, null);
+    }
+
+    /** Only native new-order pushes receive a voice scene; persisted and uni bodies stay unchanged. */
+    public void sendStoreNewOrder(Long storeId, Long ownerUserId, String titleKey, String contentKey,
+                                  String body, String orderNo, Long orderType) {
+        String scene = Long.valueOf(0L).equals(orderType) ? "NEW_ORDER_DELIVERY"
+                : Long.valueOf(1L).equals(orderType) ? "NEW_ORDER_PICKUP"
+                : Long.valueOf(2L).equals(orderType) ? "NEW_ORDER_DINE_IN" : null;
+        sendStoreNotification(storeId, ownerUserId, titleKey, contentKey, body, orderNo, scene);
+    }
+
+    private void sendStoreNotification(Long storeId, Long ownerUserId, String titleKey, String contentKey,
+                                       String body, String orderNo, String voiceScene) {
         StoreRoute route = resolveRoute(storeId, ownerUserId);
         Long resolvedOwnerId = route.ownerUserId();
         InfoUser owner = resolvedOwnerId == null ? null : infoUserService.getById(resolvedOwnerId);
@@ -78,7 +94,7 @@ public class MerchantNotificationRouter {
 
         Set<String> pushedAddresses = new LinkedHashSet<>();
         for (InfoUser recipient : recipients.values()) {
-            sendToRecipient(recipient, titleKey, contentKey, body, orderNo, pushedAddresses);
+            sendToRecipient(recipient, titleKey, contentKey, body, orderNo, voiceScene, pushedAddresses);
         }
     }
 
@@ -120,7 +136,7 @@ public class MerchantNotificationRouter {
     }
 
     private void sendToRecipient(InfoUser recipient, String titleKey, String contentKey,
-                                 String body, String orderNo, Set<String> pushedAddresses) {
+                                 String body, String orderNo, String voiceScene, Set<String> pushedAddresses) {
         String title = localize(titleKey, recipient.getUserId());
         String content = localize(contentKey, recipient.getUserId());
         if (StringUtils.isNotEmpty(orderNo)) {
@@ -137,8 +153,16 @@ public class MerchantNotificationRouter {
             return;
         }
         try {
+            String pushBody = body;
+            if (voiceScene != null && businessPushService.isNativeMode()) {
+                JSONObject nativeBody = JSON.parseObject(body);
+                if (nativeBody != null) {
+                    nativeBody.put("eventScene", voiceScene);
+                    pushBody = nativeBody.toJSONString();
+                }
+            }
             businessPushService.send(RecipientApp.MERCHANT, recipient.getUserId(), recipient.getCid(),
-                    title, content, body);
+                    title, content, pushBody);
         } catch (Exception exception) {
             log.error("Store notification external push failed, userId={}", recipient.getUserId(), exception);
         }

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

@@ -472,8 +472,8 @@ public class UserOrderController extends BaseController {
                 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);
+                        merchantNotificationRouter.sendStoreNewOrder(posOrder.getMdId(), posOrder.getShId(),
+                                "no.message.push.message", "no.message.push.new.order", body, subddId, posOrder.getType());
                     }
                 });
             }

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

@@ -73,8 +73,8 @@ public class LinePayOrderNotificationService {
             if (Long.valueOf(0L).equals(order.getType())) {
                 deliveryOrderNotificationService.notifyOrderAvailable(order);
             } else {
-                merchantNotificationRouter.sendStoreNotification(order.getMdId(), order.getShId(),
-                        "no.message.push.message", "no.message.push.new.order", body, ddId);
+                merchantNotificationRouter.sendStoreNewOrder(order.getMdId(), order.getShId(),
+                        "no.message.push.message", "no.message.push.new.order", body, ddId, order.getType());
             }
         } 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/push/BusinessPushService.java

@@ -81,6 +81,10 @@ public class BusinessPushService {
         return exception.getClass().getSimpleName();
     }
 
+    public boolean isNativeMode() {
+        return properties.getMode() == BusinessPushProperties.Mode.NATIVE;
+    }
+
     public String addressKey(InfoUser recipient) {
         if (recipient == null) {
             return null;

+ 2 - 1
ruoyi-admin/src/main/java/com/ruoyi/app/push/NativePushSound.java

@@ -7,6 +7,7 @@ public record NativePushSound(String resourceName) {
     }
 
     public String channelId() {
-        return "user_voice_" + resourceName + "_v1";
+        return (resourceName.startsWith("new_order_") ? "merchant_voice_" : "user_voice_")
+                + resourceName + "_v1";
     }
 }

+ 11 - 2
ruoyi-admin/src/main/java/com/ruoyi/app/push/NativePushSoundResolver.java

@@ -14,6 +14,8 @@ import java.util.Set;
 public class NativePushSoundResolver {
     private static final Set<String> SCENES = Set.of("RIDER_ACCEPTED", "ORDER_DELIVERING", "ORDER_DELIVERED",
             "MERCHANT_ACCEPTED", "MERCHANT_READY", "PAYMENT_SUCCEEDED", "ORDER_CANCELLED");
+    private static final Set<String> MERCHANT_NEW_ORDER_SCENES = Set.of(
+            "NEW_ORDER_DELIVERY", "NEW_ORDER_DINE_IN", "NEW_ORDER_PICKUP");
     private static final Set<String> STOP_EVENTS = Set.of("FLASH_PICKUP_STOP_COMPLETED", "FLASH_DELIVERY_STOP_COMPLETED");
     private final JedisFactory jedisFactory;
 
@@ -26,6 +28,9 @@ public class NativePushSoundResolver {
         if (scene == null) {
             return null;
         }
+        if (app == RecipientApp.MERCHANT) {
+            return new NativePushSound(scene.toLowerCase(Locale.ROOT));
+        }
         String language = null;
         if (userId != null) {
             // 与 /changeLanguages 保持同一 DB 和原始字符串格式,不经过 JSON Redis 反序列化。
@@ -40,11 +45,12 @@ public class NativePushSoundResolver {
 
     public NativePushSound forTest(RecipientApp app, String language, String payload) {
         String scene = scene(app, payload);
-        return scene == null ? null : sound(scene, language);
+        return scene == null ? null : app == RecipientApp.MERCHANT
+                ? new NativePushSound(scene.toLowerCase(Locale.ROOT)) : sound(scene, language);
     }
 
     private static String scene(RecipientApp app, String payload) {
-        if (app != RecipientApp.USER || payload == null) {
+        if (payload == null || (app != RecipientApp.USER && app != RecipientApp.MERCHANT)) {
             return null;
         }
         try {
@@ -53,6 +59,9 @@ public class NativePushSoundResolver {
                 return null;
             }
             String scene = fields.getString("eventScene");
+            if (app == RecipientApp.MERCHANT) {
+                return scene != null && MERCHANT_NEW_ORDER_SCENES.contains(scene) ? scene : null;
+            }
             if (scene != null && SCENES.contains(scene)) {
                 return scene;
             }

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

@@ -168,9 +168,9 @@ class DeliveryOrderNotificationServiceTest {
         service.notifyOrderAvailable(order);
 
         verify(positions, never()).getAcceptRiderList(order.getLongitude(), order.getLatitude(), 20);
-        verify(router).sendStoreNotification(9L, 101L,
+        verify(router).sendStoreNewOrder(9L, 101L,
                 "no.message.push.message", "no.message.push.new.order",
-                OrderPushBodyDto.getJson("DD-DELIVERY", "0", 0), "DD-DELIVERY");
+                OrderPushBodyDto.getJson("DD-DELIVERY", "0", 0), "DD-DELIVERY", 0L);
     }
 
     @Test

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

@@ -69,6 +69,29 @@ class MerchantNotificationRouterTest {
         verify(pushService).send(RecipientApp.MERCHANT, 502L, "cid-b", "title", "content,NO:DD-1", "body");
     }
 
+    @org.junit.jupiter.params.ParameterizedTest
+    @org.junit.jupiter.params.provider.CsvSource({"0,NEW_ORDER_DELIVERY", "1,NEW_ORDER_PICKUP", "2,NEW_ORDER_DINE_IN"})
+    void nativeNewOrderAddsSceneOnlyToExternalPush(long orderType, String expectedScene) {
+        when(pushService.isNativeMode()).thenReturn(true);
+        String body = "{\"ddId\":\"DD-1\",\"type\":0}";
+        router.sendStoreNewOrder(9L, 101L, "title", "content", body, "DD-1", orderType);
+        verify(events).PublisherEvent(101L, "title", "content,NO:DD-1", body);
+        var pushed = org.mockito.ArgumentCaptor.forClass(String.class);
+        verify(pushService).send(eq(RecipientApp.MERCHANT), eq(101L), eq("owner-cid"),
+                eq("title"), eq("content,NO:DD-1"), pushed.capture());
+        org.junit.jupiter.api.Assertions.assertEquals(expectedScene,
+                com.alibaba.fastjson2.JSON.parseObject(pushed.getValue()).getString("eventScene"));
+    }
+
+    @Test
+    void uniNewOrderKeepsOriginalPayload() {
+        String body = "{\"ddId\":\"DD-1\",\"type\":0}";
+        router.sendStoreNewOrder(9L, 101L, "title", "content", body, "DD-1", 0L);
+        verify(events).PublisherEvent(101L, "title", "content,NO:DD-1", body);
+        verify(pushService).send(RecipientApp.MERCHANT, 101L, "owner-cid",
+                "title", "content,NO:DD-1", body);
+    }
+
     @Test
     void storesOneMessagePerAccountButPushesSharedCidOnlyOnce() {
         InfoUser first = subaccount(501L, "shared-cid");

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

@@ -101,7 +101,7 @@ class LinePayOrderNotificationServiceTest {
         org.junit.jupiter.api.Assertions.assertEquals("PAYMENT_SUCCEEDED", json.getString("eventScene"));
         org.junit.jupiter.api.Assertions.assertEquals(0, json.getIntValue("type"));
         var merchantBody = org.mockito.ArgumentCaptor.forClass(String.class);
-        verify(merchants).sendStoreNotification(eq(null), eq(null), anyString(), anyString(), merchantBody.capture(), eq("DD-1"));
+        verify(merchants).sendStoreNewOrder(eq(null), eq(null), anyString(), anyString(), merchantBody.capture(), eq("DD-1"), eq(order.getType()));
         org.junit.jupiter.api.Assertions.assertFalse(com.alibaba.fastjson.JSON.parseObject(merchantBody.getValue()).containsKey("eventScene"));
     }
 }

+ 24 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/push/NativePushSoundTest.java

@@ -55,6 +55,30 @@ class NativePushSoundTest {
         verifyNoInteractions(cache);
     }
 
+    @org.junit.jupiter.params.ParameterizedTest
+    @org.junit.jupiter.params.provider.CsvSource({"NEW_ORDER_DELIVERY,new_order_delivery.wav", "NEW_ORDER_DINE_IN,new_order_dine_in.wav", "NEW_ORDER_PICKUP,new_order_pickup.wav"})
+    void merchantNativeNewOrderSelectsBundledSoundWithoutLanguageLookup(String scene, String file) {
+        NativePushSound sound = sounds.forRecipient(RecipientApp.MERCHANT, 9L, scene(scene));
+        assertEquals(file, sound.fileName());
+        assertEquals("merchant_voice_" + file.replace(".wav", "") + "_v1", sound.channelId());
+        verifyNoInteractions(cache);
+    }
+
+    @Test
+    void merchantSoundReachesApnsAndFcmNotificationFields() throws Exception {
+        String payload = scene("NEW_ORDER_DINE_IN");
+        NativePushSound sound = sounds.forTest(RecipientApp.MERCHANT, null, payload);
+        var apns = JSON.parseObject(ApnsPushSender.buildBody(RecipientApp.MERCHANT,
+                "title", "body", payload, sound));
+        assertEquals("new_order_dine_in.wav", apns.getJSONObject("aps").getString("sound"));
+        Message fcm = FcmPushSender.buildMessage(RecipientApp.MERCHANT,
+                "token", "title", "body", payload, sound);
+        Object notification = field(field(fcm, "androidConfig"), "notification");
+        assertEquals("new_order_dine_in", field(notification, "sound"));
+        assertEquals("merchant_voice_new_order_dine_in_v1", field(notification, "channelId"));
+        assertEquals("new_order_dine_in.wav", ((Map<?, ?>) field(fcm, "data")).get("sound"));
+    }
+
     @Test
     void readsRawRecipientPreferenceAndFallsBackOnMissingOrFailedCache() {
         when(cache.create()).thenReturn(jedis);

+ 15 - 0
specs/035-user-notification-voice/merchant-new-order-native-sound.md

@@ -0,0 +1,15 @@
+# 商家 App 原生新订单提示音接入(2026-10-29)
+
+本次只调整服务端商家 FCM/APNs 原生推送;不改 uni 推送和消息中心,不新增第二条通知,也不提供音频下载。App 已有三个本地 WAV 文件。
+
+| 订单 `type` | 原生 payload `eventScene` | APNs `aps.sound` / FCM `sound` 数据 | Android 通知渠道 ID |
+|---|---|---|---|
+| 0 外送 | `NEW_ORDER_DELIVERY` | `new_order_delivery.wav` | `merchant_voice_new_order_delivery_v1` |
+| 1 自取 | `NEW_ORDER_PICKUP` | `new_order_pickup.wav` | `merchant_voice_new_order_pickup_v1` |
+| 2 堂食 | `NEW_ORDER_DINE_IN` | `new_order_dine_in.wav` | `merchant_voice_new_order_dine_in_v1` |
+
+`eventScene` 只附加到商家**新订单**原生推送的业务 `payload` JSON 中;原有 `ddId`、`state`、`type=0`、`pushType=1` 等字段不变。原生通知还在顶层带 `sound`,Android FCM 数据另带 `channelId`。其他事件及无法识别的订单类型使用原有默认声音。商家子账号与店主收到的同一新单按同一规则选音。App 前台可按 `eventScene` 选择已打包音频,避免按订单文字或状态猜测。
+
+Android 8+ 应在 App 启动时预建上述三个渠道,并将渠道声音分别指向 `res/raw/new_order_delivery`、`new_order_pickup`、`new_order_dine_in`;已存在渠道的声音设置无法由服务器更改,必要时由 App 使用新渠道 ID。iOS 应将三个 WAV 放入 App 主包;APNs `aps.sound` 直接给出文件名。服务器只传参数,不能代替 App 打包或创建渠道。
+
+验证边界:后端夹具可验证参数和声音映射;真实前台、后台、锁屏是否播放仍需用已打包的商家 App 在 Android/iOS 真机上验收。

+ 5 - 0
specs/035-user-notification-voice/plan.md

@@ -1,5 +1,10 @@
 # 用户通知 eventScene Implementation Plan
 
+## 2026-10-29 商家原生新单语音计划
+
+在各新单发送点显式传 `PosOrder.type`,由 `MerchantNotificationRouter` 只为原生外发 body 附加场景;入库/uni 保留原 body。`NativePushSoundResolver` 仅接受商家三个新单场景,沿现有 FCM/APNs sound 通道输出本地 WAV 文件名和 Android 渠道 ID。定向验证三类映射、uni 隔离、非新单兼容以及现有新单发送测试。
+
+
 ## 2026-10-26 原生语音追加计划(覆盖历史前台范围)
 
 1. 编写场景/语言、缓存与两端声音载荷及测试入口用例,复现缺少声音映射的失败。

+ 5 - 0
specs/035-user-notification-voice/spec.md

@@ -1,5 +1,10 @@
 # Feature Specification: 用户业务通知语音提醒
 
+## 2026-10-29 商家原生新单语音追加
+
+仅商家 App 的 FCM/APNs 新订单通知按订单 `type` 选声音:0 外送 `new_order_delivery.wav`、1 自取 `new_order_pickup.wav`、2 堂食 `new_order_dine_in.wav`。原生业务 payload 增加 `eventScene=NEW_ORDER_DELIVERY|NEW_ORDER_PICKUP|NEW_ORDER_DINE_IN`;uni 推送与消息中心 body、非新单通知、用户/骑手声音保持原样。App 已有对应 WAV;Android 需预建相应 `merchant_voice_<文件名去扩展名>_v1` 通知渠道,iOS 将 WAV 打入主包。详见 [merchant-new-order-native-sound.md](merchant-new-order-native-sound.md)。本节覆盖下方历史“商家新单铃声不改”的范围限制。
+
+
 ## 2026-10-26 追加需求:原生系统通知语音
 
 本节为最新确认范围,覆盖下文历史版本中“只前台、不改音频/系统声音”的原生通道限制;uniPush 仍保持原实现。后端与资源实现进度见 tasks,真机播放待 App 接入验收。

+ 8 - 0
specs/035-user-notification-voice/tasks.md

@@ -1,5 +1,13 @@
 # Tasks: 用户通知 eventScene 与前端交接
 
+## 2026-10-29 商家原生新单语音任务
+
+- [x] 核对三类 `PosOrder.type` 及新单发送入口。
+- [x] 原生商家新单附加场景并映射本地 WAV;uni 与消息中心保留原 body。
+- [x] 补充映射/隔离单测和 App 接入说明。
+- [x] JDK 21 定向测试及 `ruoyi-admin` 模块 package 通过:MerchantNotificationRouterTest 12、NativePushSoundTest 37、DeliveryOrderNotificationServiceTest 7、LinePayOrderNotificationServiceTest 2、UserOrderNotificationSceneTest 16、OmgPaymentNotifyServiceTest 15,共 89 项,0 失败/错误/跳过;`git diff --check` 通过。未部署,未做 Android/iOS 真机播放验收。
+
+
 ## 2026-10-26 原生前台/后台/锁屏语音追加
 
 **进度:实现中(本地验证已通过,未提交)。** 以下范围覆盖历史“只前台、不修改音频”的原生部分;uni 不变。