Răsfoiți Sursa

合并测试分支到自建地图分支

qmj 1 săptămână în urmă
părinte
comite
23d808d03a
100 a modificat fișierele cu 1782 adăugiri și 196 ștergeri
  1. 6 0
      ruoyi-admin/pom.xml
  2. 2 0
      ruoyi-admin/src/main/java/com/ruoyi/app/order/MultiStoreCheckoutQueryService.java
  3. 28 0
      ruoyi-admin/src/main/java/com/ruoyi/app/order/PosOrderController.java
  4. 2 0
      ruoyi-admin/src/main/java/com/ruoyi/app/order/PosOrderQsOprateController.java
  5. 2 0
      ruoyi-admin/src/main/java/com/ruoyi/app/order/dto/MultiStoreCardView.java
  6. 2 0
      ruoyi-admin/src/main/java/com/ruoyi/app/order/dto/MultiStoreMemberView.java
  7. 5 0
      ruoyi-admin/src/main/java/com/ruoyi/app/order/dto/OrderImGroupRequest.java
  8. 106 71
      ruoyi-admin/src/main/java/com/ruoyi/app/push/ApnsPushSender.java
  9. 10 2
      ruoyi-admin/src/main/java/com/ruoyi/app/push/BusinessPushService.java
  10. 24 2
      ruoyi-admin/src/main/java/com/ruoyi/app/push/FcmPushSender.java
  11. 12 0
      ruoyi-admin/src/main/java/com/ruoyi/app/push/NativePushSound.java
  12. 94 0
      ruoyi-admin/src/main/java/com/ruoyi/app/push/NativePushSoundResolver.java
  13. 12 3
      ruoyi-admin/src/main/java/com/ruoyi/app/push/NativePushTestController.java
  14. 10 0
      ruoyi-admin/src/main/java/com/ruoyi/app/push/NativePushTestRequest.java
  15. 22 20
      ruoyi-admin/src/main/resources/application.yml
  16. 2 0
      ruoyi-admin/src/main/resources/i18n/messages.properties
  17. 2 0
      ruoyi-admin/src/main/resources/i18n/messages_en_US.properties
  18. 2 0
      ruoyi-admin/src/main/resources/i18n/messages_th_TH.properties
  19. 2 0
      ruoyi-admin/src/main/resources/i18n/messages_vi.properties
  20. 2 0
      ruoyi-admin/src/main/resources/i18n/messages_zh_CN.properties
  21. 2 0
      ruoyi-admin/src/main/resources/i18n/messages_zh_TW.properties
  22. 2 1
      ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryNotificationServiceTest.java
  23. 5 0
      ruoyi-admin/src/test/java/com/ruoyi/app/order/MultiStoreCheckoutQueryServiceTest.java
  24. 146 0
      ruoyi-admin/src/test/java/com/ruoyi/app/order/OrderImGroupControllerTest.java
  25. 25 0
      ruoyi-admin/src/test/java/com/ruoyi/app/order/PosOrderQsOprateControllerTest.java
  26. 86 0
      ruoyi-admin/src/test/java/com/ruoyi/app/order/UserInvoiceAuthResponseTest.java
  27. 50 72
      ruoyi-admin/src/test/java/com/ruoyi/app/push/ApnsPushSenderTest.java
  28. 166 0
      ruoyi-admin/src/test/java/com/ruoyi/app/push/ApnsPushyTransportTest.java
  29. 1 1
      ruoyi-admin/src/test/java/com/ruoyi/app/push/BusinessPushServiceTest.java
  30. 12 0
      ruoyi-admin/src/test/java/com/ruoyi/app/push/FcmPushSenderTest.java
  31. 145 0
      ruoyi-admin/src/test/java/com/ruoyi/app/push/NativePushSoundTest.java
  32. 5 4
      ruoyi-admin/src/test/java/com/ruoyi/app/push/NativePushTestControllerTest.java
  33. 4 0
      ruoyi-system/src/main/java/com/ruoyi/system/domain/PosOrder.java
  34. 30 2
      ruoyi-system/src/main/java/com/ruoyi/system/utils/AuthAspect.java
  35. 5 1
      ruoyi-system/src/main/resources/mapper/system/PosOrderMapper.xml
  36. 53 0
      ruoyi-system/src/test/java/com/ruoyi/system/mapper/PosOrderImGroupMapperTest.java
  37. 3 0
      specs/010-order-invoice/plan.md
  38. 6 0
      specs/010-order-invoice/spec.md
  39. 5 0
      specs/010-order-invoice/tasks.md
  40. 34 0
      specs/012-im-user-integration/contracts/order-im-group.md
  41. 4 0
      specs/012-im-user-integration/data-model.md
  42. 7 0
      specs/012-im-user-integration/plan.md
  43. 4 0
      specs/012-im-user-integration/quickstart.md
  44. 8 0
      specs/012-im-user-integration/spec.md
  45. 9 0
      specs/012-im-user-integration/tasks.md
  46. 20 2
      specs/033-native-push-routing/plan.md
  47. 24 13
      specs/033-native-push-routing/quickstart.md
  48. 20 2
      specs/033-native-push-routing/spec.md
  49. 34 0
      specs/033-native-push-routing/tasks.md
  50. 7 0
      specs/035-user-notification-voice/assets/native-wav/README.md
  51. BIN
      specs/035-user-notification-voice/assets/native-wav/flash_delivery_stop_completed_en.wav
  52. BIN
      specs/035-user-notification-voice/assets/native-wav/flash_delivery_stop_completed_id.wav
  53. BIN
      specs/035-user-notification-voice/assets/native-wav/flash_delivery_stop_completed_ms.wav
  54. BIN
      specs/035-user-notification-voice/assets/native-wav/flash_delivery_stop_completed_th.wav
  55. BIN
      specs/035-user-notification-voice/assets/native-wav/flash_delivery_stop_completed_tl.wav
  56. BIN
      specs/035-user-notification-voice/assets/native-wav/flash_delivery_stop_completed_zh_hans.wav
  57. BIN
      specs/035-user-notification-voice/assets/native-wav/flash_delivery_stop_completed_zh_hant.wav
  58. BIN
      specs/035-user-notification-voice/assets/native-wav/flash_pickup_stop_completed_en.wav
  59. BIN
      specs/035-user-notification-voice/assets/native-wav/flash_pickup_stop_completed_id.wav
  60. BIN
      specs/035-user-notification-voice/assets/native-wav/flash_pickup_stop_completed_ms.wav
  61. BIN
      specs/035-user-notification-voice/assets/native-wav/flash_pickup_stop_completed_th.wav
  62. BIN
      specs/035-user-notification-voice/assets/native-wav/flash_pickup_stop_completed_tl.wav
  63. BIN
      specs/035-user-notification-voice/assets/native-wav/flash_pickup_stop_completed_zh_hans.wav
  64. BIN
      specs/035-user-notification-voice/assets/native-wav/flash_pickup_stop_completed_zh_hant.wav
  65. 513 0
      specs/035-user-notification-voice/assets/native-wav/manifest.json
  66. BIN
      specs/035-user-notification-voice/assets/native-wav/merchant_accepted_en.wav
  67. BIN
      specs/035-user-notification-voice/assets/native-wav/merchant_accepted_id.wav
  68. BIN
      specs/035-user-notification-voice/assets/native-wav/merchant_accepted_ms.wav
  69. BIN
      specs/035-user-notification-voice/assets/native-wav/merchant_accepted_th.wav
  70. BIN
      specs/035-user-notification-voice/assets/native-wav/merchant_accepted_tl.wav
  71. BIN
      specs/035-user-notification-voice/assets/native-wav/merchant_accepted_zh_hans.wav
  72. BIN
      specs/035-user-notification-voice/assets/native-wav/merchant_accepted_zh_hant.wav
  73. BIN
      specs/035-user-notification-voice/assets/native-wav/merchant_ready_en.wav
  74. BIN
      specs/035-user-notification-voice/assets/native-wav/merchant_ready_id.wav
  75. BIN
      specs/035-user-notification-voice/assets/native-wav/merchant_ready_ms.wav
  76. BIN
      specs/035-user-notification-voice/assets/native-wav/merchant_ready_th.wav
  77. BIN
      specs/035-user-notification-voice/assets/native-wav/merchant_ready_tl.wav
  78. BIN
      specs/035-user-notification-voice/assets/native-wav/merchant_ready_zh_hans.wav
  79. BIN
      specs/035-user-notification-voice/assets/native-wav/merchant_ready_zh_hant.wav
  80. BIN
      specs/035-user-notification-voice/assets/native-wav/order_cancelled_en.wav
  81. BIN
      specs/035-user-notification-voice/assets/native-wav/order_cancelled_id.wav
  82. BIN
      specs/035-user-notification-voice/assets/native-wav/order_cancelled_ms.wav
  83. BIN
      specs/035-user-notification-voice/assets/native-wav/order_cancelled_th.wav
  84. BIN
      specs/035-user-notification-voice/assets/native-wav/order_cancelled_tl.wav
  85. BIN
      specs/035-user-notification-voice/assets/native-wav/order_cancelled_zh_hans.wav
  86. BIN
      specs/035-user-notification-voice/assets/native-wav/order_cancelled_zh_hant.wav
  87. BIN
      specs/035-user-notification-voice/assets/native-wav/order_delivered_en.wav
  88. BIN
      specs/035-user-notification-voice/assets/native-wav/order_delivered_id.wav
  89. BIN
      specs/035-user-notification-voice/assets/native-wav/order_delivered_ms.wav
  90. BIN
      specs/035-user-notification-voice/assets/native-wav/order_delivered_th.wav
  91. BIN
      specs/035-user-notification-voice/assets/native-wav/order_delivered_tl.wav
  92. BIN
      specs/035-user-notification-voice/assets/native-wav/order_delivered_zh_hans.wav
  93. BIN
      specs/035-user-notification-voice/assets/native-wav/order_delivered_zh_hant.wav
  94. BIN
      specs/035-user-notification-voice/assets/native-wav/order_delivering_en.wav
  95. BIN
      specs/035-user-notification-voice/assets/native-wav/order_delivering_id.wav
  96. BIN
      specs/035-user-notification-voice/assets/native-wav/order_delivering_ms.wav
  97. BIN
      specs/035-user-notification-voice/assets/native-wav/order_delivering_th.wav
  98. BIN
      specs/035-user-notification-voice/assets/native-wav/order_delivering_tl.wav
  99. BIN
      specs/035-user-notification-voice/assets/native-wav/order_delivering_zh_hans.wav
  100. BIN
      specs/035-user-notification-voice/assets/native-wav/order_delivering_zh_hant.wav

+ 6 - 0
ruoyi-admin/pom.xml

@@ -95,6 +95,12 @@
             <artifactId>spring-boot-starter-test</artifactId>
             <scope>test</scope>
         </dependency>
+        <dependency>
+            <!-- 与已验证的 qtw-push-server 保持同一 APNs SDK,替代手写 HTTP/2 发送。 -->
+            <groupId>com.turo</groupId>
+            <artifactId>pushy</artifactId>
+            <version>0.13.10</version>
+        </dependency>
         <dependency>
             <groupId>com.google.firebase</groupId>
             <artifactId>firebase-admin</artifactId>

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

@@ -151,6 +151,7 @@ public class MultiStoreCheckoutQueryService {
                 if ("ORDER".equals(view.getCardType())) {
                     attachOrderContext(view.getOrderId(), originals, storeMap, userMap, longitude, latitude,
                             view::setLegacyOrder, view::setStore, view::setUser, view::setJuli, view::setGoodsCount);
+                    view.setImGroupId(view.getLegacyOrder() == null ? null : view.getLegacyOrder().getImGroupId());
                 } else {
                     int cardGoods = 0;
                     boolean anyGoods = false;
@@ -158,6 +159,7 @@ public class MultiStoreCheckoutQueryService {
                         attachOrderContext(member.getOrderId(), originals, storeMap, userMap, longitude, latitude,
                                 member::setLegacyOrder, member::setStore, member::setUser, member::setJuli,
                                 member::setGoodsCount);
+                        member.setImGroupId(member.getLegacyOrder() == null ? null : member.getLegacyOrder().getImGroupId());
                         // 组没有单一配送里程;逐子单暴露旧详情的 jvli,缺失快照不臆造距离。
                         member.setJvli(member.getLegacyOrder() == null ? null : member.getLegacyOrder().getJvli());
                         if (member.getGoodsCount() != null) {

+ 28 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/order/PosOrderController.java

@@ -13,6 +13,7 @@ import com.fasterxml.jackson.databind.SerializationFeature;
 import com.ruoyi.app.order.dto.OrderPositionInfo;
 import com.ruoyi.app.order.dto.OrderPushBodyDto;
 import com.ruoyi.app.order.dto.PositionDto;
+import com.ruoyi.app.order.dto.OrderImGroupRequest;
 import com.ruoyi.app.order.dto.QsDto;
 import com.ruoyi.app.order.dto.AdminOrderActionRequest;
 import com.ruoyi.app.order.dto.AdminOrderStatusContext;
@@ -675,6 +676,7 @@ public class PosOrderController extends BaseController {
         JSONObject org = JSON.parseObject(
             JSON.toJSONStringWithDateFormat(orlist, "yyyy-MM-dd HH:mm:ss")
         );
+        org.put("imGroupId", orlist.getImGroupId());
         // 以下为非PosOrder字段或需要特殊处理的字段
         org.put("shanghu", infoUserService.getById(orlist.getShId()));
         org.put("store", posStoreService.getById(orlist.getMdId()));
@@ -745,6 +747,7 @@ public class PosOrderController extends BaseController {
         JSONObject org = JSON.parseObject(
             JSON.toJSONStringWithDateFormat(orlist, "yyyy-MM-dd HH:mm:ss")
         );
+        org.put("imGroupId", orlist.getImGroupId());
         // 以下为非PosOrder字段或需要特殊处理的字段
         org.put("shanghu", infoUserService.getById(orlist.getShId()));
         org.put("store", posStoreService.getById(orlist.getMdId()));
@@ -765,6 +768,29 @@ public class PosOrderController extends BaseController {
         return success(org);
     }
 
+    /** 保存群聊关系只修改这一列,不经废弃的整单编辑接口,避免覆盖订单履约或支付状态。 */
+    @Anonymous
+    @Auth
+    @PostMapping("/imGroup")
+    public AjaxResult saveImGroup(@RequestHeader String token, @RequestBody OrderImGroupRequest input) {
+        if (input == null || input.ddId() == null || input.ddId().isBlank()
+                || input.imGroupId() == null || input.imGroupId().isBlank()
+                || input.imGroupId().codePointCount(0, input.imGroupId().length()) > 200) {
+            throw new ServiceException(MessageUtils.message("order.im.group.invalid"));
+        }
+        PosOrder order = posOrderService.getOne(new LambdaQueryWrapper<PosOrder>()
+                .eq(PosOrder::getDdId, input.ddId()));
+        // 同一订单的用户、当前接单骑手和有门店权限的商家共用详情访问规则。
+        requireOrderDetailAccess(order);
+        if (Objects.equals(order.getImGroupId(), input.imGroupId())) {
+            return success();
+        }
+        PosOrder patch = new PosOrder();
+        patch.setId(order.getId());
+        patch.setImGroupId(input.imGroupId());
+        return toAjax(posOrderService.updatePosOrder(patch));
+    }
+
     private void requireOrderDetailAccess(PosOrder order) {
         Long loginUserId = AuthContext.requireUserId();
         InfoUser actor = infoUserService.getById(loginUserId);
@@ -863,6 +889,7 @@ public class PosOrderController extends BaseController {
             org.put("user", userMap.get(order.getUserId()));
             org.put("jvli", jl.format(order.getJvli()));
             org.put("juli", jl.format(order.getJuli()));
+            org.put("imGroupId", order.getImGroupId());
             org.put("food", JSONArray.parseArray(order.getFood()));
             arr.add(org);
         }
@@ -904,6 +931,7 @@ public class PosOrderController extends BaseController {
             org.put("qishou", userMap.get(order.getQsId()));
             org.put("jvli", jl.format(order.getJvli()));
             org.put("qsjvli", jl.format(order.getJuli()));
+            org.put("imGroupId", order.getImGroupId());
             org.put("food", JSONArray.parseArray(order.getFood()));
             org.put("sqImg", org.remove("qsImg"));
             arr.add(org);

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

@@ -426,6 +426,7 @@ public class PosOrderQsOprateController extends BaseController {
                     JSON.toJSONStringWithDateFormat(order, "yyyy-MM-dd HH:mm:ss")
             );
             org.put("juli",null);
+            org.put("imGroupId", order.getImGroupId());
             // 计算距离(km)
             if (order.getLongitude() != null && order.getLatitude() != null && longitude != null && latitude != null) {
                 double lat1 = Math.toRadians(order.getLatitude().doubleValue());
@@ -577,6 +578,7 @@ public class PosOrderQsOprateController extends BaseController {
                 JSON.toJSONStringWithDateFormat(order, "yyyy-MM-dd HH:mm:ss")
         );
         org.put("juli", null);
+        org.put("imGroupId", order.getImGroupId());
         org.put("store", order.getMdId() == null ? null : posStoreService.getById(order.getMdId().intValue()));
         org.put("user", order.getUserId() == null ? null : infoUserService.getById(order.getUserId()));
         return success(org);

+ 2 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/order/dto/MultiStoreCardView.java

@@ -16,6 +16,8 @@ public class MultiStoreCardView {
     private Long orderId;
     /** 普通单沿用原骑手/用户列表的完整订单字段,不把旧单强行压缩为 036 子单摘要。 */
     private PosOrder legacyOrder;
+    /** 普通单的 IM 群聊标识;多店组的群聊按 members 中的真实子单分别返回。 */
+    private String imGroupId;
     /** 普通单卡与旧骑手列表逐项对齐的门店、用户与直线距离;组卡/批次卡无单一对应,保持空。 */
     private PosStore store;
     private InfoUser user;

+ 2 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/order/dto/MultiStoreMemberView.java

@@ -26,6 +26,8 @@ public class MultiStoreMemberView {
     private Integer codToMerchantAmount;
     /** 与旧骑手列表逐单展示对齐的附加信息;无对应数据时保持空。 */
     private PosOrder legacyOrder;
+    /** 本子单对应的 IM 群聊标识,不能与配送组 groupId 混用。 */
+    private String imGroupId;
     private PosStore store;
     private InfoUser user;
     private Double juli;

+ 5 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/order/dto/OrderImGroupRequest.java

@@ -0,0 +1,5 @@
+package com.ruoyi.app.order.dto;
+
+/** App 完成 IM 建群后,按真实订单号保存群聊标识。 */
+public record OrderImGroupRequest(String ddId, String imGroupId) {
+}

+ 106 - 71
ruoyi-admin/src/main/java/com/ruoyi/app/push/ApnsPushSender.java

@@ -2,38 +2,42 @@ package com.ruoyi.app.push;
 
 import com.alibaba.fastjson2.JSON;
 import com.alibaba.fastjson2.JSONObject;
+import com.turo.pushy.apns.ApnsClient;
+import com.turo.pushy.apns.ApnsClientBuilder;
+import com.turo.pushy.apns.DeliveryPriority;
+import com.turo.pushy.apns.PushNotificationResponse;
+import com.turo.pushy.apns.PushType;
+import com.turo.pushy.apns.util.SimpleApnsPushNotification;
+import io.netty.channel.nio.NioEventLoopGroup;
 import jakarta.annotation.PostConstruct;
 import jakarta.annotation.PreDestroy;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import org.springframework.stereotype.Component;
 
-import javax.net.ssl.KeyManagerFactory;
-import javax.net.ssl.SSLContext;
-import java.io.IOException;
+import java.io.ByteArrayInputStream;
 import java.io.InputStream;
-import java.net.URI;
-import java.net.http.HttpClient;
-import java.net.http.HttpRequest;
-import java.net.http.HttpResponse;
 import java.nio.charset.StandardCharsets;
 import java.security.KeyStore;
 import java.security.PrivateKey;
 import java.security.cert.X509Certificate;
-import java.time.Duration;
-import java.util.EnumMap;
+import java.util.Date;
 import java.util.List;
 import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
 
 @Component
 public class ApnsPushSender {
     private static final Logger log = LoggerFactory.getLogger(ApnsPushSender.class);
     private static final List<String> BUSINESS_FIELDS = List.of("ddId", "state", "type", "pushType");
-    private static final String PRODUCTION_HOST = "api.push.apple.com";
-    private static final String DEVELOPMENT_HOST = "api.sandbox.push.apple.com";
-
     private final BusinessPushProperties properties;
-    private final Map<RecipientApp, HttpClient> clients = new EnumMap<>(RecipientApp.class);
+    // Pushy 客户端绑定网关,按环境隔离,避免复用与当前配置不符的连接。
+    private record ClientKey(RecipientApp app, BusinessPushProperties.ApnsEnvironment environment) { }
+    private final Map<ClientKey, ApnsClient> clients = new ConcurrentHashMap<>();
+    private NioEventLoopGroup eventLoopGroup;
 
     public ApnsPushSender(BusinessPushProperties properties) {
         this.properties = properties;
@@ -41,14 +45,12 @@ public class ApnsPushSender {
 
     @PostConstruct
     public synchronized void initialize() {
-        if (properties.getMode() != BusinessPushProperties.Mode.NATIVE || !clients.isEmpty()) {
+        if (properties.getMode() != BusinessPushProperties.Mode.NATIVE) {
             return;
         }
         try {
             for (RecipientApp app : RecipientApp.values()) {
-                BusinessPushProperties.AppProperties config = properties.getApp(app);
-                loadApnsConfig(app, config);
-                clients.put(app, createClient(app, config));
+                initializeClient(app, properties.getApnsEnvironment());
             }
         } catch (RuntimeException exception) {
             close();
@@ -82,11 +84,16 @@ public class ApnsPushSender {
         }
     }
 
-    protected HttpClient createClient(RecipientApp app, BusinessPushProperties.AppProperties config) {
+    protected ApnsClient createClient(RecipientApp app, BusinessPushProperties.ApnsEnvironment environment,
+                                      BusinessPushProperties.AppProperties config) {
         try {
             char[] password = config.getApnsCertificatePassword().toCharArray();
             KeyStore store = KeyStore.getInstance("PKCS12");
+            byte[] certificateBytes;
             try (InputStream stream = PushCredentialResource.open(config.getApnsCertificatePath())) {
+                certificateBytes = stream.readAllBytes();
+            }
+            try (InputStream stream = new ByteArrayInputStream(certificateBytes)) {
                 store.load(stream, password);
             }
             boolean validPrivateKey = false;
@@ -101,15 +108,20 @@ public class ApnsPushSender {
             if (!validPrivateKey) {
                 throw new IllegalStateException("APNs certificate has no private key for " + app);
             }
-            KeyManagerFactory keyManagers = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm());
-            keyManagers.init(store, password);
-            SSLContext sslContext = SSLContext.getInstance("TLS");
-            sslContext.init(keyManagers.getKeyManagers(), null, null);
-            return HttpClient.newBuilder()
-                    .version(HttpClient.Version.HTTP_2)
-                    .connectTimeout(Duration.ofSeconds(5))
-                    .sslContext(sslContext)
-                    .build();
+            // 三端共用少量 I/O 线程,各自保持独立证书与连接,不逐条创建客户端。
+            if (eventLoopGroup == null) {
+                eventLoopGroup = new NioEventLoopGroup(2);
+            }
+            try (InputStream stream = new ByteArrayInputStream(certificateBytes)) {
+                return new ApnsClientBuilder()
+                        .setApnsServer(environment == BusinessPushProperties.ApnsEnvironment.DEVELOPMENT
+                                ? ApnsClientBuilder.DEVELOPMENT_APNS_HOST : ApnsClientBuilder.PRODUCTION_APNS_HOST)
+                        .setClientCredentials(stream, config.getApnsCertificatePassword())
+                        .setEventLoopGroup(eventLoopGroup)
+                        .setConnectionTimeout(5, TimeUnit.SECONDS)
+                        .setGracefulShutdownTimeout(3, TimeUnit.SECONDS)
+                        .build();
+            }
         } catch (Exception exception) {
             throw new IllegalStateException("Cannot initialize APNs certificate for " + app);
         }
@@ -124,20 +136,30 @@ public class ApnsPushSender {
     }
 
     public void send(RecipientApp app, String token, String title, String content, String payload) {
-        String host = properties.getApnsEnvironment() == BusinessPushProperties.ApnsEnvironment.DEVELOPMENT
-                ? DEVELOPMENT_HOST : PRODUCTION_HOST;
-        sendToHost(app, token, title, content, payload, host);
+        send(app, token, title, content, payload, null);
+    }
+
+    public void send(RecipientApp app, String token, String title, String content, String payload,
+                     NativePushSound sound) {
+        sendToClient(app, token, title, content, payload, properties.getApnsEnvironment(), sound);
     }
 
-    /** Test delivery always uses the production gateway and does not change the business push mode. */
-    public void sendForTestProduction(RecipientApp app, String token, String title,
-                                      String content, String payload) {
-        initializeForTest(app);
-        sendToHost(app, token, title, content, payload, PRODUCTION_HOST);
+    /** 原生测试也遵循所配 APNs 环境;只按需加载目标端,不切换业务推送通道。 */
+    public void sendForTest(RecipientApp app, String token, String title,
+                            String content, String payload) {
+        sendForTest(app, token, title, content, payload, null);
     }
 
-    private synchronized void initializeForTest(RecipientApp app) {
-        if (clients.containsKey(app)) {
+    public void sendForTest(RecipientApp app, String token, String title,
+                            String content, String payload, NativePushSound sound) {
+        BusinessPushProperties.ApnsEnvironment environment = properties.getApnsEnvironment();
+        initializeClient(app, environment);
+        sendToClient(app, token, title, content, payload, environment, sound);
+    }
+
+    private synchronized void initializeClient(RecipientApp app, BusinessPushProperties.ApnsEnvironment environment) {
+        ClientKey key = new ClientKey(app, environment);
+        if (clients.containsKey(key)) {
             return;
         }
         BusinessPushProperties.AppProperties config = properties.getApp(app);
@@ -146,54 +168,72 @@ public class ApnsPushSender {
             throw new IllegalStateException("APNS_CONFIG_MISSING");
         }
         loadApnsConfig(app, config);
-        clients.put(app, createClient(app, config));
+        if (config.getApnsCertificatePassword() == null || config.getApnsTopic() == null
+                || config.getApnsTopic().isBlank() || environment == null) {
+            throw new IllegalStateException("APNS_CONFIG_MISSING");
+        }
+        try {
+            clients.put(key, createClient(app, environment, config));
+        } catch (RuntimeException exception) {
+            if (clients.isEmpty()) {
+                close();
+            }
+            throw exception;
+        }
     }
 
-    private void sendToHost(RecipientApp app, String token, String title, String content,
-                            String payload, String host) {
-        HttpClient client = clients.get(app);
+    private void sendToClient(RecipientApp app, String token, String title, String content,
+                              String payload, BusinessPushProperties.ApnsEnvironment environment,
+                              NativePushSound sound) {
+        ApnsClient client = clients.get(new ClientKey(app, environment));
         if (client == null) {
             throw new IllegalStateException("APNS_NOT_INITIALIZED");
         }
         if (token == null || !token.matches("[0-9a-fA-F]{64}")) {
             throw new IllegalStateException("APNS_TOKEN_INVALID");
         }
-        String body = buildBody(app, title, content, payload);
+        String body = buildBody(app, title, content, payload, sound);
         if (body.getBytes(StandardCharsets.UTF_8).length > 4096) {
             throw new IllegalStateException("APNS_PAYLOAD_TOO_LARGE");
         }
-        HttpRequest request = HttpRequest.newBuilder(URI.create("https://" + host + "/3/device/" + token))
-                .version(HttpClient.Version.HTTP_2)
-                .timeout(Duration.ofSeconds(10))
-                .header("apns-topic", properties.getApp(app).getApnsTopic())
-                .header("apns-push-type", "alert")
-                .header("apns-priority", "10")
-                .POST(HttpRequest.BodyPublishers.ofString(body, StandardCharsets.UTF_8))
-                .build();
+        SimpleApnsPushNotification notification = new SimpleApnsPushNotification(token,
+                properties.getApp(app).getApnsTopic(), body, new Date(0), DeliveryPriority.IMMEDIATE, PushType.ALERT);
+        PushNotificationResponse<SimpleApnsPushNotification> response;
         try {
-            HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
-            if (response.statusCode() / 100 != 2) {
-                String category = failureCategory(response);
-                log.warn("APNs push failed, app={}, status={}, category={}", app, response.statusCode(), category);
-                throw new IllegalStateException("APNS_" + category);
-            }
+            // 等待苹果确认,而非像参考工程的异步入口一样仅入队就报告成功。
+            response = client.sendNotification(notification).get(10, TimeUnit.SECONDS);
         } catch (InterruptedException exception) {
             Thread.currentThread().interrupt();
             throw new IllegalStateException("APNS_INTERRUPTED");
-        } catch (IOException exception) {
+        } catch (TimeoutException exception) {
+            // 超时不能证明未发送,不自动重试,避免同一通知重复到达。
+            throw new IllegalStateException("APNS_TIMEOUT");
+        } catch (ExecutionException | RuntimeException exception) {
             throw new IllegalStateException("APNS_TRANSPORT_ERROR");
         }
+        if (!response.isAccepted()) {
+            String category = failureCategory(response.getRejectionReason());
+            log.warn("APNs push failed, app={}, environment={}, category={}", app, environment, category);
+            throw new IllegalStateException("APNS_" + category);
+        }
     }
 
     static String buildBody(RecipientApp app, String title, String content, String payload) {
+        return buildBody(app, title, content, payload, null);
+    }
+
+    static String buildBody(RecipientApp app, String title, String content, String payload, NativePushSound sound) {
         JSONObject alert = new JSONObject();
         alert.put("title", title == null ? "" : title);
         alert.put("body", content == null ? "" : content);
         JSONObject aps = new JSONObject();
         aps.put("alert", alert);
-        aps.put("sound", app == RecipientApp.RIDER ? "pushsound.caf" : "default");
+        aps.put("sound", sound != null ? sound.fileName() : app == RecipientApp.RIDER ? "pushsound.caf" : "default");
         JSONObject body = new JSONObject();
         body.put("aps", aps);
+        if (sound != null) {
+            body.put("sound", sound.fileName());
+        }
         if (payload != null) {
             body.put("payload", payload);
             try {
@@ -213,24 +253,19 @@ public class ApnsPushSender {
         return body.toJSONString();
     }
 
-    private static String failureCategory(HttpResponse<String> response) {
-        try {
-            JSONObject error = JSON.parseObject(response.body());
-            String reason = error == null ? null : error.getString("reason");
-            if (reason != null && reason.matches("[A-Za-z0-9_]{1,64}")) {
-                return reason;
-            }
-        } catch (RuntimeException ignored) {
-            // The HTTP status remains available when APNs omits a JSON reason.
-        }
-        return "HTTP_" + response.statusCode();
+    private static String failureCategory(String reason) {
+        return reason != null && reason.matches("[A-Za-z0-9_]{1,64}") ? reason : "REJECTED";
     }
 
     @PreDestroy
     public synchronized void close() {
-        for (HttpClient client : clients.values()) {
-            client.close();
+        for (ApnsClient client : clients.values()) {
+            client.close().awaitUninterruptibly(5, TimeUnit.SECONDS);
         }
         clients.clear();
+        if (eventLoopGroup != null) {
+            eventLoopGroup.shutdownGracefully(0, 3, TimeUnit.SECONDS).awaitUninterruptibly(5, TimeUnit.SECONDS);
+            eventLoopGroup = null;
+        }
     }
 }

+ 10 - 2
ruoyi-admin/src/main/java/com/ruoyi/app/push/BusinessPushService.java

@@ -18,14 +18,17 @@ public class BusinessPushService {
     private final FcmPushSender fcm;
     private final ApnsPushSender apns;
     private final PayPush uni;
+    private final NativePushSoundResolver sounds;
 
     public BusinessPushService(BusinessPushProperties properties, IInfoUserService users,
-                               FcmPushSender fcm, ApnsPushSender apns, PayPush uni) {
+                               FcmPushSender fcm, ApnsPushSender apns, PayPush uni,
+                               NativePushSoundResolver sounds) {
         this.properties = properties;
         this.users = users;
         this.fcm = fcm;
         this.apns = apns;
         this.uni = uni;
+        this.sounds = sounds;
     }
 
     public void send(RecipientApp app, Long userId, String cid,
@@ -53,7 +56,12 @@ public class BusinessPushService {
                         app, userId, platform);
                 return;
             }
-            if ("android".equals(platform)) {
+            NativePushSound sound = sounds.forRecipient(app, userId, payload);
+            if (sound != null && "android".equals(platform)) {
+                fcm.send(app, current.getDeviceToken(), title, content, payload, sound);
+            } else if (sound != null) {
+                apns.send(app, current.getDeviceToken(), title, content, payload, sound);
+            } else if ("android".equals(platform)) {
                 fcm.send(app, current.getDeviceToken(), title, content, payload);
             } else {
                 apns.send(app, current.getDeviceToken(), title, content, payload);

+ 24 - 2
ruoyi-admin/src/main/java/com/ruoyi/app/push/FcmPushSender.java

@@ -87,6 +87,11 @@ public class FcmPushSender {
     }
 
     public void send(RecipientApp app, String token, String title, String content, String payload) {
+        send(app, token, title, content, payload, null);
+    }
+
+    public void send(RecipientApp app, String token, String title, String content, String payload,
+                     NativePushSound sound) {
         FirebaseMessaging client = messaging.get(app);
         if (client == null) {
             throw new IllegalStateException("FCM_NOT_INITIALIZED");
@@ -95,7 +100,7 @@ public class FcmPushSender {
             throw new IllegalStateException("FCM_TOKEN_MISSING");
         }
         try {
-            client.send(buildMessage(app, token, title, content, payload));
+            client.send(buildMessage(app, token, title, content, payload, sound));
         } catch (FirebaseMessagingException exception) {
             MessagingErrorCode code = exception.getMessagingErrorCode();
             String category = code == null ? "UNKNOWN" : code.name();
@@ -106,8 +111,13 @@ public class FcmPushSender {
 
     /** Test delivery initializes only the selected app, without changing the business push mode. */
     public void sendForTest(RecipientApp app, String token, String title, String content, String payload) {
+        sendForTest(app, token, title, content, payload, null);
+    }
+
+    public void sendForTest(RecipientApp app, String token, String title, String content, String payload,
+                            NativePushSound sound) {
         initializeForTest(app);
-        send(app, token, title, content, payload);
+        send(app, token, title, content, payload, sound);
     }
 
     private synchronized void initializeForTest(RecipientApp app) {
@@ -184,12 +194,21 @@ public class FcmPushSender {
     }
 
     static Message buildMessage(RecipientApp app, String token, String title, String content, String payload) {
+        return buildMessage(app, token, title, content, payload, null);
+    }
+
+    static Message buildMessage(RecipientApp app, String token, String title, String content, String payload,
+                                NativePushSound sound) {
         String safeTitle = title == null ? "" : title;
         String safeContent = content == null ? "" : content;
         AndroidNotification.Builder androidNotification = AndroidNotification.builder().setDefaultSound(true);
         if (app == RecipientApp.RIDER) {
             androidNotification.setChannelId("qsRing");
         }
+        if (sound != null) {
+            // Android 8+ 最终使用 App 预建渠道的声音,sound 同时兼容旧系统。
+            androidNotification.setDefaultSound(false).setSound(sound.resourceName()).setChannelId(sound.channelId());
+        }
         Message.Builder message = Message.builder()
                 .setToken(token)
                 .setNotification(Notification.builder().setTitle(safeTitle).setBody(safeContent).build())
@@ -197,6 +216,9 @@ public class FcmPushSender {
                         .setNotification(androidNotification.build()).build())
                 .putData("title", safeTitle)
                 .putData("body", safeContent);
+        if (sound != null) {
+            message.putData("sound", sound.fileName()).putData("channelId", sound.channelId());
+        }
         if (payload != null) {
             message.putData("payload", payload);
             try {

+ 12 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/push/NativePushSound.java

@@ -0,0 +1,12 @@
+package com.ruoyi.app.push;
+
+/** 两端共用 WAV 文件名;Android 原始资源名不带扩展名,渠道固定绑定该声音。 */
+public record NativePushSound(String resourceName) {
+    public String fileName() {
+        return resourceName + ".wav";
+    }
+
+    public String channelId() {
+        return "user_voice_" + resourceName + "_v1";
+    }
+}

+ 94 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/push/NativePushSoundResolver.java

@@ -0,0 +1,94 @@
+package com.ruoyi.app.push;
+
+import com.alibaba.fastjson2.JSON;
+import com.alibaba.fastjson2.JSONObject;
+import com.ruoyi.system.utils.JedisFactory;
+import org.springframework.stereotype.Component;
+import redis.clients.jedis.Jedis;
+
+import java.util.Locale;
+import java.util.Set;
+
+/** 仅处理原生用户通知;从明确场景选择随包音频,不根据状态或通知文案猜测声音。 */
+@Component
+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> STOP_EVENTS = Set.of("FLASH_PICKUP_STOP_COMPLETED", "FLASH_DELIVERY_STOP_COMPLETED");
+    private final JedisFactory jedisFactory;
+
+    public NativePushSoundResolver(JedisFactory jedisFactory) {
+        this.jedisFactory = jedisFactory;
+    }
+
+    public NativePushSound forRecipient(RecipientApp app, Long userId, String payload) {
+        String scene = scene(app, payload);
+        if (scene == null) {
+            return null;
+        }
+        String language = null;
+        if (userId != null) {
+            // 与 /changeLanguages 保持同一 DB 和原始字符串格式,不经过 JSON Redis 反序列化。
+            try (Jedis jedis = jedisFactory.create()) {
+                language = jedis.get(userId.toString());
+            } catch (RuntimeException ignored) {
+                // 缺失或暂时读不到语言不阻断通知,统一回退繁体中文。
+            }
+        }
+        return sound(scene, language);
+    }
+
+    public NativePushSound forTest(RecipientApp app, String language, String payload) {
+        String scene = scene(app, payload);
+        return scene == null ? null : sound(scene, language);
+    }
+
+    private static String scene(RecipientApp app, String payload) {
+        if (app != RecipientApp.USER || payload == null) {
+            return null;
+        }
+        try {
+            JSONObject fields = JSON.parseObject(payload);
+            if (fields == null) {
+                return null;
+            }
+            String scene = fields.getString("eventScene");
+            if (scene != null && SCENES.contains(scene)) {
+                return scene;
+            }
+            String event = fields.getString("eventType");
+            return event != null && STOP_EVENTS.contains(event) ? event : null;
+        } catch (RuntimeException ignored) {
+            return null;
+        }
+    }
+
+    private static NativePushSound sound(String scene, String language) {
+        return new NativePushSound(scene.toLowerCase(Locale.ROOT) + "_" + languageSuffix(language));
+    }
+
+    private static String languageSuffix(String language) {
+        if (language == null || language.isBlank()) {
+            return "zh_hant";
+        }
+        String tag = language.trim().replace('_', '-').toLowerCase(Locale.ROOT);
+        if (tag.equals("zhhant") || tag.equals("tw") || tag.equals("zhtw")
+                || tag.equals("zh-tw") || tag.equals("zh-hk") || tag.equals("zh-mo")
+                || tag.equals("zh-hant") || tag.startsWith("zh-hant-")) {
+            return "zh_hant";
+        }
+        if (tag.equals("zhhans") || tag.equals("cn") || tag.equals("zhcn")
+                || tag.equals("zh-cn") || tag.equals("zh-sg")
+                || tag.equals("zh-hans") || tag.startsWith("zh-hans-")) {
+            return "zh_hans";
+        }
+        return switch (tag.split("-", 2)[0]) {
+            case "en" -> "en";
+            case "th" -> "th";
+            case "id", "in" -> "id";
+            case "ms" -> "ms";
+            case "tl", "fil" -> "tl";
+            default -> "zh_hant";
+        };
+    }
+}

+ 12 - 3
ruoyi-admin/src/main/java/com/ruoyi/app/push/NativePushTestController.java

@@ -19,10 +19,12 @@ public class NativePushTestController {
 
     private final FcmPushSender fcm;
     private final ApnsPushSender apns;
+    private final NativePushSoundResolver sounds;
 
-    public NativePushTestController(FcmPushSender fcm, ApnsPushSender apns) {
+    public NativePushTestController(FcmPushSender fcm, ApnsPushSender apns, NativePushSoundResolver sounds) {
         this.fcm = fcm;
         this.apns = apns;
+        this.sounds = sounds;
     }
 
     /** Explicit test path: does not read an account or change the ordinary business push mode. */
@@ -45,11 +47,18 @@ public class NativePushTestController {
             return AjaxResult.error(MessageUtils.message("native.push.test.invalid"));
         }
         try {
-            if ("android".equals(platform)) {
+            NativePushSound sound = sounds.forTest(app, request.getLanguage(), request.getPayload());
+            if (sound != null && "android".equals(platform)) {
+                fcm.sendForTest(app, request.getDeviceToken(), request.getTitle(),
+                        request.getContent(), request.getPayload(), sound);
+            } else if (sound != null) {
+                apns.sendForTest(app, request.getDeviceToken(), request.getTitle(),
+                        request.getContent(), request.getPayload(), sound);
+            } else if ("android".equals(platform)) {
                 fcm.sendForTest(app, request.getDeviceToken(), request.getTitle(),
                         request.getContent(), request.getPayload());
             } else {
-                apns.sendForTestProduction(app, request.getDeviceToken(), request.getTitle(),
+                apns.sendForTest(app, request.getDeviceToken(), request.getTitle(),
                         request.getContent(), request.getPayload());
             }
             return AjaxResult.success(MessageUtils.message("native.push.test.accepted"));

+ 10 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/push/NativePushTestRequest.java

@@ -8,6 +8,16 @@ public class NativePushTestRequest {
     private String title;
     private String content;
     private String payload;
+    /** 可选,原生用户语音测试使用;缺省或无法识别时使用繁体中文。 */
+    private String language;
+
+    public String getLanguage() {
+        return language;
+    }
+
+    public void setLanguage(String language) {
+        this.language = language;
+    }
 
     public String getAppType() {
         return appType;

+ 22 - 20
ruoyi-admin/src/main/resources/application.yml

@@ -51,29 +51,31 @@ omgpay:
     retry-delay-ms: 300000
     round-budget-seconds: 45
 
-# 普通业务推送通道(默认沿用 uniPush)
+# 普通业务推送配置固定在本地;三端密码/topic来自各自APNs JSON的pwd/packetName。
+# 当前业务通道仍沿用uniPush,uni、native测试接口可独立发送原生推送;切换通道需直接修改mode并重启。
 business-push:
-  mode: "${BUSINESS_PUSH_MODE:uni}"
-  apns-environment: "${BUSINESS_PUSH_APNS_ENVIRONMENT:production}"
+  mode: native
+  # 当前测试包使用 sandbox;正式签名包(含 TestFlight/App Store)部署时改为 production。
+  apns-environment: development
   apps:
     user:
-      fcm-credentials-path: "${BUSINESS_PUSH_USER_FCM_CREDENTIALS_PATH:classpath:business-push/user-fcm-service-account.json}"
-      apns-certificate-path: "${BUSINESS_PUSH_USER_APNS_CERTIFICATE_PATH:classpath:business-push/user-apns.p12}"
-      apns-config-path: "${BUSINESS_PUSH_USER_APNS_CONFIG_PATH:classpath:business-push/user-apns-config.json}"
-      apns-certificate-password: "${BUSINESS_PUSH_USER_APNS_CERTIFICATE_PASSWORD:}"
-      apns-topic: "${BUSINESS_PUSH_USER_APNS_TOPIC:}"
+      fcm-credentials-path: "classpath:business-push/user-fcm-service-account.json"
+      apns-certificate-path: "classpath:business-push/user-apns.p12"
+      apns-config-path: "classpath:business-push/user-apns-config.json"
+      apns-certificate-password: "1234"
+      apns-topic: "com.twanmsdyh.app"
     merchant:
-      fcm-credentials-path: "${BUSINESS_PUSH_MERCHANT_FCM_CREDENTIALS_PATH:classpath:business-push/merchant-fcm-service-account.json}"
-      apns-certificate-path: "${BUSINESS_PUSH_MERCHANT_APNS_CERTIFICATE_PATH:classpath:business-push/merchant-apns.p12}"
-      apns-config-path: "${BUSINESS_PUSH_MERCHANT_APNS_CONFIG_PATH:classpath:business-push/merchant-apns-config.json}"
-      apns-certificate-password: "${BUSINESS_PUSH_MERCHANT_APNS_CERTIFICATE_PASSWORD:}"
-      apns-topic: "${BUSINESS_PUSH_MERCHANT_APNS_TOPIC:}"
+      fcm-credentials-path: "classpath:business-push/merchant-fcm-service-account.json"
+      apns-certificate-path: "classpath:business-push/merchant-apns.p12"
+      apns-config-path: "classpath:business-push/merchant-apns-config.json"
+      apns-certificate-password: "1234"
+      apns-topic: "com.twanmsdsj.app"
     rider:
-      fcm-credentials-path: "${BUSINESS_PUSH_RIDER_FCM_CREDENTIALS_PATH:classpath:business-push/rider-fcm-service-account.json}"
-      apns-certificate-path: "${BUSINESS_PUSH_RIDER_APNS_CERTIFICATE_PATH:classpath:business-push/rider-apns.p12}"
-      apns-config-path: "${BUSINESS_PUSH_RIDER_APNS_CONFIG_PATH:classpath:business-push/rider-apns-config.json}"
-      apns-certificate-password: "${BUSINESS_PUSH_RIDER_APNS_CERTIFICATE_PASSWORD:}"
-      apns-topic: "${BUSINESS_PUSH_RIDER_APNS_TOPIC:}"
+      fcm-credentials-path: "classpath:business-push/rider-fcm-service-account.json"
+      apns-certificate-path: "classpath:business-push/rider-apns.p12"
+      apns-config-path: "classpath:business-push/rider-apns-config.json"
+      apns-certificate-password: "1234"
+      apns-topic: "com.twanmsdqs.app"
 
 # IM 即时沟通配置
 im:
@@ -192,7 +194,7 @@ spring:
       # 数据库索引
       database: 1
       # 可选:仅需在启用密码的环境设置 REDIS_PASSWORD;Lettuce、Redisson 和裸 Jedis 共用
-      password:
+      password: 123456
       # 连接超时时间
       timeout: 10s
       lettuce:
@@ -208,7 +210,7 @@ spring:
   redisson:
     address: redis://127.0.0.1:6379
     # 密码沿用 spring.data.redis.password,未配置时免密连接
-
+    password: 123456
 # token配置
 token:
     # 令牌自定义标识

+ 2 - 0
ruoyi-admin/src/main/resources/i18n/messages.properties

@@ -510,3 +510,5 @@ map.merchantSync.invalid=商家地图同步参数无效
 map.merchantSync.invalidStore=门店地图资料无效或门店已下架
 map.merchantSync.storeMissing=门店不存在
 map.merchantSync.unavailable=门店地图同步失败,请稍后重试
+
+order.im.group.invalid=订单号和群聊标识不能为空,群聊标识不能超过200个字符

+ 2 - 0
ruoyi-admin/src/main/resources/i18n/messages_en_US.properties

@@ -496,3 +496,5 @@ map.merchantSync.invalid=Invalid merchant map synchronization request
 map.merchantSync.invalidStore=The store has invalid or unavailable map information
 map.merchantSync.storeMissing=The store does not exist
 map.merchantSync.unavailable=Unable to synchronize the store with the map service
+
+order.im.group.invalid=Order number and chat group ID are required; the chat group ID must not exceed 200 characters

+ 2 - 0
ruoyi-admin/src/main/resources/i18n/messages_th_TH.properties

@@ -497,3 +497,5 @@ map.merchantSync.invalid=คำขอซิงค์แผนที่ร้า
 map.merchantSync.invalidStore=ข้อมูลแผนที่ร้านค้าไม่ถูกต้องหรือร้านถูกซ่อน
 map.merchantSync.storeMissing=ไม่พบร้านค้า
 map.merchantSync.unavailable=ไม่สามารถซิงค์ร้านค้ากับแผนที่ได้ โปรดลองอีกครั้ง
+
+order.im.group.invalid=ต้องระบุหมายเลขคำสั่งซื้อและรหัสกลุ่มแชต โดยรหัสกลุ่มแชตต้องไม่เกิน 200 ตัวอักษร

+ 2 - 0
ruoyi-admin/src/main/resources/i18n/messages_vi.properties

@@ -34,3 +34,5 @@ map.merchantSync.invalid=Tham số đồng bộ cửa hàng với bản đồ kh
 map.merchantSync.invalidStore=Thông tin bản đồ của cửa hàng không hợp lệ hoặc cửa hàng đã bị ẩn
 map.merchantSync.storeMissing=Cửa hàng không tồn tại
 map.merchantSync.unavailable=Không thể đồng bộ cửa hàng với bản đồ, vui lòng thử lại
+
+order.im.group.invalid=Mã đơn hàng và mã nhóm trò chuyện không được để trống; mã nhóm không được vượt quá 200 ký tự

+ 2 - 0
ruoyi-admin/src/main/resources/i18n/messages_zh_CN.properties

@@ -498,3 +498,5 @@ map.merchantSync.invalid=商家地图同步参数无效
 map.merchantSync.invalidStore=门店地图资料无效或门店已下架
 map.merchantSync.storeMissing=门店不存在
 map.merchantSync.unavailable=门店地图同步失败,请稍后重试
+
+order.im.group.invalid=订单号和群聊标识不能为空,群聊标识不能超过200个字符

+ 2 - 0
ruoyi-admin/src/main/resources/i18n/messages_zh_TW.properties

@@ -497,3 +497,5 @@ map.merchantSync.invalid=商家地圖同步參數無效
 map.merchantSync.invalidStore=門店地圖資料無效或門店已下架
 map.merchantSync.storeMissing=門店不存在
 map.merchantSync.unavailable=門店地圖同步失敗,請稍後再試
+
+order.im.group.invalid=訂單編號和群聊識別碼不可空白,群聊識別碼不可超過200個字元

+ 2 - 1
ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryNotificationServiceTest.java

@@ -498,7 +498,8 @@ class FlashDeliveryNotificationServiceTest {
             IInfoUserService currentUsers = mock(IInfoUserService.class);
             when(currentUsers.getById(7L)).thenReturn(sender);
             BusinessPushService router = new BusinessPushService(properties, currentUsers,
-                    mock(FcmPushSender.class), mock(ApnsPushSender.class), uni);
+                    mock(FcmPushSender.class), mock(ApnsPushSender.class), uni,
+                    mock(com.ruoyi.app.push.NativePushSoundResolver.class));
             IPushMessageService repository = mock(IPushMessageService.class);
             doAnswer(call -> {
                 if (persistenceFailure) throw new IllegalStateException("message storage unavailable");

+ 5 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/order/MultiStoreCheckoutQueryServiceTest.java

@@ -179,10 +179,12 @@ class MultiStoreCheckoutQueryServiceTest {
         memberOrder.setId(71L); memberOrder.setMdId(5L); memberOrder.setUserId(21L);
         memberOrder.setLongitude(riderLng); memberOrder.setLatitude(riderLat);
         memberOrder.setFood("[{\"name\":\"A\",\"number\":2},{\"name\":\"B\",\"number\":1}]");
+        memberOrder.setImGroupId("member-room");
         PosOrder ordinaryOrder = new PosOrder();
         ordinaryOrder.setId(9L); ordinaryOrder.setMdId(5L); ordinaryOrder.setUserId(21L);
         ordinaryOrder.setLongitude(riderLng); ordinaryOrder.setLatitude(riderLat);
         ordinaryOrder.setFood("[{\"name\":\"C\",\"number\":4}]");
+        ordinaryOrder.setImGroupId("ordinary-room");
         when(ordinaryOrders.listByIds(List.of(9L, 71L))).thenReturn(List.of(memberOrder, ordinaryOrder));
         PosStore store = new PosStore();
         store.setId(5);
@@ -200,12 +202,15 @@ class MultiStoreCheckoutQueryServiceTest {
         assertEquals(0.0, groupCard.getMembers().get(0).getJuli());
         assertEquals(3, groupCard.getMembers().get(0).getGoodsCount());
         assertEquals(3, groupCard.getGoodsCount());
+        assertEquals("member-room", groupCard.getMembers().get(0).getImGroupId());
+        assertEquals("member-room", groupCard.getMembers().get(0).getLegacyOrder().getImGroupId());
         MultiStoreCardView orderCard = result.records().get(1);
         assertEquals(9L, orderCard.getLegacyOrder().getId());
         assertEquals(5, orderCard.getStore().getId());
         assertEquals(21L, orderCard.getUser().getUserId());
         assertEquals(0.0, orderCard.getJuli());
         assertEquals(4, orderCard.getGoodsCount());
+        assertEquals("ordinary-room", orderCard.getImGroupId());
     }
 
     @Test

+ 146 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/order/OrderImGroupControllerTest.java

@@ -0,0 +1,146 @@
+package com.ruoyi.app.order;
+
+import com.baomidou.mybatisplus.core.conditions.Wrapper;
+import com.ruoyi.app.order.dto.OrderImGroupRequest;
+import com.ruoyi.common.core.domain.AjaxResult;
+import com.ruoyi.common.exception.ServiceException;
+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.service.IPosOrderService;
+import com.ruoyi.system.service.MerchantStoreAccessService;
+import com.ruoyi.system.utils.AuthContext;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+
+class OrderImGroupControllerTest {
+    private final PosOrderController controller = new PosOrderController();
+    private final IPosOrderService orders = mock(IPosOrderService.class);
+    private final IInfoUserService users = mock(IInfoUserService.class);
+    private final MerchantStoreAccessService merchants = mock(MerchantStoreAccessService.class);
+
+    OrderImGroupControllerTest() {
+        ReflectionTestUtils.setField(controller, "posOrderService", orders);
+        ReflectionTestUtils.setField(controller, "infoUserService", users);
+        ReflectionTestUtils.setField(controller, "merchantStoreAccessService", merchants);
+        ReflectionTestUtils.setField(controller, "linePayOrderGuard", new com.ruoyi.app.pay.LinePayOrderGuard(
+                mock(com.ruoyi.system.service.IPosOrderLinePaymentService.class)));
+    }
+
+    @Test
+    void orderUserRiderAndAuthorizedMerchantCanSaveOnlyGroupField() {
+        try (var auth = mockStatic(AuthContext.class); var messages = mockStatic(MessageUtils.class)) {
+            PosOrder order = order();
+            when(orders.getOne(any(Wrapper.class))).thenReturn(order);
+            when(orders.updatePosOrder(any(PosOrder.class))).thenReturn(1);
+            for (String type : new String[]{"0", "2", "1", "3", "4", "5"}) {
+                long actorId = "0".equals(type) ? 21L : "2".equals(type) ? 33L : 11L;
+                auth.when(AuthContext::requireUserId).thenReturn(actorId);
+                InfoUser actor = new InfoUser();
+                actor.setUserId(actorId);
+                actor.setUserType(type);
+                actor.setStoreId(5L);
+                when(users.getById(actorId)).thenReturn(actor);
+                AjaxResult result = controller.saveImGroup("token", new OrderImGroupRequest("ORDER-1", "im-room"));
+                assertEquals(200, result.get("code"));
+            }
+            ArgumentCaptor<PosOrder> update = ArgumentCaptor.forClass(PosOrder.class);
+            verify(orders, times(6)).updatePosOrder(update.capture());
+            for (PosOrder patch : update.getAllValues()) {
+                assertEquals(7L, patch.getId());
+                assertEquals("im-room", patch.getImGroupId());
+                assertNull(patch.getState());
+                assertNull(patch.getQsId());
+                assertNull(patch.getDdId());
+            }
+            verify(merchants, times(2)).requireOrderAccess(11L, 7L);
+        }
+    }
+
+    @Test
+    void unrelatedUserAndRiderCannotChangeAnotherOrdersGroup() {
+        try (var auth = mockStatic(AuthContext.class); var messages = mockStatic(MessageUtils.class)) {
+            auth.when(AuthContext::requireUserId).thenReturn(99L);
+            when(orders.getOne(any(Wrapper.class))).thenReturn(order());
+            for (String type : new String[]{"0", "2"}) {
+                InfoUser actor = new InfoUser(); actor.setUserId(99L); actor.setUserType(type);
+                when(users.getById(99L)).thenReturn(actor);
+                assertThrows(ServiceException.class,
+                        () -> controller.saveImGroup("token", new OrderImGroupRequest("ORDER-1", "im-room")));
+            }
+            verify(orders, never()).updatePosOrder(any());
+        }
+    }
+
+    @Test
+    void invalidAndOverlengthGroupAreRejectedBeforeQuery() {
+        try (var messages = mockStatic(MessageUtils.class)) {
+            for (OrderImGroupRequest input : new OrderImGroupRequest[]{null,
+                    new OrderImGroupRequest("", "room"), new OrderImGroupRequest("ORDER-1", " "),
+                    new OrderImGroupRequest("ORDER-1", "x".repeat(201))}) {
+                assertThrows(ServiceException.class, () -> controller.saveImGroup("token", input));
+            }
+            verifyNoInteractions(orders);
+        }
+    }
+
+    @Test
+    void acceptsTwoHundredCharactersAndRepeatedValueIsIdempotent() {
+        try (var auth = mockStatic(AuthContext.class); var messages = mockStatic(MessageUtils.class)) {
+            auth.when(AuthContext::requireUserId).thenReturn(21L);
+            InfoUser actor = new InfoUser(); actor.setUserId(21L); actor.setUserType("0");
+            when(users.getById(21L)).thenReturn(actor);
+            PosOrder order = order();
+            String groupId = "群".repeat(200);
+            when(orders.getOne(any(Wrapper.class))).thenReturn(order);
+            when(orders.updatePosOrder(any())).thenReturn(1);
+            assertEquals(200, controller.saveImGroup("token", new OrderImGroupRequest("ORDER-1", groupId)).get("code"));
+            order.setImGroupId(groupId);
+            assertEquals(200, controller.saveImGroup("token", new OrderImGroupRequest("ORDER-1", groupId)).get("code"));
+            verify(orders, times(1)).updatePosOrder(any());
+        }
+    }
+
+    private PosOrder order() {
+        PosOrder order = new PosOrder();
+        order.setId(7L); order.setDdId("ORDER-1"); order.setUserId(21L);
+        order.setQsId(33L); order.setShId(11L); order.setMdId(5L);
+        return order;
+    }
+
+    @Test
+    void sharedOrderDetailReturnsGroupIdentifier() {
+        try (var auth = mockStatic(AuthContext.class); var messages = mockStatic(MessageUtils.class)) {
+            auth.when(AuthContext::requireUserId).thenReturn(21L);
+            PosOrder order = order(); order.setImGroupId("detail-room");
+            order.setQsId(null); order.setFood("[]"); order.setShAddress("{}");
+            when(orders.getOne(any(Wrapper.class))).thenReturn(order);
+            InfoUser user = new InfoUser(); user.setUserId(21L); user.setUserType("0");
+            when(users.getById(21L)).thenReturn(user);
+            ReflectionTestUtils.setField(controller, "posStoreService",
+                    mock(com.ruoyi.system.service.IPosStoreService.class));
+            var ratings = mock(com.ruoyi.system.service.IPosOrderRatingService.class);
+            var ratingMapper = mock(com.ruoyi.system.mapper.PosOrderRatingMapper.class);
+            when(ratings.getBaseMapper()).thenReturn(ratingMapper);
+            when(ratingMapper.selectCount(any(Wrapper.class))).thenReturn(0L);
+            ReflectionTestUtils.setField(controller, "posOrderRatingService", ratings);
+            var promotions = mock(com.ruoyi.system.mapper.PosOrderPromotionMapper.class);
+            when(promotions.selectByOrderId(7L)).thenReturn(java.util.List.of());
+            ReflectionTestUtils.setField(controller, "posOrderPromotionMapper", promotions);
+
+            var data = (com.alibaba.fastjson.JSONObject) controller.getorderxq("token", "ORDER-1").get("data");
+
+            assertEquals("detail-room", data.getString("imGroupId"));
+            order.setImGroupId(null);
+            var unbound = (com.alibaba.fastjson.JSONObject) controller.getorderxq("token", "ORDER-1").get("data");
+            assertTrue(unbound.containsKey("imGroupId"));
+            assertNull(unbound.get("imGroupId"));
+        }
+    }
+}

+ 25 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/order/PosOrderQsOprateControllerTest.java

@@ -97,6 +97,31 @@ class PosOrderQsOprateControllerTest {
         assertTrue(wrapper.getParamNameValuePairs().containsValue(1L));
     }
 
+    @Test
+    void riderOrderListAndDetailReturnImGroupId() {
+        PosOrderQsOprateController controller = new PosOrderQsOprateController();
+        IPosOrderService orders = mock(IPosOrderService.class);
+        IPosStoreService stores = mock(IPosStoreService.class);
+        IInfoUserService users = mock(IInfoUserService.class);
+        ReflectionTestUtils.setField(controller, "posOrderService", orders);
+        ReflectionTestUtils.setField(controller, "posStoreService", stores);
+        ReflectionTestUtils.setField(controller, "infoUserService", users);
+        PosOrder order = new PosOrder(); order.setId(7L); order.setDdId("ORDER-1");
+        order.setMdId(5L); order.setUserId(21L); order.setQsId(88L); order.setImGroupId("rider-room");
+        Page<PosOrder> page = new Page<>(1, 10); page.setRecords(List.of(order));
+        when(orders.page(any(Page.class), any(Wrapper.class))).thenReturn(page);
+        when(orders.getOne(any(Wrapper.class))).thenReturn(order);
+        when(stores.listByIds(any())).thenReturn(List.of());
+        when(users.listByIds(any())).thenReturn(List.of());
+        String token = JwtUtil.token("88", "rider");
+
+        JSONObject list = (JSONObject) controller.orderList(token, 1, 10, "toPickup", null, null).get("data");
+        JSONObject detail = (JSONObject) controller.getOrderDetial(token, "ORDER-1").get("data");
+
+        assertEquals("rider-room", list.getJSONArray("records").getJSONObject(0).getString("imGroupId"));
+        assertEquals("rider-room", detail.getString("imGroupId"));
+    }
+
     @Test
     void acceptOrderRejectsSelfDeliveryOrder() {
         PosOrderQsOprateController controller = new PosOrderQsOprateController();

+ 86 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/order/UserInvoiceAuthResponseTest.java

@@ -0,0 +1,86 @@
+package com.ruoyi.app.order;
+
+import com.github.pagehelper.PageHelper;
+import com.ruoyi.common.core.redis.RedisCache;
+import com.ruoyi.common.utils.MessageUtils;
+import com.ruoyi.system.domain.vo.PosOrderInvoiceVo;
+import com.ruoyi.system.utils.AuthAspect;
+import com.ruoyi.system.utils.JwtUtil;
+import org.junit.jupiter.api.Test;
+import org.springframework.aop.aspectj.annotation.AspectJProxyFactory;
+import org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.mock.web.MockHttpServletResponse;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.*;
+
+/** 使用真实Controller代理复现鉴权失败时的checkcast,而不是直接调用切面绕过代理。 */
+class UserInvoiceAuthResponseTest {
+    private MockHttpServletResponse response;
+    @Test
+    void expiredAndRevokedSessionReturn401WithoutCastingPaginationToAjaxResult() {
+        RedisCache redis = mock(RedisCache.class);
+        OrderInvoiceService invoices = mock(OrderInvoiceService.class);
+        MockHttpServletRequest request = new MockHttpServletRequest();
+        request.addHeader("token", "expired-fixture");
+        UserOrderController controller = proxy(request, redis, invoices);
+        try (var jwt = mockStatic(JwtUtil.class)) {
+            jwt.when(() -> JwtUtil.verifyToken("expired-fixture")).thenReturn(Map.of("valid", false));
+            var result = assertDoesNotThrow(() -> controller.myInvoices("expired-fixture", 1, 10));
+            assertEquals(401, result.getCode());
+            assertEquals(401, response.getStatus());
+            assertEquals(0, result.getTotal());
+            assertTrue(result.getRows().isEmpty());
+            assertFalse(result.getMsg().isBlank());
+            assertEquals(401, controller.loveOrgList("expired-fixture", 1, 20, null).getCode());
+            response.setStatus(200);
+            assertEquals(401, controller.getInvoice("expired-fixture", 123L).get("code"));
+            assertEquals(401, response.getStatus()); // 原AjaxResult接口也是真实HTTP401。
+
+            // 签名有效但会话被顶号/登出,也必须返回分页形状的401,不进入查询。
+            jwt.when(() -> JwtUtil.verifyToken("expired-fixture")).thenReturn(
+                    Map.of("valid", true, "id", "7", "jti", "qtw_tokens:user:7:revoked"));
+            when(redis.hasKey("qtw_tokens:user:7:revoked")).thenReturn(false);
+            response.setStatus(200);
+            assertEquals(401, controller.myInvoices("expired-fixture", 1, 10).getCode());
+            assertEquals(401, response.getStatus());
+            verifyNoInteractions(invoices);
+        }
+    }
+
+    @Test
+    void validSessionKeepsExistingUserScopedRowsTotalAndCode() {
+        RedisCache redis = mock(RedisCache.class);
+        OrderInvoiceService invoices = mock(OrderInvoiceService.class);
+        String token = JwtUtil.token("7", "user");
+        MockHttpServletRequest request = new MockHttpServletRequest();
+        request.addHeader("token", token);
+        PosOrderInvoiceVo invoice = new PosOrderInvoiceVo(); invoice.setOrderId(123L);
+        when(invoices.listMyInvoices(7L)).thenReturn(List.of(invoice));
+        try (var messages = mockStatic(MessageUtils.class, call -> call.getArgument(0))) {
+            var result = proxy(request, redis, invoices).myInvoices(token, 1, 10);
+            assertEquals(200, result.getCode());
+            assertEquals(200, response.getStatus());
+            assertEquals(1, result.getTotal());
+            assertSame(invoice, result.getRows().get(0));
+            verify(invoices).listMyInvoices(7L);
+        } finally { PageHelper.clearPage(); }
+    }
+
+    private UserOrderController proxy(MockHttpServletRequest request, RedisCache redis, OrderInvoiceService invoices) {
+        UserOrderController target = new UserOrderController();
+        ReflectionTestUtils.setField(target, "orderInvoiceService", invoices);
+        AuthAspect aspect = new AuthAspect(redis);
+        aspect.setHttpServletRequest(request);
+        response = new MockHttpServletResponse();
+        aspect.setHttpServletResponse(response);
+        AspectJProxyFactory factory = new AspectJProxyFactory(target);
+        factory.setProxyTargetClass(true);
+        factory.addAspect(aspect);
+        return factory.getProxy();
+    }
+}

+ 50 - 72
ruoyi-admin/src/test/java/com/ruoyi/app/push/ApnsPushSenderTest.java

@@ -2,15 +2,15 @@ package com.ruoyi.app.push;
 
 import com.alibaba.fastjson2.JSON;
 import com.alibaba.fastjson2.JSONObject;
+import com.turo.pushy.apns.ApnsClient;
+import com.turo.pushy.apns.PushNotificationResponse;
+import com.turo.pushy.apns.PushType;
+import com.turo.pushy.apns.util.SimpleApnsPushNotification;
+import com.turo.pushy.apns.util.concurrent.PushNotificationFuture;
+import io.netty.util.concurrent.ImmediateEventExecutor;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.io.TempDir;
 
-import java.io.ByteArrayOutputStream;
-import java.net.http.HttpClient;
-import java.net.http.HttpRequest;
-import java.net.http.HttpResponse;
-import java.nio.ByteBuffer;
-import java.nio.charset.StandardCharsets;
 import java.nio.file.Files;
 import java.nio.file.Path;
 import java.security.KeyStore;
@@ -18,8 +18,6 @@ import java.security.cert.CertificateExpiredException;
 import java.security.cert.X509Certificate;
 import java.util.EnumMap;
 import java.util.Map;
-import java.util.concurrent.CompletableFuture;
-import java.util.concurrent.Flow;
 import java.util.concurrent.TimeUnit;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
@@ -45,13 +43,13 @@ class ApnsPushSenderTest {
         sender.initialize();
         String payload = com.ruoyi.app.order.dto.OrderPushBodyDto.getUserJson("ORDER-1", "2", -1, 3, scene);
         sender.send(RecipientApp.USER, "a".repeat(64), "title", "content", payload);
-        var captured = org.mockito.ArgumentCaptor.forClass(HttpRequest.class);
-        verify(sender.clients.get(RecipientApp.USER)).send(captured.capture(), any());
-        JSONObject body = JSON.parseObject(bodyOf(captured.getValue()));
+        var captured = org.mockito.ArgumentCaptor.forClass(SimpleApnsPushNotification.class);
+        verify(sender.clients.get(RecipientApp.USER)).sendNotification(captured.capture());
+        JSONObject body = JSON.parseObject(captured.getValue().getPayload());
         assertEquals(payload, body.getString("payload"));
         assertFalse(body.containsKey("eventScene"));
         assertEquals("default", body.getJSONObject("aps").getString("sound"));
-        assertEquals("alert", captured.getValue().headers().firstValue("apns-push-type").orElseThrow());
+        assertEquals(PushType.ALERT, captured.getValue().getPushType());
     }
 
     @Test
@@ -64,14 +62,14 @@ class ApnsPushSenderTest {
             sender.send(app, "a".repeat(64), "New order", "Order body",
                     "{\"ddId\":123,\"state\":2,\"type\":0,\"pushType\":\"order\"}");
 
-            org.mockito.ArgumentCaptor<HttpRequest> captured = org.mockito.ArgumentCaptor.forClass(HttpRequest.class);
-            verify(sender.clients.get(app)).send(captured.capture(), any());
-            HttpRequest request = captured.getValue();
-            assertEquals("api.sandbox.push.apple.com", request.uri().getHost());
-            assertEquals("/3/device/" + "a".repeat(64), request.uri().getPath());
-            assertEquals("com.example." + app.name().toLowerCase(), request.headers().firstValue("apns-topic").orElseThrow());
-            assertEquals("alert", request.headers().firstValue("apns-push-type").orElseThrow());
-            JSONObject body = JSON.parseObject(bodyOf(request));
+            var captured = org.mockito.ArgumentCaptor.forClass(SimpleApnsPushNotification.class);
+            verify(sender.clients.get(app)).sendNotification(captured.capture());
+            SimpleApnsPushNotification request = captured.getValue();
+            assertEquals(BusinessPushProperties.ApnsEnvironment.DEVELOPMENT, sender.environments.get(app));
+            assertEquals("a".repeat(64), request.getToken());
+            assertEquals("com.example." + app.name().toLowerCase(), request.getTopic());
+            assertEquals(PushType.ALERT, request.getPushType());
+            JSONObject body = JSON.parseObject(request.getPayload());
             JSONObject aps = body.getJSONObject("aps");
             assertEquals("New order", aps.getJSONObject("alert").getString("title"));
             assertEquals("Order body", aps.getJSONObject("alert").getString("body"));
@@ -89,24 +87,23 @@ class ApnsPushSenderTest {
 
         sender.send(RecipientApp.USER, "b".repeat(64), "title", "body", "{}");
 
-        org.mockito.ArgumentCaptor<HttpRequest> captured = org.mockito.ArgumentCaptor.forClass(HttpRequest.class);
-        verify(sender.clients.get(RecipientApp.USER)).send(captured.capture(), any());
-        assertEquals("api.push.apple.com", captured.getValue().uri().getHost());
+        verify(sender.clients.get(RecipientApp.USER)).sendNotification(any(SimpleApnsPushNotification.class));
+        assertEquals(BusinessPushProperties.ApnsEnvironment.PRODUCTION, sender.environments.get(RecipientApp.USER));
     }
 
-    @Test
-    void testSendUsesProductionGatewayEvenWhenBusinessModeIsUni() throws Exception {
-        BusinessPushProperties properties = nativeProperties(BusinessPushProperties.ApnsEnvironment.DEVELOPMENT);
+    @org.junit.jupiter.params.ParameterizedTest
+    @org.junit.jupiter.params.provider.EnumSource(BusinessPushProperties.ApnsEnvironment.class)
+    void testSendUsesConfiguredGatewayEvenWhenBusinessModeIsUni(BusinessPushProperties.ApnsEnvironment environment) throws Exception {
+        BusinessPushProperties properties = nativeProperties(environment);
         properties.setMode(BusinessPushProperties.Mode.UNI);
         StubSender sender = new StubSender(properties);
 
         sender.initialize();
-        sender.sendForTestProduction(RecipientApp.USER, "b".repeat(64), "test", "body", "{}");
+        sender.sendForTest(RecipientApp.USER, "b".repeat(64), "test", "body", "{}");
 
         assertEquals(Map.of(RecipientApp.USER, "user.p12"), sender.loadedPaths);
-        org.mockito.ArgumentCaptor<HttpRequest> captured = org.mockito.ArgumentCaptor.forClass(HttpRequest.class);
-        verify(sender.clients.get(RecipientApp.USER)).send(captured.capture(), any());
-        assertEquals("api.push.apple.com", captured.getValue().uri().getHost());
+        verify(sender.clients.get(RecipientApp.USER)).sendNotification(any(SimpleApnsPushNotification.class));
+        assertEquals(environment, sender.environments.get(RecipientApp.USER));
         sender.close();
     }
 
@@ -122,9 +119,9 @@ class ApnsPushSenderTest {
 
         for (RecipientApp app : RecipientApp.values()) {
             sender.send(app, "a".repeat(64), "title", "body", "{}");
-            org.mockito.ArgumentCaptor<HttpRequest> captured = org.mockito.ArgumentCaptor.forClass(HttpRequest.class);
-            verify(sender.clients.get(app)).send(captured.capture(), any());
-            assertEquals(topics.get(app), captured.getValue().headers().firstValue("apns-topic").orElseThrow());
+            var captured = org.mockito.ArgumentCaptor.forClass(SimpleApnsPushNotification.class);
+            verify(sender.clients.get(app)).sendNotification(captured.capture());
+            assertEquals(topics.get(app), captured.getValue().getTopic());
         }
     }
 
@@ -168,10 +165,10 @@ class ApnsPushSenderTest {
         StubSender sender = new StubSender(nativeProperties(BusinessPushProperties.ApnsEnvironment.PRODUCTION));
         sender.initialize();
         @SuppressWarnings("unchecked")
-        HttpResponse<String> failure = mock(HttpResponse.class);
-        when(failure.statusCode()).thenReturn(400);
-        when(failure.body()).thenReturn("{\"reason\":\"BadDeviceToken\"}");
-        doReturn(failure).when(sender.clients.get(RecipientApp.USER)).send(any(), any());
+        PushNotificationResponse<SimpleApnsPushNotification> failure = mock(PushNotificationResponse.class);
+        when(failure.isAccepted()).thenReturn(false);
+        when(failure.getRejectionReason()).thenReturn("BadDeviceToken");
+        when(sender.responses.get(RecipientApp.USER).get(10, TimeUnit.SECONDS)).thenReturn(failure);
 
         IllegalStateException error = assertThrows(IllegalStateException.class,
                 () -> sender.send(RecipientApp.USER, "c".repeat(64), "title", "body", "{}"));
@@ -208,54 +205,35 @@ class ApnsPushSenderTest {
         return properties;
     }
 
-    private String bodyOf(HttpRequest request) throws Exception {
-        CompletableFuture<byte[]> completed = new CompletableFuture<>();
-        ByteArrayOutputStream output = new ByteArrayOutputStream();
-        request.bodyPublisher().orElseThrow().subscribe(new Flow.Subscriber<>() {
-            @Override
-            public void onSubscribe(Flow.Subscription subscription) {
-                subscription.request(Long.MAX_VALUE);
-            }
-
-            @Override
-            public void onNext(ByteBuffer item) {
-                byte[] bytes = new byte[item.remaining()];
-                item.get(bytes);
-                output.writeBytes(bytes);
-            }
-
-            @Override
-            public void onError(Throwable throwable) {
-                completed.completeExceptionally(throwable);
-            }
-
-            @Override
-            public void onComplete() {
-                completed.complete(output.toByteArray());
-            }
-        });
-        return new String(completed.get(1, TimeUnit.SECONDS), StandardCharsets.UTF_8);
-    }
-
     private static class StubSender extends ApnsPushSender {
-        private final Map<RecipientApp, HttpClient> clients = new EnumMap<>(RecipientApp.class);
+        private final Map<RecipientApp, ApnsClient> clients = new EnumMap<>(RecipientApp.class);
+        private final Map<RecipientApp, PushNotificationFuture<SimpleApnsPushNotification,
+                PushNotificationResponse<SimpleApnsPushNotification>>> responses = new EnumMap<>(RecipientApp.class);
         private final Map<RecipientApp, String> loadedPaths = new EnumMap<>(RecipientApp.class);
+        private final Map<RecipientApp, BusinessPushProperties.ApnsEnvironment> environments = new EnumMap<>(RecipientApp.class);
 
         StubSender(BusinessPushProperties properties) throws Exception {
             super(properties);
             for (RecipientApp app : RecipientApp.values()) {
-                HttpClient client = mock(HttpClient.class);
+                ApnsClient client = mock(ApnsClient.class);
+                @SuppressWarnings("unchecked")
+                PushNotificationResponse<SimpleApnsPushNotification> response = mock(PushNotificationResponse.class);
                 @SuppressWarnings("unchecked")
-                HttpResponse<String> response = mock(HttpResponse.class);
-                when(response.statusCode()).thenReturn(200);
-                doReturn(response).when(client).send(any(), any());
+                PushNotificationFuture<SimpleApnsPushNotification, PushNotificationResponse<SimpleApnsPushNotification>> future = mock(PushNotificationFuture.class);
+                when(response.isAccepted()).thenReturn(true);
+                when(future.get(10, TimeUnit.SECONDS)).thenReturn(response);
+                doReturn(future).when(client).sendNotification(any(SimpleApnsPushNotification.class));
+                when(client.close()).thenReturn(ImmediateEventExecutor.INSTANCE.newSucceededFuture(null));
                 clients.put(app, client);
+                responses.put(app, future);
             }
         }
 
         @Override
-        protected HttpClient createClient(RecipientApp app, BusinessPushProperties.AppProperties config) {
+        protected ApnsClient createClient(RecipientApp app, BusinessPushProperties.ApnsEnvironment environment,
+                                          BusinessPushProperties.AppProperties config) {
             loadedPaths.put(app, config.getApnsCertificatePath());
+            environments.put(app, environment);
             return clients.get(app);
         }
     }

+ 166 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/push/ApnsPushyTransportTest.java

@@ -0,0 +1,166 @@
+package com.ruoyi.app.push;
+
+import com.alibaba.fastjson2.JSON;
+import com.turo.pushy.apns.ApnsClient;
+import com.turo.pushy.apns.DeliveryPriority;
+import com.turo.pushy.apns.PushNotificationResponse;
+import com.turo.pushy.apns.PushType;
+import com.turo.pushy.apns.util.SimpleApnsPushNotification;
+import com.turo.pushy.apns.util.concurrent.PushNotificationFuture;
+import org.junit.jupiter.api.Test;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.TimeoutException;
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.*;
+
+/** 发送Apple类型化通知并以Apple接受结果为准,不把队列成功当投递接受。 */
+class ApnsPushyTransportTest {
+    @Test
+    void rawTokenTestForwardsCustomSoundToApple() throws Exception {
+        var properties = new BusinessPushProperties();
+        var config = new BusinessPushProperties.AppProperties();
+        config.setApnsCertificatePath("user.p12");
+        config.setApnsCertificatePassword("fixture-password");
+        config.setApnsTopic("com.example.user");
+        properties.getApps().put(RecipientApp.USER, config);
+        ApnsClient client = acceptedClient();
+        ApnsPushSender sender = new ApnsPushSender(properties) {
+            @Override
+            protected ApnsClient createClient(RecipientApp app, BusinessPushProperties.ApnsEnvironment environment,
+                                               BusinessPushProperties.AppProperties actualConfig) {
+                return client;
+            }
+        };
+        NativePushSound sound = new NativePushSound("rider_accepted_zh_hant");
+        sender.sendForTest(RecipientApp.USER, "a".repeat(64), "title", "body", "{}", sound);
+        var capture = org.mockito.ArgumentCaptor.forClass(SimpleApnsPushNotification.class);
+        verify(client).sendNotification(capture.capture());
+        var payload = JSON.parseObject(capture.getValue().getPayload());
+        assertEquals(sound.fileName(), payload.getJSONObject("aps").getString("sound"));
+        assertEquals(sound.fileName(), payload.getString("sound"));
+        sender.close();
+    }
+
+    @Test
+    void configuredTestEnvironmentNeverReusesOtherGatewayAndClientsAreReusedAndClosed() throws Exception {
+        var properties = new BusinessPushProperties();
+        properties.setMode(BusinessPushProperties.Mode.NATIVE);
+        properties.setApnsEnvironment(BusinessPushProperties.ApnsEnvironment.PRODUCTION);
+        for (RecipientApp app : RecipientApp.values()) {
+            var config = new BusinessPushProperties.AppProperties();
+            config.setApnsCertificatePath(app.name() + ".p12");
+            config.setApnsCertificatePassword("fixture-password");
+            config.setApnsTopic("com.example." + app.name());
+            properties.getApps().put(app, config);
+        }
+        var sandbox = acceptedClient();
+        var production = acceptedClient();
+        var factoryCalls = new java.util.ArrayList<String>();
+        ApnsPushSender sender = new ApnsPushSender(properties) {
+            @Override
+            protected ApnsClient createClient(RecipientApp app, BusinessPushProperties.ApnsEnvironment environment,
+                                               BusinessPushProperties.AppProperties config) {
+                factoryCalls.add(app + ":" + environment);
+                return environment == BusinessPushProperties.ApnsEnvironment.PRODUCTION ? production : sandbox;
+            }
+        };
+        // 测试客户端可以先初始化;之后 native 启动仍须加载三端业务客户端。
+        sender.sendForTest(RecipientApp.USER, "a".repeat(64), "test", "body", "{}");
+        properties.setApnsEnvironment(BusinessPushProperties.ApnsEnvironment.DEVELOPMENT);
+        sender.initialize();
+        sender.initialize();
+        sender.send(RecipientApp.USER, "a".repeat(64), "business", "body", "{}");
+        properties.setApnsEnvironment(BusinessPushProperties.ApnsEnvironment.PRODUCTION);
+        sender.sendForTest(RecipientApp.USER, "a".repeat(64), "test", "body", "{}");
+        assertEquals(java.util.List.of("USER:PRODUCTION", "USER:DEVELOPMENT", "MERCHANT:DEVELOPMENT", "RIDER:DEVELOPMENT"), factoryCalls);
+        verify(production, times(2)).sendNotification(any(SimpleApnsPushNotification.class));
+        verify(sandbox).sendNotification(any(SimpleApnsPushNotification.class));
+        sender.close();
+        verify(production).close();
+        verify(sandbox, times(3)).close();
+    }
+
+    @SuppressWarnings("unchecked")
+    private ApnsClient acceptedClient() throws Exception {
+        ApnsClient client = mock(ApnsClient.class);
+        PushNotificationResponse<SimpleApnsPushNotification> response = mock(PushNotificationResponse.class);
+        PushNotificationFuture<SimpleApnsPushNotification, PushNotificationResponse<SimpleApnsPushNotification>> future =
+                mock(PushNotificationFuture.class);
+        when(response.isAccepted()).thenReturn(true);
+        when(future.get(10, TimeUnit.SECONDS)).thenReturn(response);
+        doReturn(future).when(client).sendNotification(any(SimpleApnsPushNotification.class));
+        when(client.close()).thenReturn(io.netty.util.concurrent.ImmediateEventExecutor.INSTANCE.newSucceededFuture(null));
+        return client;
+    }
+
+    @Test
+    void referenceSdkKeepsProductionTopicPayloadAndReturnsActualAppleRejection() throws Exception {
+        var properties = new BusinessPushProperties();
+        properties.setMode(BusinessPushProperties.Mode.UNI);
+        var config = new BusinessPushProperties.AppProperties();
+        config.setApnsCertificatePath("user.p12");
+        config.setApnsCertificatePassword("fixture-password");
+        config.setApnsTopic("com.example.user");
+        properties.getApps().put(RecipientApp.USER, config);
+        ApnsClient client = mock(ApnsClient.class);
+        @SuppressWarnings("unchecked")
+        PushNotificationResponse<SimpleApnsPushNotification> response = mock(PushNotificationResponse.class);
+        @SuppressWarnings("unchecked")
+        PushNotificationFuture<SimpleApnsPushNotification, PushNotificationResponse<SimpleApnsPushNotification>> future =
+                mock(PushNotificationFuture.class);
+        when(future.get(10, TimeUnit.SECONDS)).thenReturn(response);
+        when(response.isAccepted()).thenReturn(false);
+        when(response.getRejectionReason()).thenReturn("BadDeviceToken");
+        doReturn(future).when(client).sendNotification(any(SimpleApnsPushNotification.class));
+        ApnsPushSender sender = new ApnsPushSender(properties) {
+            @Override
+            protected ApnsClient createClient(RecipientApp app, BusinessPushProperties.ApnsEnvironment environment,
+                                               BusinessPushProperties.AppProperties actualConfig) {
+                assertEquals(RecipientApp.USER, app);
+                assertEquals(BusinessPushProperties.ApnsEnvironment.PRODUCTION, environment);
+                assertSame(config, actualConfig);
+                return client;
+            }
+        };
+        IllegalStateException rejected = assertThrows(IllegalStateException.class, () ->
+                sender.sendForTest(RecipientApp.USER, "a".repeat(64), "title", "body", "{\"ddId\":123}"));
+        assertEquals("APNS_BadDeviceToken", rejected.getMessage());
+        var capture = org.mockito.ArgumentCaptor.forClass(SimpleApnsPushNotification.class);
+        verify(client).sendNotification(capture.capture());
+        var notification = capture.getValue();
+        assertEquals("a".repeat(64), notification.getToken());
+        assertEquals("com.example.user", notification.getTopic());
+        assertEquals(PushType.ALERT, notification.getPushType());
+        assertEquals(DeliveryPriority.IMMEDIATE, notification.getPriority());
+        assertEquals("123", JSON.parseObject(notification.getPayload()).getString("ddId"));
+        when(response.isAccepted()).thenReturn(true);
+        assertDoesNotThrow(() -> sender.sendForTest(RecipientApp.USER, "a".repeat(64), "title", "body", "{}"));
+
+        when(response.isAccepted()).thenReturn(false);
+        when(response.getRejectionReason()).thenReturn("token=" + "a".repeat(64));
+        assertEquals("APNS_REJECTED", assertThrows(IllegalStateException.class, () ->
+                sender.sendForTest(RecipientApp.USER, "a".repeat(64), "title", "body", "{}")).getMessage());
+        doThrow(new TimeoutException()).when(future).get(10, TimeUnit.SECONDS);
+        assertEquals("APNS_TIMEOUT", assertThrows(IllegalStateException.class, () ->
+                sender.sendForTest(RecipientApp.USER, "a".repeat(64), "title", "body", "{}")).getMessage());
+        doThrow(new ExecutionException(new Exception("token=" + "a".repeat(64)))).when(future).get(10, TimeUnit.SECONDS);
+        assertEquals("APNS_TRANSPORT_ERROR", assertThrows(IllegalStateException.class, () ->
+                sender.sendForTest(RecipientApp.USER, "a".repeat(64), "title", "body", "{}")).getMessage());
+        doThrow(new InterruptedException()).when(future).get(10, TimeUnit.SECONDS);
+        try {
+            assertEquals("APNS_INTERRUPTED", assertThrows(IllegalStateException.class, () ->
+                    sender.sendForTest(RecipientApp.USER, "a".repeat(64), "title", "body", "{}")).getMessage());
+            assertTrue(Thread.currentThread().isInterrupted());
+        } finally {
+            Thread.interrupted();
+        }
+        clearInvocations(client);
+        assertEquals("APNS_TOKEN_INVALID", assertThrows(IllegalStateException.class, () ->
+                sender.sendForTest(RecipientApp.USER, "invalid", "title", "body", "{}")).getMessage());
+        assertEquals("APNS_PAYLOAD_TOO_LARGE", assertThrows(IllegalStateException.class, () ->
+                sender.sendForTest(RecipientApp.USER, "a".repeat(64), "title", "大".repeat(2000), "{}")).getMessage());
+        verifyNoInteractions(client);
+    }
+}

+ 1 - 1
ruoyi-admin/src/test/java/com/ruoyi/app/push/BusinessPushServiceTest.java

@@ -189,7 +189,7 @@ class BusinessPushServiceTest {
     }
 
     private BusinessPushService service() {
-        return new BusinessPushService(properties, users, fcm, apns, uni);
+        return new BusinessPushService(properties, users, fcm, apns, uni, mock(NativePushSoundResolver.class));
     }
 
     private InfoUser user(Long id, String platform, String token) {

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

@@ -31,6 +31,18 @@ class FcmPushSenderTest {
     @TempDir
     Path tempDir;
 
+    @Test
+    void rawTokenTestForwardsCustomSoundToFirebase() throws Exception {
+        StubSender sender = new StubSender(nativeProperties());
+        NativePushSound sound = new NativePushSound("rider_accepted_zh_hant");
+        sender.sendForTest(RecipientApp.USER, "token", "title", "body", "{}", sound);
+        var capture = org.mockito.ArgumentCaptor.forClass(Message.class);
+        verify(sender.messaging.get(RecipientApp.USER)).send(capture.capture());
+        Object notification = field(field(capture.getValue(), "androidConfig"), "notification");
+        assertEquals(sound.resourceName(), field(notification, "sound"));
+        assertEquals(sound.channelId(), field(notification, "channelId"));
+    }
+
     @org.junit.jupiter.params.ParameterizedTest
     @org.junit.jupiter.params.provider.EnumSource(UserNotificationScene.class)
     void userSceneStaysInFullPayloadAndKeepsDefaultSound(UserNotificationScene scene) throws Exception {

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

@@ -0,0 +1,145 @@
+package com.ruoyi.app.push;
+
+import com.alibaba.fastjson2.JSON;
+import com.google.firebase.messaging.Message;
+import com.ruoyi.app.utils.PayPush;
+import com.ruoyi.system.utils.JedisFactory;
+import redis.clients.jedis.Jedis;
+import com.ruoyi.common.utils.MessageUtils;
+import com.ruoyi.system.domain.InfoUser;
+import com.ruoyi.system.service.IInfoUserService;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import java.lang.reflect.Field;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.*;
+
+class NativePushSoundTest {
+    private final JedisFactory cache = mock(JedisFactory.class);
+    private final Jedis jedis = mock(Jedis.class);
+    private final NativePushSoundResolver sounds = new NativePushSoundResolver(cache);
+
+    @ParameterizedTest
+    @CsvSource({"zhHant,zh_hant", "zh-Hant,zh_hant", "zh_TW,zh_hant", "tw,zh_hant",
+            "zh-Hant-TW,zh_hant", "zhHans,zh_hans", "zh-CN,zh_hans", "zh-Hans-CN,zh_hans",
+            "en-US,en", "th,th", "id-ID,id", "in,id", "ms-MY,ms", "fil-PH,tl", "tl,tl",
+            "unknown,zh_hant", "vi,zh_hant", "zh,zh_hant"})
+    void selectsCanonicalLanguage(String language, String suffix) {
+        NativePushSound sound = sounds.forTest(RecipientApp.USER, language, scene("RIDER_ACCEPTED"));
+        assertEquals("rider_accepted_" + suffix + ".wav", sound.fileName());
+        assertEquals("user_voice_rider_accepted_" + suffix + "_v1", sound.channelId());
+    }
+
+    @ParameterizedTest
+    @ValueSource(strings = {"RIDER_ACCEPTED", "ORDER_DELIVERING", "ORDER_DELIVERED", "MERCHANT_ACCEPTED",
+            "MERCHANT_READY", "PAYMENT_SUCCEEDED", "ORDER_CANCELLED",
+            "FLASH_PICKUP_STOP_COMPLETED", "FLASH_DELIVERY_STOP_COMPLETED"})
+    void recognizesOnlyExplicitScenes(String scene) {
+        String key = scene.startsWith("FLASH_") ? "eventType" : "eventScene";
+        NativePushSound sound = sounds.forTest(RecipientApp.USER, null, "{\"" + key + "\":\"" + scene + "\"}");
+        assertEquals(scene.toLowerCase(java.util.Locale.ROOT) + "_zh_hant.wav", sound.fileName());
+    }
+
+    @Test
+    void oldOrMalformedPayloadAndOtherRecipientsKeepOriginalSound() {
+        for (String payload : new String[]{null, "oops", "null", "{}", "{\"state\":2,\"type\":0}", scene("UNKNOWN")}) {
+            assertNull(sounds.forTest(RecipientApp.USER, "en", payload));
+        }
+        assertNull(sounds.forRecipient(RecipientApp.RIDER, 9L, scene("RIDER_ACCEPTED")));
+        assertNull(sounds.forRecipient(RecipientApp.MERCHANT, 9L, scene("MERCHANT_READY")));
+        verifyNoInteractions(cache);
+    }
+
+    @Test
+    void readsRawRecipientPreferenceAndFallsBackOnMissingOrFailedCache() {
+        when(cache.create()).thenReturn(jedis);
+        when(jedis.get("9")).thenReturn("id");
+        assertEquals("rider_accepted_id.wav", sounds.forRecipient(RecipientApp.USER, 9L, scene("RIDER_ACCEPTED")).fileName());
+        assertEquals("rider_accepted_zh_hant.wav", sounds.forRecipient(RecipientApp.USER, null, scene("RIDER_ACCEPTED")).fileName());
+        verify(jedis).close();
+        when(jedis.get("9")).thenThrow(new IllegalStateException("offline"));
+        assertEquals("rider_accepted_zh_hant.wav", sounds.forRecipient(RecipientApp.USER, 9L, scene("RIDER_ACCEPTED")).fileName());
+        verify(jedis, times(2)).close();
+    }
+
+    @Test
+    void transportPayloadsSelectSystemSoundAndRetainBusinessPayload() throws Exception {
+        String payload = scene("RIDER_ACCEPTED");
+        NativePushSound sound = sounds.forTest(RecipientApp.USER, "en", payload);
+        var body = JSON.parseObject(ApnsPushSender.buildBody(RecipientApp.USER, "title", "body", payload, sound));
+        assertEquals("rider_accepted_en.wav", body.getJSONObject("aps").getString("sound"));
+        assertEquals(sound.fileName(), body.getString("sound"));
+        assertEquals(payload, body.getString("payload"));
+        Message message = FcmPushSender.buildMessage(RecipientApp.USER, "token", "title", "body", payload, sound);
+        Object notification = field(field(message, "androidConfig"), "notification");
+        assertEquals("rider_accepted_en", field(notification, "sound"));
+        assertEquals(Boolean.FALSE, field(notification, "defaultSound"));
+        assertEquals(sound.channelId(), field(notification, "channelId"));
+        Map<?, ?> data = (Map<?, ?>) field(message, "data");
+        assertEquals(payload, data.get("payload"));
+        assertEquals(sound.fileName(), data.get("sound"));
+        assertEquals(sound.channelId(), data.get("channelId"));
+    }
+
+    @Test
+    void businessRoutingUsesSoundOnlyForNativeAndKeepsUniPayload() {
+        BusinessPushProperties properties = new BusinessPushProperties();
+        IInfoUserService users = mock(IInfoUserService.class);
+        FcmPushSender fcm = mock(FcmPushSender.class);
+        ApnsPushSender apns = mock(ApnsPushSender.class);
+        PayPush uni = mock(PayPush.class);
+        BusinessPushService service = new BusinessPushService(properties, users, fcm, apns, uni, sounds);
+        String payload = scene("RIDER_ACCEPTED");
+        service.send(RecipientApp.USER, 9L, "cid", "title", "body", payload);
+        verify(uni).apppush("cid", "title", "body", payload);
+        verifyNoInteractions(cache, users, fcm, apns);
+        properties.setMode(BusinessPushProperties.Mode.NATIVE);
+        InfoUser user = new InfoUser(); user.setCidType("android"); user.setDeviceToken("token");
+        when(users.getById(9L)).thenReturn(user);
+        when(cache.create()).thenReturn(jedis);
+        when(jedis.get("9")).thenReturn("en");
+        NativePushSound expected = sounds.forTest(RecipientApp.USER, "en", payload);
+        service.send(RecipientApp.USER, 9L, null, "title", "body", payload);
+        verify(fcm).send(RecipientApp.USER, "token", "title", "body", payload, expected);
+        user.setCidType("ios");
+        service.send(RecipientApp.USER, 9L, null, "title", "body", payload);
+        verify(apns).send(RecipientApp.USER, "token", "title", "body", payload, expected);
+    }
+
+    @ParameterizedTest
+    @ValueSource(strings = {"android", "ios"})
+    void anonymousTestCanSelectLanguageWithoutAnAccount(String platform) {
+        FcmPushSender fcm = mock(FcmPushSender.class);
+        ApnsPushSender apns = mock(ApnsPushSender.class);
+        NativePushTestController controller = new NativePushTestController(fcm, apns, sounds);
+        NativePushTestRequest request = new NativePushTestRequest();
+        request.setAppType("user"); request.setPlatform(platform); request.setDeviceToken("token");
+        request.setTitle("title"); request.setContent("body"); request.setLanguage("zhHant");
+        request.setPayload(scene("RIDER_ACCEPTED"));
+        try (var ignored = mockStatic(MessageUtils.class)) {
+            assertEquals(200, controller.send(request).get("code"));
+        }
+        NativePushSound expected = sounds.forTest(RecipientApp.USER, "zhHant", request.getPayload());
+        if ("android".equals(platform)) {
+            verify(fcm).sendForTest(RecipientApp.USER, "token", "title", "body", request.getPayload(), expected);
+        } else {
+            verify(apns).sendForTest(RecipientApp.USER, "token", "title", "body", request.getPayload(), expected);
+        }
+        verifyNoInteractions(cache);
+    }
+
+    private static String scene(String value) {
+        return "{\"eventScene\":\"" + value + "\"}";
+    }
+
+    private static Object field(Object object, String name) throws Exception {
+        Field field = object.getClass().getDeclaredField(name);
+        field.setAccessible(true);
+        return field.get(object);
+    }
+}

+ 5 - 4
ruoyi-admin/src/test/java/com/ruoyi/app/push/NativePushTestControllerTest.java

@@ -16,7 +16,8 @@ import static org.mockito.Mockito.verify;
 class NativePushTestControllerTest {
     private final FcmPushSender fcm = mock(FcmPushSender.class);
     private final ApnsPushSender apns = mock(ApnsPushSender.class);
-    private final NativePushTestController controller = new NativePushTestController(fcm, apns);
+    private final NativePushTestController controller = new NativePushTestController(fcm, apns,
+            mock(NativePushSoundResolver.class));
 
     @Test
     void androidRequestSendsDirectlyToFcmWithoutAccountLookup() {
@@ -28,12 +29,12 @@ class NativePushTestControllerTest {
 
             assertEquals(200, result.get("code"));
             verify(fcm).sendForTest(RecipientApp.RIDER, "fcm-token", "title", "content", "{}");
-            verify(apns, never()).sendForTestProduction(RecipientApp.RIDER, "fcm-token", "title", "content", "{}");
+            verify(apns, never()).sendForTest(RecipientApp.RIDER, "fcm-token", "title", "content", "{}");
         }
     }
 
     @Test
-    void iosRequestUsesProductionApns() {
+    void iosRequestUsesApnsTestSenderWithConfiguredEnvironment() {
         try (var messages = mockStatic(MessageUtils.class)) {
             messages.when(() -> MessageUtils.message("native.push.test.accepted")).thenReturn("accepted");
             NativePushTestRequest request = request("user", "ios", "a".repeat(64));
@@ -41,7 +42,7 @@ class NativePushTestControllerTest {
             AjaxResult result = controller.send(request);
 
             assertEquals(200, result.get("code"));
-            verify(apns).sendForTestProduction(RecipientApp.USER, "a".repeat(64), "title", "content", "{}");
+            verify(apns).sendForTest(RecipientApp.USER, "a".repeat(64), "title", "content", "{}");
         }
     }
 

+ 4 - 0
ruoyi-system/src/main/java/com/ruoyi/system/domain/PosOrder.java

@@ -36,6 +36,10 @@ public class PosOrder {
      */
     private String ddId;
 
+    /** IM 订单群聊标识,由 App 创建群聊后回写;每张真实订单独立保存,最长 200 字符。 */
+    @TableField("im_group_id")
+    private String imGroupId;
+
     /**
      * 商家id
      */

+ 30 - 2
ruoyi-system/src/main/java/com/ruoyi/system/utils/AuthAspect.java

@@ -1,6 +1,7 @@
 package com.ruoyi.system.utils;
 
 import com.ruoyi.common.core.domain.AjaxResult;
+import com.ruoyi.common.core.page.TableDataInfo;
 import com.ruoyi.common.core.redis.RedisCache;
 import org.aspectj.lang.ProceedingJoinPoint;
 import org.aspectj.lang.annotation.Around;
@@ -11,6 +12,7 @@ import org.springframework.stereotype.Component;
 
 import jakarta.annotation.Resource;
 import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
 
 import java.util.Map;
 
@@ -29,6 +31,7 @@ public class AuthAspect {
     private static final String SESSION_EXPIRED_MESSAGE = "token已过期,请重新登录!";
 
     private HttpServletRequest request;
+    private HttpServletResponse response;
     private final RedisCache redisCache;
 
     public AuthAspect(RedisCache redisCache) {
@@ -40,6 +43,11 @@ public class AuthAspect {
         this.request = request;
     }
 
+    @Resource
+    public void setHttpServletResponse(HttpServletResponse response) {
+        this.response = response;
+    }
+
 
     @Pointcut("@annotation(com.ruoyi.system.utils.Auth)")
     private void authPointcut() {
@@ -57,7 +65,7 @@ public class AuthAspect {
         if (auth.token()) {
             Map<String, Object> claims = JwtUtil.verifyToken(request.getHeader("token"));
             if (!Boolean.TRUE.equals(claims.get("valid"))) {
-                return AjaxResult.error(401, SESSION_EXPIRED_MESSAGE);
+                return sessionExpiredResponse(methodSignature);
             }
 
             Object jtiValue = claims.get("jti");
@@ -68,7 +76,7 @@ public class AuthAspect {
             // 放行至 15 天自然过期淘汰,避免升级瞬间误伤存量登录。
             if (jti != null && !jti.isBlank() && jti.contains(":")
                     && !Boolean.TRUE.equals(redisCache.hasKey(jti))) {
-                return AjaxResult.error(401, SESSION_EXPIRED_MESSAGE);
+                return sessionExpiredResponse(methodSignature);
             }
 
             Long userId = parseUserId(claims.get("id"));
@@ -78,6 +86,26 @@ public class AuthAspect {
         return joinPoint.proceed();
     }
 
+    /**
+     * 鉴权发生在Controller之前,短路返回也必须匹配方法的声明类型。
+     * 发票夹等分页接口声明TableDataInfo,直接返回AjaxResult会在CGLIB代理中checkcast失败,
+     * 导致用户看不到原本应返回的401;正常会话与原有AjaxResult接口保持原行为。
+     */
+    private Object sessionExpiredResponse(MethodSignature signature) {
+        // 业务code与HTTP状态同步为401;不能以HTTP200掩盖失效会话。
+        // 非Servlet的切面单元调用无response,真实请求由Spring注入当前响应代理。
+        if (response != null) response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
+        if (TableDataInfo.class.equals(signature.getReturnType())) {
+            TableDataInfo result = new TableDataInfo();
+            result.setCode(401);
+            result.setMsg(SESSION_EXPIRED_MESSAGE);
+            result.setRows(java.util.List.of());
+            result.setTotal(0);
+            return result;
+        }
+        return AjaxResult.error(401, SESSION_EXPIRED_MESSAGE);
+    }
+
     private Long parseUserId(Object claim) {
         if (!(claim instanceof String value) || value.isBlank() || "null".equals(value)) {
             return null;

+ 5 - 1
ruoyi-system/src/main/resources/mapper/system/PosOrderMapper.xml

@@ -7,6 +7,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
     <resultMap type="PosOrder" id="PosOrderResult">
         <result property="id"    column="id"    />
         <result property="ddId"    column="dd_id"    />
+        <result property="imGroupId" column="im_group_id" />
         <result property="shId"    column="sh_id"    />
         <result property="mdId"    column="md_id"    />
         <result property="cretim"    column="cretim"    />
@@ -68,7 +69,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
     </resultMap>
 
     <sql id="selectPosOrderVo">
-        select id, dd_id, sh_id, md_id, cretim, shdz_id, user_id,sh_address, amount, remarks, state, type, delry_time,food,yh_id,yh_name,md_yh_id,md_yh_name,md_discount_amount,jvli,freight,dining_status,qs_id,pay_url,collect_payment,activity,md_activity,kefu_state,kefu_content,kefu_repeat,repeat_dd_id,sales_name,md_sales_name,sales_reduction,md_sales_reduction,points,points_reduction,sd_time,pay_type,order_source,parent_dd_id,order_category,table_id,table_no,logo,pos_name,food_amount,pick_up_num,pick_up_time,reserve_phone,is_accepted,is_display,display_time,effective_at,delivery_status,pay_status,after_sale_status,promotion_pricing_version,merchant_delivery_subsidy,customer_delivery_fee from pos_order
+        select id, dd_id, im_group_id, sh_id, md_id, cretim, shdz_id, user_id,sh_address, amount, remarks, state, type, delry_time,food,yh_id,yh_name,md_yh_id,md_yh_name,md_discount_amount,jvli,freight,dining_status,qs_id,pay_url,collect_payment,activity,md_activity,kefu_state,kefu_content,kefu_repeat,repeat_dd_id,sales_name,md_sales_name,sales_reduction,md_sales_reduction,points,points_reduction,sd_time,pay_type,order_source,parent_dd_id,order_category,table_id,table_no,logo,pos_name,food_amount,pick_up_num,pick_up_time,reserve_phone,is_accepted,is_display,display_time,effective_at,delivery_status,pay_status,after_sale_status,promotion_pricing_version,merchant_delivery_subsidy,customer_delivery_fee from pos_order
     </sql>
 
     <select id="selectPosOrderList" parameterType="PosOrder" resultMap="PosOrderResult">
@@ -159,6 +160,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         insert into pos_order
         <trim prefix="(" suffix=")" suffixOverrides=",">
             <if test="ddId != null">dd_id,</if>
+            <if test="imGroupId != null">im_group_id,</if>
             <if test="shId != null">sh_id,</if>
             <if test="mdId != null">md_id,</if>
             <if test="cretim != null">cretim,</if>
@@ -207,6 +209,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
          </trim>
         <trim prefix="values (" suffix=")" suffixOverrides=",">
             <if test="ddId != null">#{ddId},</if>
+            <if test="imGroupId != null">#{imGroupId},</if>
             <if test="shId != null">#{shId},</if>
             <if test="mdId != null">#{mdId},</if>
             <if test="cretim != null">#{cretim},</if>
@@ -259,6 +262,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         update pos_order
         <trim prefix="SET" suffixOverrides=",">
             <if test="ddId != null">dd_id = #{ddId},</if>
+            <if test="imGroupId != null">im_group_id = #{imGroupId},</if>
             <if test="shId != null">sh_id = #{shId},</if>
             <if test="mdId != null">md_id = #{mdId},</if>
             <if test="cretim != null">cretim = #{cretim},</if>

+ 53 - 0
ruoyi-system/src/test/java/com/ruoyi/system/mapper/PosOrderImGroupMapperTest.java

@@ -0,0 +1,53 @@
+package com.ruoyi.system.mapper;
+
+import com.ruoyi.system.domain.PosOrder;
+import org.apache.ibatis.builder.xml.XMLMapperBuilder;
+import org.apache.ibatis.session.Configuration;
+import org.junit.jupiter.api.Test;
+
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class PosOrderImGroupMapperTest {
+    @Test
+    void detailAndListSelectGroupAndResultMapUsesGroupColumn() throws Exception {
+        Configuration configuration = configuration();
+        var mapping = configuration.getResultMap("com.ruoyi.system.mapper.PosOrderMapper.PosOrderResult")
+                .getResultMappings().stream().filter(item -> "imGroupId".equals(item.getProperty())).findFirst();
+        assertTrue(mapping.isPresent());
+        assertEquals("im_group_id", mapping.orElseThrow().getColumn());
+        assertTrue(configuration.getMappedStatement("com.ruoyi.system.mapper.PosOrderMapper.selectPosOrderById")
+                .getBoundSql(Map.of("id", 7L)).getSql().contains("im_group_id"));
+    }
+
+    @Test
+    void partialUpdateWritesOnlyGroupForTargetOrder() throws Exception {
+        PosOrder patch = new PosOrder(); patch.setId(7L); patch.setImGroupId("im-room");
+        var bound = configuration().getMappedStatement("com.ruoyi.system.mapper.PosOrderMapper.updatePosOrder")
+                .getBoundSql(patch);
+        assertEquals("update pos_order SET im_group_id = ? where id = ?", bound.getSql().replaceAll("\\s+", " ").trim());
+        assertEquals(java.util.List.of("imGroupId", "id"),
+                bound.getParameterMappings().stream().map(item -> item.getProperty()).toList());
+    }
+
+    @Test
+    void insertPersistsGroupWhenPresent() throws Exception {
+        PosOrder order = new PosOrder(); order.setDdId("ORDER-1"); order.setImGroupId("im-room");
+        var bound = configuration().getMappedStatement("com.ruoyi.system.mapper.PosOrderMapper.insertPosOrder")
+                .getBoundSql(order);
+        assertTrue(bound.getSql().contains("im_group_id"));
+        assertTrue(bound.getParameterMappings().stream().anyMatch(item -> "imGroupId".equals(item.getProperty())));
+    }
+
+    private Configuration configuration() throws Exception {
+        Configuration configuration = new Configuration();
+        configuration.getTypeAliasRegistry().registerAlias("PosOrder", PosOrder.class);
+        String resource = "mapper/system/PosOrderMapper.xml";
+        try (var stream = getClass().getClassLoader().getResourceAsStream(resource)) {
+            assertNotNull(stream);
+            new XMLMapperBuilder(stream, configuration, resource, configuration.getSqlFragments()).parse();
+        }
+        return configuration;
+    }
+}

+ 3 - 0
specs/010-order-invoice/plan.md

@@ -175,3 +175,6 @@ src/lang/{zh,tw,en,vi}.js                    # 四语言加 key
 
 **范围边界**:交付后端 + 管理端展示;客户端 App 的结账页/发票夹/抬头管理 UI 由客户端团队(沿用 010 分工)。POS 实体打印不做(商家自打证明联)。
 
+# 2026-10-26 增量修复执行记录
+
+用户发票夹异常定位为AuthAspect鉴权短路返回AjaxResult与Controller声明TableDataInfo不匹配。在切面匹配分页声明返回401分页对象,并通过当前Servlet响应设置HTTP401;其它AjaxResult接口保留原响应体并同步HTTP401。正常请求保持HTTP200和原查询权限。UserInvoiceAuthResponseTest使用真实CGLIB代理复现原异常,覆盖签名失效/会话撤销/正常用户分页、实际HTTP状态及既有鉴权回归;无SQL、无App调整。

+ 6 - 0
specs/010-order-invoice/spec.md

@@ -1,5 +1,11 @@
 # Feature Specification: 订单 ezPay 电子发票开立
 
+## 2026-10-26 用户发票夹鉴权返回修复
+
+用户端`GET /system/userOrder/myInvoices`成功响应继续为HTTP200和`TableDataInfo`分页结构(code/msg/rows/total)。登录token过期、非法或Redis会话撤销时,在查询之前返回HTTP401,并返回同类型的`code=401, rows=[], total=0`及既有会话失效提示,不能由鉴权切面返回不兼容的AjaxResult导致强制转换异常。捐赠机构分页接口同步受保护;其它AjaxResult接口保留原JSON结构,同时明确返回HTTP401。
+
+不修改App、发票查询条件、开票/作废流程或数据库结构。验证须使用真实Controller的Spring AOP代理,覆盖失效会话、撤销会话和正常会话查询。
+
 **Feature Branch**: 不新建分支(在当前 test 分支开发)
 
 **Created**: 2026-06-16

+ 5 - 0
specs/010-order-invoice/tasks.md

@@ -280,3 +280,8 @@ description: "Task list for 订单 ezPay 电子发票开立"
 - [x] T060 [P] i18n:结账页发票选项文案属客户端 UI(客户端团队);**后端无新增面向用户文字**,本期后端 i18n no-op。
 
 **Checkpoint**: 已完成订单(开发票门店)→ 出餐自动开出个人纸本(零信息)→ 码值落库 → 商家可打印;选手機條碜下单 → checkBarCode 验真 → 出餐自动开载具发票;会员 `/myInvoices` 看到发票。
+# 2026-10-26 用户发票夹异常修复
+
+- [x] T061 修复AuthAspect与用户发票分页返回类型不匹配,失效会话HTTP401+code401;真实AOP代理回归覆盖失效、撤销、正常分页和HTTP状态,保留其它AjaxResult响应体。
+
+2026-10-26 本地验证:JDK21,`mvn -q -pl ruoyi-admin -am -Dtest=AuthAspectTest,UserInvoiceAuthResponseTest,MerchantSessionAuthAnnotationTest,*Invoice*Test -Dsurefire.failIfNoSpecifiedTests=false test`,14项通过、失败/错误均0;原ClassCastException已由真实代理复现后修复,`git diff --check`通过。位于test-202609v2,未提交/部署;不修改App或数据库。未执行测试环境真机登录和发票查询联调。

+ 34 - 0
specs/012-im-user-integration/contracts/order-im-group.md

@@ -0,0 +1,34 @@
+# 订单 IM 群聊标识(2026-10-26)
+
+App/IM 创建群聊、维护商家/骑手/用户成员后,将群标识保存到真实订单。后端不代建群。
+
+## 保存接口
+
+`POST /system/order/imGroup`,请求头 `token: <App 登录 Token>`、`Content-Type: application/json`。
+
+```json
+{
+  "ddId": "176000000000000001",
+  "imGroupId": "im-order-room-001"
+}
+```
+
+- `ddId`:真实子单业务订单号,字符串;多店批次父单号、配送组 ID 不能代替。
+- `imGroupId`:非空字符串,最长 200 字符,原样保存。
+- 仅下单用户、当前接单骑手以及有门店访问权限的商家可保存。方法使用 `@Anonymous` 放行后台 Spring Security,同时由 `@Auth` 校验 App 登录,**不属于免登录接口**。
+- 成功返回 `code=200`。同一标识重复保存成功;传入新值会更新已有标识。无清空操作。订单无群聊时字段为空,历史订单兼容。
+- 接口只修改 `im_group_id`,不修改履约/支付状态,不调用 IM 外部接口。
+
+## 查询返回位置
+
+| 接口 | 群标识位置 |
+| --- | --- |
+| `GET /system/order/getorderxq?ddid=...` | `data.imGroupId` |
+| `GET /system/order/{id}` | `data.imGroupId` |
+| `GET /system/orderQsOprate/getOrderDetial?ddId=...` | `data.imGroupId` |
+| `GET /system/orderQsOprate/orderList` | `data.records[].imGroupId` |
+| `GET /system/order/getqsorderlist`、`/getqishouorderlist` | `data[].imGroupId` |
+| `GET /system/multiStoreCheckout/rider/tasks` 普通单 | `data.records[].imGroupId`、`legacyOrder.imGroupId` |
+| 同接口的多店组、`GET /system/multiStoreCheckout/groups/{groupId}` | `members[].imGroupId`、`members[].legacyOrder.imGroupId` |
+
+多店组没有单一群聊标识,每个真实子单关联自己的群。数据库部署前需人工执行 `updatesql/sql.md` 的本次新增列语句;本地测试不代替实际 IM 建群、邀请成员及 App 打开群聊的联调。

+ 4 - 0
specs/012-im-user-integration/data-model.md

@@ -71,3 +71,7 @@ ALTER TABLE info_user ADD COLUMN im_user_id BIGINT DEFAULT NULL COMMENT 'IM平
 | `imUserId` | String | IM 平台返回的 userId(**String 规避前端 Long 精度丢失**) |
 
 > service 内部 `InfoUser.imUserId` 为 Long;组装 VO 时 `String.valueOf(imUserId)` 转字符串返回。
+
+## 2026-10-26 变更表:pos_order
+
+新增 `im_group_id VARCHAR(200) DEFAULT NULL`,实体/API 属性 `imGroupId`。每张真实订单保存一个当前群聊标识,App/IM 建群后通过保存接口写入;新值可覆盖旧值,历史订单为空。多店父单、配送组不存共享标识,各子单分别保存。XML 与 MyBatis-Plus 查询均映射该列,迁移 SQL 见 `updatesql/sql.md`。

+ 7 - 0
specs/012-im-user-integration/plan.md

@@ -79,3 +79,10 @@ updatesql/sql.md                             # 【改】追加 info_user 加两
 ## Complexity Tracking
 
 无 Constitution 违规,无需记录复杂度豁免。本方案保持最小改动:2 字段、1 接口、1 工具类、1 配置段、1 SQL,不引入新分层、不改动注册流程。
+
+## 2026-10-26 增量计划:订单群聊标识
+
+1. `PosOrder` 增加 `imGroupId`,补齐 `PosOrderMapper.xml` resultMap、公共查询列和动态 insert/update。SQL 写入 `updatesql/sql.md`,不执行迁移。
+2. 在现有 `PosOrderController` 增加 `POST /imGroup`,使用 `OrderImGroupRequest(ddId, imGroupId)`、`@RequestHeader token` 与 `@Auth`,复用 `requireOrderDetailAccess`;只构造含 id/群标识的更新对象,经现有 `IPosOrderService.updatePosOrder` 保存,相同值幂等返回。接口按普通保存语义允许新值覆盖旧值。
+3. 旧列表/详情沿用完整实体返回;新版任务与组详情在普通单卡/子单摘要补 `imGroupId`,不把配送组 ID 当成 IM 群 ID。
+4. 补权限、字段边界、映射与新旧查询回归测试;更新 IM 接口契约及 036 App 接入说明,使用 JDK 21 定向测试和模块构建验证。

+ 4 - 0
specs/012-im-user-integration/quickstart.md

@@ -65,3 +65,7 @@
 - 场景 1~5 全部符合预期。
 - 新老用户(im_api_key 原为空)都能通过同一接口开通(场景 1 即覆盖老用户补建)。
 - 注册流程未被改动(回归验证:注册接口行为与返回结构保持原样)。
+
+## 2026-10-26 订单群聊联调
+
+先人工执行 `updatesql/sql.md` 的 `pos_order.im_group_id` 迁移,再部署后端。App/IM 建群完成后,用该订单用户、当前骑手或有门店权限的商家 Token 调用 [保存群聊标识接口](contracts/order-im-group.md),随后刷新订单详情和骑手列表,确认 `imGroupId` 一致。多店组按 `members[]` 中各真实子单读取,不能用配送组 `groupId` 代替 IM 群标识;历史未绑定单为空。再验证重复保存成功、超过 200 字符及无关账号被拒绝,实际成员邀请和聊天由 App/IM 联调确认。

+ 8 - 0
specs/012-im-user-integration/spec.md

@@ -104,3 +104,11 @@ IM 平台的访问域名与鉴权令牌(extToken)集中存放于系统配置
 - 接口通过项目现有登录鉴权机制识别当前用户(解析 token → userId),不新增鉴权方式。
 - 配置文件采用项目现有 application.yml 风格(参考 ezpay / newebpay 段),新增独立 im 配置段。
 - HTTP 客户端沿用项目现有 org.apache.http 用法(参考 NewebPay 工具类)。
+
+## 2026-10-26 追加:订单群聊标识
+
+- **FR-010**: `pos_order` 增加可空的 `im_group_id VARCHAR(200)`,Java/API 字段为 `imGroupId`。每张真实餐饮订单独立保存,包含多店下单子单;不能与多店配送组 `groupId` 混用。
+- **FR-011**: 提供 `POST /system/order/imGroup`,请求头 `token`,JSON DTO 包含字符串 `ddId` 与 `imGroupId`。复用订单详情权限,只允许订单用户、当前接单骑手和有订单所属门店权限的商家调用;空值或超过 200 字符拒绝。重复相同值成功,传入新值更新该订单标识,仅修改该字段。
+- **FR-012**: 订单详情、旧骑手订单列表/详情返回 `imGroupId`;新版骑手任务的普通单卡返回 `imGroupId`,多店组在 `members[].imGroupId` 及各 `legacyOrder.imGroupId` 返回逐单标识。未绑定时为空。
+- **FR-013**: App/IM 负责建群和成员维护,后端接口仅保存与查询群标识;本次不接入 IM 建群/邀请/解散接口,不修改订单状态。
+- **SC-006**: 覆盖 200 字符边界、非法输入、重复保存及越权拒绝;验证 XML 读写映射、订单详情、骑手新旧列表和多店成员字段透传。

+ 9 - 0
specs/012-im-user-integration/tasks.md

@@ -148,3 +148,12 @@ description: "Task list for 骑手与用户 IM 即时沟通账号接入"
 - `imUserId` 在 VO 中用 String,落库用 Long/BIGINT(防前端 JS 精度丢失 + 容纳 19 位)
 - 复用现有 `JwtUtil.getusid(token)` 解析用户,不新增鉴权方式
 - 不改动注册流程(用户明确要求)
+
+## 2026-10-26 追加:订单群聊标识
+
+- [x] T015 `PosOrder.imGroupId`、XML 查询/新增/修改映射及 `VARCHAR(200)` 手工 SQL。
+- [x] T016 在现有订单 Controller 增加保存接口和 DTO,复用登录及订单访问规则,仅更新群标识。
+- [x] T017 新旧骑手列表、详情和多店逐店摘要返回群标识,补充接口契约与 App 接入说明。
+- [x] T018 验证权限/200 字符边界/幂等与字段读写,运行定向测试和构建;实际数据库迁移与 App/IM 联调由部署后验证。
+
+T018 验证(2026-10-26):JDK 21 定向运行 OrderImGroupControllerTest、PosOrderImGroupMapperTest、PosOrderQsOprateControllerTest、MultiStoreCheckoutQueryServiceTest、MultiStoreRiderRoutesTest、OrderListPaymentFilterTest、MultiStoreCheckoutBatchServiceTest、UserOrderParentDetailMultiStoreTest,共 47 个用例,失败/错误/跳过均为 0;同轮 `mvn -q -pl ruoyi-admin -am -Dtest=上述类 -Dsurefire.failIfNoSpecifiedTests=false package` 打包退出码 0。未执行数据库迁移,未调用实际 IM 建群或验证 App 聊天。

+ 20 - 2
specs/033-native-push-routing/plan.md

@@ -1,12 +1,16 @@
 # 可切换原生业务推送 Implementation Plan
 
+## 2026-10-26 追加:用户原生系统语音
+
+在已有发送器增加可选 NativePushSound 重载,由 BusinessPushService 在 native 分支按九场景/七语种解析;uni 分支不读取语言或声音。使用现有 /changeLanguages 的同源 Jedis 值,未知语言/缓存错误回退繁中并释放连接。FCM 配置固定声音渠道和资源名,APNs 配置随包 WAV;另给前台提供 sound。测试入口可选 language,原有请求兼容。63 个 WAV 和 manifest 随规格交付 App,不放后端 classpath,不改数据库/配置或 App。按 035 T016–T019 完成定向测试、package、资源与差异检查;真机另外验收。
+
 > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
 
 **Goal:** 在不改变默认 uniPush 行为的前提下,为当前在用的普通业务通知增加可通过服务端配置和重启启用的三端 Android FCM / iOS APNs 原生发送能力。
 
 **Architecture:** `PayPush` 的业务封装与现有直接业务发送点统一委托给 `BusinessPushService`。服务按全局 `business-push.mode` 选择现有 uniCloud 方法或原生发送器;原生时按显式接收端类型与数据库中当前账号的 `cidType/deviceToken` 选择三套独立凭据。`PushEvent` 继续由原调用点处理,路由层只负责外发。
 
-**Tech Stack:** Java 21、Spring Boot、现有 Firebase Admin SDK、JDK HTTP/2 `HttpClient`、PKCS#12 APNs 普通通知证书、JUnit/Mockito。
+**Tech Stack:** Java 21、Spring Boot、现有 Firebase Admin SDK、Pushy 0.13.10(HTTP/2 APNs 客户端)、PKCS#12 APNs 普通通知证书、JUnit/Mockito。
 
 **Spec:** `specs/033-native-push-routing/spec.md`
 
@@ -88,7 +92,7 @@ FCM 请求的 data 至少包含字符串形式的 `title`、`body`、原始 `pay
 
 - [ ] Write tests: each app selects its own topic/certificate; DEVELOPMENT/PRODUCTION selects sandbox/production host; alert title/body, sound and custom payload preserve business fields; malformed/expired/no-private-key PKCS#12 fails initialization; non-2xx response is categorized without printing secrets.
 - [ ] Run `mvn -pl ruoyi-admin -am -Dtest=ApnsPushSenderTest -Dsurefire.failIfNoSpecifiedTests=false test` with JDK 21 and verify failure before implementation.
-- [ ] Load the app-specific PKCS#12 into per-app TLS client, reuse JDK HTTP/2 connections, send to `/3/device/{token}` on the selected environment's gateway; use ordinary alert push, not VoIP; use rider `pushsound.caf` and the existing default sound for other apps.
+- [ ] Load the app-specific PKCS#12 into per-app TLS client, reuse Pushy HTTP/2 connections, send to `/3/device/{token}` on the selected environment's gateway; use ordinary alert push, not VoIP; use rider `pushsound.caf` and the existing default sound for other apps.(2026-10-26 替换原 JDK HttpClient 方案,以下追加计划为准。)
 - [ ] Re-run the same directed command and confirm pass; inspect staged names, commit only Task 3 files.
 
 ## Task 4: 统一外发路由与现有包装方法
@@ -154,3 +158,17 @@ PowerShell 中仅对当前命令临时设置 `$env:JAVA_HOME='C:\Users\99029\.jd
 ## Task 9: 常驻匿名原生推送测试接口(2026-10-23 追加)
 
 保留普通业务 `business-push.mode` 的现有行为。在 `ruoyi-admin` 增加 `POST /system/push/testNative`:DTO 接收 `appType`(user/merchant/rider)、`platform`(android/ios)、原生 `deviceToken`、标题、正文及可选 payload,不查询用户数据。FCM/APNs 发送器在测试调用时只加载当前端凭据,iOS 测试固定使用生产网关;发送成功返回“服务商已接受”,失败返回脱敏错误类别。接口按用户要求允许匿名访问,文档明确公网滥用风险与真机测试前提。用发送器和 Controller 定向测试证明默认 `uni` 下仍可测试,且业务路由不改变。
+# 2026-10-26 配置方式执行记录
+
+按用户要求去掉business-push全部显式环境变量占位符,三端资源路径固定为既有classpath文件,密码/topic从同端配套JSON原值填入YAML。保留当前默认uni和production,不自动启用业务原生通道;不改推送payload、接收端选择和通话推送。使用现有APNs证书加载、配置校验和原生测试入口回归;不发送真实外部推送、不输出凭据内容。
+
+## 2026-10-26 APNs 发送实现对齐参考工程
+
+1. 采用 `D:/QtwCode/CTE/qtw-push-server/qtw-push-server` 使用的 `com.turo:pushy:0.13.10`,仅替换 `ApnsPushSender` 的 HTTP/2 传输,不迁移参考工程的 WFC/VoIP 业务协议或异步先返回成功的行为。
+2. 通过 classpath 流加载三端原有 `.p12`,保留私钥、有效期校验与 JSON 补空值兼容。使用 `ApnsClientBuilder.setClientCredentials`;按接收端和 APNs 环境复用独立客户端,共用有界 I/O 线程,停机关闭客户端和线程组。
+3. 发送 `SimpleApnsPushNotification`,保留 `alert`、优先级 10、原 payload、骑手铃声。最多等待苹果响应 10 秒,拒绝、超时、网络异常、中断均返回脱敏类别;不自动换网关或重试,不将入队成功当作苹果接受。
+4. 原生测试和业务原生发送均按 `apns-environment` 选择客户端,不得复用其他环境的连接;`uni` 默认和 Android FCM 不变。先完成模拟接受/拒绝与客户端隔离回归,再执行推送定向测试、JDK 21 模块打包和差异核对。真实 APNs 投递由部署后另行验证。
+
+## 2026-10-26 APNs 环境修正
+
+此前“本地固定 production、测试入口始终正式”的执行记录为历史状态,由本节覆盖。当前 YAML 为 development,正式签名包部署时改为 production;不新增环境变量。将 `sendForTestProduction` 改为 `sendForTest`,读取现有 APNs 环境并按需初始化目标端,Controller 不增加请求字段。参数化覆盖 development/production 下默认 uni 也可原生测试,并保持客户端按端和环境隔离。先用旧代码复现 development 配置仍选 production 的失败,再完成推送定向测试和模块打包;不在本机发送真实外部推送。

+ 24 - 13
specs/033-native-push-routing/quickstart.md

@@ -1,5 +1,7 @@
 # 普通业务推送:原生通道部署与回退
 
+> **2026-10-26 声音接入补充:** 用户原生通知现支持按场景/语种选择前台、后台及锁屏声音;App 需携带 WAV 并初始化 Android 渠道。完整资源、字段与测试示例见 [035 原生声音接入](../035-user-notification-voice/native-sound-integration.md)。匿名 POST /system/push/testNative 可选 language,场景仍放原 payload JSON 字符串,未知语言默认繁中;原生测试不改变默认 uni 模式。商家/骑手原声音保持,旧请求未含场景继续默认声音。
+
 **状态(2026-09-29)**:服务端具备 FCM/APNs 路由能力,默认 `uni`,**尚未启用原生推送**。本功能不处理音视频通话,也不包含 App 端 token 获取、上报和展示改造;未完成真机验证前不要切换生产环境。
 
 ## 配置与凭据
@@ -12,36 +14,37 @@
 | 商家(含夜市及分管账号) | `merchant-fcm-service-account.json` | `merchant-apns.p12` | `merchant-apns-config.json` |
 | 骑手 | `rider-fcm-service-account.json` | `rider-apns.p12` | `rider-apns-config.json` |
 
-APNs 配置 JSON 使用原文件的 `packetName` 作为 topic、`pwd` 解锁同端 `.p12`;三端 Firebase 项目分别对应各自 App,不能共享 `server.json`。不使用 VoIP 证书。`application.yml` 默认指向上述 classpath 文件,仍保留显式文件路径及 APNs 密码/topic 覆盖能力,以兼容原部署方式。
+APNs配置JSON原文件的packetName/pwd分别提供topic和证书密码;2026-10-26按用户要求已将这些既有值直接固定到application.yml,各端路径也固定为自身classpath资源。不生成或重设证书密码。三端Firebase项目分别对应各自App,不共享server.json,不使用VoIP证书。
 
-| 设置 | 环境变量 | 默认/要求 |
+| 设置 | 本地配置项 | 当前值/要求 |
 | --- | --- | --- |
-| 通道 | `BUSINESS_PUSH_MODE` | `uni`;可选 `native`,重启后生效 |
-| APNs 环境 | `BUSINESS_PUSH_APNS_ENVIRONMENT` | `production`;测试包用 `development` |
-| 三端 FCM 路径覆盖 | `BUSINESS_PUSH_{USER|MERCHANT|RIDER}_FCM_CREDENTIALS_PATH` | 可选,默认各自的 classpath JSON |
-| 三端 APNs 证书路径覆盖 | `BUSINESS_PUSH_{USER|MERCHANT|RIDER}_APNS_CERTIFICATE_PATH` | 可选,默认各自的 classpath `.p12` |
-| 三端 APNs 配置路径覆盖 | `BUSINESS_PUSH_{USER|MERCHANT|RIDER}_APNS_CONFIG_PATH` | 可选,默认各自的 classpath 配置 JSON |
+| 通道 | `business-push.mode` | 固定`uni`;如启用`native`直接改YAML并重启 |
+| APNs环境 | `business-push.apns-environment` | 当前`development`(sandbox);正式签名包(含TestFlight/App Store)设为`production` |
+| 三端FCM路径 | `apps.{user/merchant/rider}.fcm-credentials-path` | 固定各端classpath JSON |
+| 三端APNs路径 | `apps.{user/merchant/rider}.apns-certificate-path` | 固定各端classpath .p12 |
+| 三端配套JSON路径 | `apps.{user/merchant/rider}.apns-config-path` | 固定各端classpath配套JSON |
+| 三端密码/topic | `apps.{user/merchant/rider}.apns-certificate-password/apns-topic` | YAML固定值来自各自JSON,非服务端自行生成 |
 
-如需覆盖 APNs 配置 JSON,也可分别设置原有的 `BUSINESS_PUSH_{USER|MERCHANT|RIDER}_APNS_CERTIFICATE_PASSWORD` 与 `BUSINESS_PUSH_{USER|MERCHANT|RIDER}_APNS_TOPIC`;两项都显式提供时不读取配置 JSON。默认 `uni` 模式不读取原生凭据;`native` 模式启动时校验三端配置并加载文件,缺失、不可读或证书无私钥/已过期时启动失败。
+不再通过BUSINESS_PUSH系列环境变量修改配置;直接维护YAML。两项APNs值均固定后,发送器优先使用YAML,无需再从JSON补空值;未来更换证书时须同步更新YAML中的密码/topic,只改JSON不会覆盖这些固定值。uni模式不初始化业务原生凭据;native模式启动时校验三端并加载资源,缺失/不可读/无私钥/过期会失败。匿名测试仍按需初始化所选端。
 
-`BUSINESS_PUSH_APNS_ENVIRONMENT=development` 使用 APNs sandbox 网关,`production` 使用正式网关;两种环境读取同端同一份证书。已核对三端 `packetName` 与证书主题相符,但证书能否在两个网关实际投递仍需真机验证。三端证书到期日为 2027-07-30;更新任一资源后必须重新构建并部署 JAR,再做各端测试。
+development/production分别对应sandbox/正式网关,当前配置development;匿名测试和业务原生发送均遵循该配置,不再固定正式网关。正式签名包部署前改为production。APNs环境取决于安装包签名与Token,不能仅凭业务服务器是测试还是生产来判断;TestFlight/App Store包即使连接测试服务器,也仍须使用production。已核对三端packetName与证书主题相符,但外部真实投递仍须真机验证。三端证书到期日为2027-07-30;改配置或资源后重新构建部署JAR并验证,不通过重新生成密码解决BadDeviceToken。
 
 ## 启用前条件
 
 1. App 端分别上报真实的 Android FCM token 或 iOS APNs device token 至 `info_user.deviceToken`,并把 `cidType` 设置为 `android` 或 `ios`;`cid` 只用于 uniPush,不能复制到 `deviceToken`。本次交付不实现 App 端上报。
 2. 确认新手机登录后旧手机会话失效,数据库中账号只保留当前设备 token;原生发送时服务端重新读取该账号的当前 `cidType/deviceToken`。
-3. 在非生产环境确认私有 JAR 包含九个按端命名的资源,设置对应 APNs 环境与 `BUSINESS_PUSH_MODE=native` 并重启。先检查服务启动,再用三端 Android/iOS 各一台真机触发订单、闪送和支付通知;核对通知栏、点击 payload、消息中心、语言与接收人。iOS 骑手使用 `pushsound.caf`;Android 骑手 FCM 带 `qsRing` 通知渠道标识,须由 App 创建并配置该渠道才能保证自定义铃声,本次不修改 App。门店订单有可用分管账号时只通知分管账号,没有时才回退主账号。
+3. 在非生产环境确认私有 JAR 包含九个按端命名的资源,在 `application.yml` 设置对应 APNs 环境与 `business-push.mode: native` 并重启。先检查服务启动,再用三端 Android/iOS 各一台真机触发订单、闪送和支付通知;核对通知栏、点击 payload、消息中心、语言与接收人。iOS 骑手使用 `pushsound.caf`;Android 骑手 FCM 带 `qsRing` 通知渠道标识,须由 App 创建并配置该渠道才能保证自定义铃声,本次不修改 App。门店订单有可用分管账号时只通知分管账号,没有时才回退主账号。
 4. 重点覆盖 CID 为空而原生 token 有效、token 缺失/失效、账号换机、预约闪送进入可抢单窗口,以及发送失败但业务操作成功。某条原生通知失败时不会自动回退 uniPush;原路径已有的消息中心记录应保持一次。
 
 ## 切换与回退
 
-生产切换须先完成 App 端配套和真机验收。使用包含上述资源的受控构建,将 `BUSINESS_PUSH_MODE` 设为 `native`,按实例逐一重启并检查启动及推送错误日志;一条通知只会选择当前实例的一个通道,滚动重启期间不同实例可能短暂处于不同模式。若原生投递异常,改回 `BUSINESS_PUSH_MODE=uni` 并逐一重启;之后的新通知重新使用 `cid`,已失败的通知不会由服务端自动补发。回退时确认 App 仍维护有效的 uniPush CID。
+生产切换须先完成 App 端配套和真机验收。使用包含上述资源的受控构建,在 YAML 将 `business-push.mode` 设为 `native`,按实例逐一重启并检查启动及推送错误日志;一条通知只会选择当前实例的一个通道,滚动重启期间不同实例可能短暂处于不同模式。若原生投递异常,改回 YAML 的 `business-push.mode: uni` 并逐一重启;之后的新通知重新使用 `cid`,已失败的通知不会由服务端自动补发。回退时确认 App 仍维护有效的 uniPush CID。
 
 自动化测试验证三端 classpath 凭据可加载、路由、请求构造和错误隔离;真实设备 token、FCM/APNs 外部投递、通知展示与回退操作仍需部署环境验证。
 
 ## ApiPost 原生推送测试(业务模式无需切换)
 
-常驻接口 `POST /system/push/testNative` 允许匿名访问,`Content-Type: application/json`,无需账号 ID 或后台 token。即使 `BUSINESS_PUSH_MODE` 保持默认 `uni`,该接口仍直接调用原生通道;Android 使用所选端的 FCM 项目,iOS 固定走 APNs 正式网关。请求示例:
+常驻接口 `POST /system/push/testNative` 允许匿名访问,`Content-Type: application/json`,无需账号 ID 或后台 token。即使 YAML 的 `business-push.mode` 保持默认 `uni`,该接口仍直接调用原生通道;Android 使用所选端的 FCM 项目,iOS 遵循 `apns-environment`,当前为 sandbox。请求示例:
 
 ```json
 {
@@ -54,8 +57,16 @@ APNs 配置 JSON 使用原文件的 `packetName` 作为 topic、`pwd` 解锁同
 }
 ```
 
-`appType` 可填 `user`、`merchant`、`rider`;`platform` 可填 `android`、`ios`。测试 iOS 时改为 `ios` 并传正式包取得的 64 位十六进制 APNs token;开发包的 sandbox token 不能用于这个正式网关。`payload` 可省略。响应 `code=200` 只表示 FCM/APNs 接受发送请求,**不证明手机通知栏已显示**;失败时 `code=500` 和 `msg` 返回不含 token 的错误类别。需在真机核对通知权限、前台展示与通知栏,不调用业务订单流程,也不写入 `push_message`。
+`appType` 可填 `user`、`merchant`、`rider`;`platform` 可填 `android`、`ios`。测试 iOS 时改为 `ios` 并传与所配网关匹配的 64 位十六进制 APNs token:development 对应开发签名包,production 对应正式签名包;两类 Token 不能混用。`payload` 可省略。响应 `code=200` 只表示 FCM/APNs 接受发送请求,**不证明手机通知栏已显示**;失败时 `code=500` 和 `msg` 返回不含 token 的错误类别。需在真机核对通知权限、前台展示与通知栏,不调用业务订单流程,也不写入 `push_message`。
 
 该接口按需求长期匿名开放,公网部署后任何知道地址的人都可以使用服务端凭据向其掌握的设备 Token 发送消息,可能造成滥用和推送配额消耗;如不接受此风险,应在网关限制可访问来源或后续改为鉴权接口。
 
 外卖和闪送新单通知附近最多 20 名骑手时,`native` 模式会按骑手当前设备类型分流:Android 骑手按相同语言文案和订单 payload 使用骑手端 FCM multicast,逐 token 处理返回结果;iOS 骑手继续逐设备走 APNs。每位骑手的消息中心事件保持独立,批量失败不回退 uniPush。默认 `uni` 模式及其逐骑手新单通知完全不变,旧的 uni 批量方法未接入此流程。
+
+## iOS 发送实现(2026-10-26)
+
+后端改用参考工程相同的 Pushy 0.13.10,三端现有普通推送证书和 topic 继续使用,不需要换 App 接口或增加配置。业务原生模式和测试接口都按所配环境发送,客户端按接收端和环境缓存复用。
+
+测试接口等待苹果返回结果:接受后才返回成功,拒绝继续显示 `APNS_BadDeviceToken` 等类别;等待超过 10 秒返回 `APNS_TIMEOUT`,网络错误返回 `APNS_TRANSPORT_ERROR`。超时不表示苹果一定未接受,请勿直接据此重复发送。日志记录拒绝的端、环境、类别,不记录完整 Token 或密钥。
+
+本地证书加载和模拟苹果响应可通过自动测试验证;是否消除这台设备的 BadDeviceToken,仍须部署新 JAR 后用对应 App 当前上报且匹配网关的 Token 调用测试接口并核对真机通知。替换 SDK 不能替代真实投递验收。

+ 20 - 2
specs/033-native-push-routing/spec.md

@@ -1,5 +1,9 @@
 # Feature Specification: 可切换的原生业务推送通道
 
+## 2026-10-26 原生用户系统语音补充
+
+原生用户通知支持九个明确场景、七语种随包 WAV 声音,语言未知默认繁中。FCM 使用 android.notification.sound/channel_id 与 data.sound/channelId;APNs 使用 aps.sound 与顶层 sound;payload 原样保留。音频与渠道不从服务端下载,App 必须预先打包/初始化;uni、商家/骑手及原生骑手批量默认声音不变。匿名测试新增可选 language,仍不切换业务模式。完整契约及资源见 [035 原生声音接入](../035-user-notification-voice/native-sound-integration.md)。后台/锁屏由系统处理,真机效果另行验收。
+
 **Feature Branch**: `033-native-push-routing`
 
 **Created**: 2026-09-29
@@ -108,7 +112,7 @@
 - **FR-011**: 实现范围 MUST 排除音视频通话推送、已废弃订单/支付入口及 App 端代码;不新增推送业务场景或改变登录互斥规则。
 - **FR-012**: 本功能 MUST 沿用现有 `info_user` 单设备字段,不新增数据库字段或设备表。
 - **FR-013**: 外卖和闪送新单向多名骑手开放时,仅在 `native` 模式对 Android 骑手按相同标题、正文及 payload 分组,使用骑手端 FCM multicast 发送;iOS 骑手保留逐设备 APNs 请求,`uni` 模式保留原有逐骑手发送方式,不接入旧批量方法。各骑手的语言、消息中心记录和发送失败处理 MUST 保持独立,原生失败不得回退 uniPush。
-- **FR-014**: 服务端 MUST 提供常驻的匿名 HTTP 原生推送测试接口,调用者直接提供接收端 `appType`、设备 `platform`、`deviceToken`、标题和正文;接口不读取账号、不验证 token 归属、不切换全局业务推送模式。Android 直发对应端 FCM,iOS 固定走 APNs 正式网关。只按需初始化所选端凭据,成功响应只表示 FCM/APNs 接受请求,失败响应提供不含 token/密钥的错误类别。该接口不发布业务事件、不写入消息中心。公网部署时任何调用者均可使用该接口发送测试消息,属于明确接受的匿名调用风险。
+- **FR-014**: 服务端 MUST 提供常驻的匿名 HTTP 原生推送测试接口,调用者直接提供接收端 `appType`、设备 `platform`、`deviceToken`、标题和正文;接口不读取账号、不验证 token 归属、不切换全局业务推送模式。Android 直发对应端 FCM,iOS 与业务原生推送一致,遵循 `business-push.apns-environment`:`development` 走 sandbox,`production` 走正式网关,禁止固定正式网关或拒绝后自动换网关。只按需初始化所选端凭据,成功响应只表示 FCM/APNs 接受请求,失败响应提供不含 token/密钥的错误类别。该接口不发布业务事件、不写入消息中心。公网部署时任何调用者均可使用该接口发送测试消息,属于明确接受的匿名调用风险。
 
 ### Key Entities *(include if feature involves data)*
 
@@ -126,7 +130,7 @@
 - **SC-003**: 受测通知在正常发送、token 缺失和发送异常场景下,单次均不出现双通道外发;现有会入库的路径每次最多生成一条消息记录。
 - **SC-004**: 模拟 FCM/APNs 不可用或 token 无效时,原业务操作结果与不发送推送时一致;失败可通过日志定位到接收端、平台与错误类别,日志不包含完整 token 或凭据。
 - **SC-005**: 测试配置切回 `uni` 并重启后,后续受测通知恢复原 uniPush 发送路径;音视频通话路径保持原行为。
-- **SC-006**: 保持业务模式为 `uni` 时,匿名测试接口仍能按所传端类型与平台分别向 FCM/APNs 正式网关发送,不需要修改配置或重启;错误响应不泄露设备 token,业务发送路线保持原样。
+- **SC-006**: 保持业务模式为 `uni` 时,匿名测试接口仍能按所传端类型与平台分别向 FCM/所配置的 APNs 网关发送,无需切换业务通道;`development` 和 `production` 的定向测试分别选中对应客户端,错误响应不泄露设备 token,业务发送路线保持原样。
 
 ## Assumptions
 
@@ -135,3 +139,17 @@
 - 原生能力交付后仍保持 `uni` 为默认值;凭据虽随包提供,只有运维确认 App 端 token、将服务端配置切为 `native` 并重启,才会启用原生通道。
 - 音视频通话使用其他推送流程,不受本次普通业务通知通道开关控制;`voIPToken` 及 VoIP 证书不用于本功能。
 - 证书与 Firebase 服务账号由用户提供并提交到本仓库;仓库访问、构建产物分发和凭据轮换由部署方按含密钥材料管理。默认 `uni` 下不加载这些凭据。
+# 2026-10-26 配置方式调整
+
+按用户要求,application.yml的business-push配置全部使用本地固定值,不再使用BUSINESS_PUSH系列环境变量占位符。三端FCM/证书/配套JSON路径各自固定,密码和topic分别复制既有同端JSON的pwd/packetName,不生成新密码,不更改证书。业务通道保留原默认uni;APNs环境固定production。需启用native或调整配置时直接编辑本地YAML并重启,匿名原生测试接口仍独立于业务通道。
+
+## 2026-10-26 iOS 发送层对齐
+
+- 普通 APNs 通知改用参考推送工程同版本 Pushy 0.13.10;保留三端独立证书/topic、固定本地配置、消息 payload 和骑手铃声。Android、App 代码和 VoIP 不在改动范围。
+- 客户端按「接收端 + 开发/生产环境」隔离并复用;测试接口和业务原生发送均按 `apns-environment` 选择网关。开发签名包对应 `development`,TestFlight/App Store 对应 `production`。不得因网关拒绝自动切换环境。
+- 发送成功必须以苹果 `isAccepted()` 为准,最多等待 10 秒。苹果拒绝继续返回 `APNS_BadDeviceToken` 等脱敏类别,超时为 `APNS_TIMEOUT`,网络异常为 `APNS_TRANSPORT_ERROR`,中断为 `APNS_INTERRUPTED`。超时并不证明未发送,不自动重发。
+- SDK 更换不保证消除苹果对设备 Token 的拒绝。证书可加载及模拟测试通过与真实设备可接收分别验收;未经真机投递不能宣称 BadDeviceToken 已修复。
+
+## 2026-10-26 APNs 环境规则修正(覆盖此前固定正式网关的规定)
+
+用户确认当前待测 Token 需要苹果测试网关。当前仓库 YAML 改为 `apns-environment: development`,测试接口不再固定 production;正式签名包部署时显式改为 `production` 后重新构建/重启。采用本地固定配置,不新增环境变量或 App 参数,不根据业务服务器名称、TestFlight/App Store 安装来源推断 APNs 环境。Token 必须匹配安装包实际签名中的 APNs 环境,测试服务器上的正式签名包也要使用 production。

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

@@ -1,5 +1,15 @@
 # Tasks: 可切换的原生业务推送通道
 
+## 2026-10-26 原生用户语音追加
+
+**进度:实现中(本地验证已通过,未提交)。**
+
+- [x] T025 对齐 035 新增用户原生 sound 解析及 FCM/APNs 重载,测试入口支持可选 language;uni 与原商家/骑手声音保持。
+- [x] T026 交付 63 个 WAV 及 manifest、App 渠道/前台播放/系统声音和 Apipost 接入说明。
+- [x] T027 执行 JDK 21 推送与闪送通知定向测试、package、音频元数据和差异检查,记录真实未验证项;验证内容与 035 T019 共用。
+
+验证记录:JDK 21 定向 `*Push*Test,FlashDeliveryNotificationServiceTest` 共 141 项全部通过,package 退出 0;63 WAV 编码/时长/manifest 映射及 git diff --check 通过。未执行完整回归、部署、真实云端投递或 App 真机播放,未提交。详细记录见 035 T019。
+
 **Input**: `specs/033-native-push-routing/spec.md`、`plan.md`
 
 **Execution**: 当前 `test-202609v2` 分支;不创建新分支。每项先写并运行失败测试,再实现、验证、核对暂存范围并提交。默认模式始终为 `uni`。
@@ -46,3 +56,27 @@ T001–T002 → T003/T004 → T005–T006 → T007 → T008–T009。FCM 与 APN
 - [x] T016 先写失败测试:默认 `uni` 时只按需初始化目标端 FCM/APNs,iOS 测试固定发送到 APNs 正式网关。
 - [x] T017 新增匿名 `POST /system/push/testNative`,直接使用请求提供的设备 Token,不查询账号;返回服务商接受或脱敏失败类别,保留普通业务推送模式。
 - [x] T018 更新 ApiPost 示例与风险说明,完成差异检查、定向测试和模块构建;真实手机通知由部署后人工验证。
+# 2026-10-26 本地固定配置
+
+- [x] 去掉business-push所有显式环境变量占位符;三端路径、密码、topic按本地配套文件固定,保持uni/production当前默认,补充来源注释与使用说明。
+- [x] APNs证书加载、配置校验、测试入口及发票鉴权定向回归通过;用户要求核对后分项中文提交并推送,提交状态及SHA以Git为准。
+
+2026-10-26:本地JDK21合并定向测试39项通过,失败/错误均0,包含真实三端classpath证书加载、模拟APNs/FCM响应及发票鉴权;不代表真实设备投递成功。未执行服务器部署或真实外部推送,BadDeviceToken尚待实际安装包/当前token/部署配置联调验证。
+
+## 2026-10-26 追加:使用参考工程的 Pushy 发送方式
+
+- [x] T019 对照 qtw-push-server,补充类型化 APNs 通知、苹果接受/拒绝测试;新增工厂签名在旧实现下编译失败,确认尚未具备 Pushy 发送能力。
+- [x] T020 以 Pushy 0.13.10 替换 JDK HttpClient,保留原有证书/消息/铃声;按端和环境缓存客户端,等待苹果真实响应并脱敏分类,有限时关闭资源,添加说明注释。
+- [x] T021 验证正式测试与开发业务客户端隔离、复用、关闭、超时/中断/异常及三端证书加载;完成推送定向测试、JDK 21 模块构建与 diff 检查,记录真实设备投递未验证。
+
+验证记录:JDK 21 `mvn -pl ruoyi-admin -am -Dmaven.test.skip=true package dependency:build-classpath` 与随后本地 `install` 均退出 0,确认 JAR 包含 `pushy-0.13.10.jar` 和受项目统一版本管理的 Netty HTTP/2 依赖。本轮常规 reactor 测试被工作区并行新增的 `PosOrderImGroupMapperTest` 引用未实现的 `setImGroupId` 阻断,未修改这部分文件。为避免并行构建覆盖 `target/classes`,复制构建结果至 `target/apns-validation`,使用 JDK 21 javac 定向编译本轮推送源码和全部 `*Push*Test`,再以同版 JUnit Platform Console 1.10.5 执行:78 项成功,0 失败/中止/跳过。日志为 `ruoyi-admin/target/apns-pushy-console-tests.log`,报告在 `target/apns-validation/reports`。全量测试、服务器部署、真实 APNs/FCM 及 TestFlight/App Store 真机投递未执行,不能据此判定 BadDeviceToken 已解决。
+
+## 2026-10-26 追加:APNs 测试/正式网关按配置选择
+
+以下任务覆盖历史记录中“测试入口固定正式网关”的规则。
+
+- [x] T022 参数化复现旧实现错误:配置 development 且业务 uni 时,测试入口仍选 PRODUCTION;16 项 APNs 测试中该项失败,production 用例通过。
+- [x] T023 测试发送改为 sendForTest 并读取现有 apns-environment;当前 YAML 设 development,补充正式签名包使用 production 的注释;不增加环境变量、请求字段或网关失败自动切换。
+- [x] T024 更新规格、计划和接入说明,验证双环境原生测试、业务通道和客户端隔离,执行 JDK 21 推送定向测试、打包及差异检查;真实 APNs 投递单独验收。
+
+验证记录:`mvn -pl ruoyi-admin -am -Dtest=*Push*Test -Dsurefire.failIfNoSpecifiedTests=false package` 在 JDK 21 下退出 0,79 项定向测试成功,0 失败/错误/跳过;包含 development 和 production 的测试入口用例及跨环境客户端隔离。`git diff --check` 通过。本轮未执行全量回归、服务器部署或真实 APNs 投递,测试证明配置路由已修正,不代表真机已收到通知。

+ 7 - 0
specs/035-user-notification-voice/assets/native-wav/README.md

@@ -0,0 +1,7 @@
+# 原生系统通知音频
+
+63 个 WAV 对应 9 场景 × en/id/ms/th/tl/zh-Hans/zh-Hant。编码为 16-bit Linear PCM、16 kHz、单声道,iOS 和 Android 共用文件。
+
+原 MP3 目录保留不变;简繁中文分别选择 zh-Hans/zh-Hant 源音频。文件名、原文件名、语种、Android 资源名与渠道 ID 见 [manifest.json](manifest.json)。未知语言默认 zh-Hant,未知场景沿用原默认声音。
+
+这些是交付 App 随包携带的资源,不是后端运行时下载或加载的声音。iOS 放入用户 App bundle,Android 放入 res/raw 并按清单预建渠道;详细说明见 [原生声音接入](../../native-sound-integration.md)。

BIN
specs/035-user-notification-voice/assets/native-wav/flash_delivery_stop_completed_en.wav


BIN
specs/035-user-notification-voice/assets/native-wav/flash_delivery_stop_completed_id.wav


BIN
specs/035-user-notification-voice/assets/native-wav/flash_delivery_stop_completed_ms.wav


BIN
specs/035-user-notification-voice/assets/native-wav/flash_delivery_stop_completed_th.wav


BIN
specs/035-user-notification-voice/assets/native-wav/flash_delivery_stop_completed_tl.wav


BIN
specs/035-user-notification-voice/assets/native-wav/flash_delivery_stop_completed_zh_hans.wav


BIN
specs/035-user-notification-voice/assets/native-wav/flash_delivery_stop_completed_zh_hant.wav


BIN
specs/035-user-notification-voice/assets/native-wav/flash_pickup_stop_completed_en.wav


BIN
specs/035-user-notification-voice/assets/native-wav/flash_pickup_stop_completed_id.wav


BIN
specs/035-user-notification-voice/assets/native-wav/flash_pickup_stop_completed_ms.wav


BIN
specs/035-user-notification-voice/assets/native-wav/flash_pickup_stop_completed_th.wav


BIN
specs/035-user-notification-voice/assets/native-wav/flash_pickup_stop_completed_tl.wav


BIN
specs/035-user-notification-voice/assets/native-wav/flash_pickup_stop_completed_zh_hans.wav


BIN
specs/035-user-notification-voice/assets/native-wav/flash_pickup_stop_completed_zh_hant.wav


+ 513 - 0
specs/035-user-notification-voice/assets/native-wav/manifest.json

@@ -0,0 +1,513 @@
+{
+  "format": "WAV",
+  "codec": "pcm_s16le",
+  "sampleRateHz": 16000,
+  "channels": 1,
+  "defaultLanguage": "zh-Hant",
+  "entries": [
+    {
+      "scene": "FLASH_DELIVERY_STOP_COMPLETED",
+      "language": "en",
+      "sourceFile": "FLASH_DELIVERY_STOP_COMPLETED_en.mp3",
+      "fileName": "flash_delivery_stop_completed_en.wav",
+      "androidResource": "flash_delivery_stop_completed_en",
+      "androidChannelId": "user_voice_flash_delivery_stop_completed_en_v1"
+    },
+    {
+      "scene": "FLASH_DELIVERY_STOP_COMPLETED",
+      "language": "id",
+      "sourceFile": "FLASH_DELIVERY_STOP_COMPLETED_id.mp3",
+      "fileName": "flash_delivery_stop_completed_id.wav",
+      "androidResource": "flash_delivery_stop_completed_id",
+      "androidChannelId": "user_voice_flash_delivery_stop_completed_id_v1"
+    },
+    {
+      "scene": "FLASH_DELIVERY_STOP_COMPLETED",
+      "language": "ms",
+      "sourceFile": "FLASH_DELIVERY_STOP_COMPLETED_ms.mp3",
+      "fileName": "flash_delivery_stop_completed_ms.wav",
+      "androidResource": "flash_delivery_stop_completed_ms",
+      "androidChannelId": "user_voice_flash_delivery_stop_completed_ms_v1"
+    },
+    {
+      "scene": "FLASH_DELIVERY_STOP_COMPLETED",
+      "language": "th",
+      "sourceFile": "FLASH_DELIVERY_STOP_COMPLETED_th.mp3",
+      "fileName": "flash_delivery_stop_completed_th.wav",
+      "androidResource": "flash_delivery_stop_completed_th",
+      "androidChannelId": "user_voice_flash_delivery_stop_completed_th_v1"
+    },
+    {
+      "scene": "FLASH_DELIVERY_STOP_COMPLETED",
+      "language": "tl",
+      "sourceFile": "FLASH_DELIVERY_STOP_COMPLETED_tl.mp3",
+      "fileName": "flash_delivery_stop_completed_tl.wav",
+      "androidResource": "flash_delivery_stop_completed_tl",
+      "androidChannelId": "user_voice_flash_delivery_stop_completed_tl_v1"
+    },
+    {
+      "scene": "FLASH_DELIVERY_STOP_COMPLETED",
+      "language": "zh-Hans",
+      "sourceFile": "FLASH_DELIVERY_STOP_COMPLETED_zh-Hans.mp3",
+      "fileName": "flash_delivery_stop_completed_zh_hans.wav",
+      "androidResource": "flash_delivery_stop_completed_zh_hans",
+      "androidChannelId": "user_voice_flash_delivery_stop_completed_zh_hans_v1"
+    },
+    {
+      "scene": "FLASH_DELIVERY_STOP_COMPLETED",
+      "language": "zh-Hant",
+      "sourceFile": "FLASH_DELIVERY_STOP_COMPLETED_zh-Hant.mp3",
+      "fileName": "flash_delivery_stop_completed_zh_hant.wav",
+      "androidResource": "flash_delivery_stop_completed_zh_hant",
+      "androidChannelId": "user_voice_flash_delivery_stop_completed_zh_hant_v1"
+    },
+    {
+      "scene": "FLASH_PICKUP_STOP_COMPLETED",
+      "language": "en",
+      "sourceFile": "FLASH_PICKUP_STOP_COMPLETED_en.mp3",
+      "fileName": "flash_pickup_stop_completed_en.wav",
+      "androidResource": "flash_pickup_stop_completed_en",
+      "androidChannelId": "user_voice_flash_pickup_stop_completed_en_v1"
+    },
+    {
+      "scene": "FLASH_PICKUP_STOP_COMPLETED",
+      "language": "id",
+      "sourceFile": "FLASH_PICKUP_STOP_COMPLETED_id.mp3",
+      "fileName": "flash_pickup_stop_completed_id.wav",
+      "androidResource": "flash_pickup_stop_completed_id",
+      "androidChannelId": "user_voice_flash_pickup_stop_completed_id_v1"
+    },
+    {
+      "scene": "FLASH_PICKUP_STOP_COMPLETED",
+      "language": "ms",
+      "sourceFile": "FLASH_PICKUP_STOP_COMPLETED_ms.mp3",
+      "fileName": "flash_pickup_stop_completed_ms.wav",
+      "androidResource": "flash_pickup_stop_completed_ms",
+      "androidChannelId": "user_voice_flash_pickup_stop_completed_ms_v1"
+    },
+    {
+      "scene": "FLASH_PICKUP_STOP_COMPLETED",
+      "language": "th",
+      "sourceFile": "FLASH_PICKUP_STOP_COMPLETED_th.mp3",
+      "fileName": "flash_pickup_stop_completed_th.wav",
+      "androidResource": "flash_pickup_stop_completed_th",
+      "androidChannelId": "user_voice_flash_pickup_stop_completed_th_v1"
+    },
+    {
+      "scene": "FLASH_PICKUP_STOP_COMPLETED",
+      "language": "tl",
+      "sourceFile": "FLASH_PICKUP_STOP_COMPLETED_tl.mp3",
+      "fileName": "flash_pickup_stop_completed_tl.wav",
+      "androidResource": "flash_pickup_stop_completed_tl",
+      "androidChannelId": "user_voice_flash_pickup_stop_completed_tl_v1"
+    },
+    {
+      "scene": "FLASH_PICKUP_STOP_COMPLETED",
+      "language": "zh-Hans",
+      "sourceFile": "FLASH_PICKUP_STOP_COMPLETED_zh-Hans.mp3",
+      "fileName": "flash_pickup_stop_completed_zh_hans.wav",
+      "androidResource": "flash_pickup_stop_completed_zh_hans",
+      "androidChannelId": "user_voice_flash_pickup_stop_completed_zh_hans_v1"
+    },
+    {
+      "scene": "FLASH_PICKUP_STOP_COMPLETED",
+      "language": "zh-Hant",
+      "sourceFile": "FLASH_PICKUP_STOP_COMPLETED_zh-Hant.mp3",
+      "fileName": "flash_pickup_stop_completed_zh_hant.wav",
+      "androidResource": "flash_pickup_stop_completed_zh_hant",
+      "androidChannelId": "user_voice_flash_pickup_stop_completed_zh_hant_v1"
+    },
+    {
+      "scene": "MERCHANT_ACCEPTED",
+      "language": "en",
+      "sourceFile": "MERCHANT_ACCEPTED_en.mp3",
+      "fileName": "merchant_accepted_en.wav",
+      "androidResource": "merchant_accepted_en",
+      "androidChannelId": "user_voice_merchant_accepted_en_v1"
+    },
+    {
+      "scene": "MERCHANT_ACCEPTED",
+      "language": "id",
+      "sourceFile": "MERCHANT_ACCEPTED_id.mp3",
+      "fileName": "merchant_accepted_id.wav",
+      "androidResource": "merchant_accepted_id",
+      "androidChannelId": "user_voice_merchant_accepted_id_v1"
+    },
+    {
+      "scene": "MERCHANT_ACCEPTED",
+      "language": "ms",
+      "sourceFile": "MERCHANT_ACCEPTED_ms.mp3",
+      "fileName": "merchant_accepted_ms.wav",
+      "androidResource": "merchant_accepted_ms",
+      "androidChannelId": "user_voice_merchant_accepted_ms_v1"
+    },
+    {
+      "scene": "MERCHANT_ACCEPTED",
+      "language": "th",
+      "sourceFile": "MERCHANT_ACCEPTED_th.mp3",
+      "fileName": "merchant_accepted_th.wav",
+      "androidResource": "merchant_accepted_th",
+      "androidChannelId": "user_voice_merchant_accepted_th_v1"
+    },
+    {
+      "scene": "MERCHANT_ACCEPTED",
+      "language": "tl",
+      "sourceFile": "MERCHANT_ACCEPTED_tl.mp3",
+      "fileName": "merchant_accepted_tl.wav",
+      "androidResource": "merchant_accepted_tl",
+      "androidChannelId": "user_voice_merchant_accepted_tl_v1"
+    },
+    {
+      "scene": "MERCHANT_ACCEPTED",
+      "language": "zh-Hans",
+      "sourceFile": "MERCHANT_ACCEPTED_zh-Hans.mp3",
+      "fileName": "merchant_accepted_zh_hans.wav",
+      "androidResource": "merchant_accepted_zh_hans",
+      "androidChannelId": "user_voice_merchant_accepted_zh_hans_v1"
+    },
+    {
+      "scene": "MERCHANT_ACCEPTED",
+      "language": "zh-Hant",
+      "sourceFile": "MERCHANT_ACCEPTED_zh-Hant.mp3",
+      "fileName": "merchant_accepted_zh_hant.wav",
+      "androidResource": "merchant_accepted_zh_hant",
+      "androidChannelId": "user_voice_merchant_accepted_zh_hant_v1"
+    },
+    {
+      "scene": "MERCHANT_READY",
+      "language": "en",
+      "sourceFile": "MERCHANT_READY_en.mp3",
+      "fileName": "merchant_ready_en.wav",
+      "androidResource": "merchant_ready_en",
+      "androidChannelId": "user_voice_merchant_ready_en_v1"
+    },
+    {
+      "scene": "MERCHANT_READY",
+      "language": "id",
+      "sourceFile": "MERCHANT_READY_id.mp3",
+      "fileName": "merchant_ready_id.wav",
+      "androidResource": "merchant_ready_id",
+      "androidChannelId": "user_voice_merchant_ready_id_v1"
+    },
+    {
+      "scene": "MERCHANT_READY",
+      "language": "ms",
+      "sourceFile": "MERCHANT_READY_ms.mp3",
+      "fileName": "merchant_ready_ms.wav",
+      "androidResource": "merchant_ready_ms",
+      "androidChannelId": "user_voice_merchant_ready_ms_v1"
+    },
+    {
+      "scene": "MERCHANT_READY",
+      "language": "th",
+      "sourceFile": "MERCHANT_READY_th.mp3",
+      "fileName": "merchant_ready_th.wav",
+      "androidResource": "merchant_ready_th",
+      "androidChannelId": "user_voice_merchant_ready_th_v1"
+    },
+    {
+      "scene": "MERCHANT_READY",
+      "language": "tl",
+      "sourceFile": "MERCHANT_READY_tl.mp3",
+      "fileName": "merchant_ready_tl.wav",
+      "androidResource": "merchant_ready_tl",
+      "androidChannelId": "user_voice_merchant_ready_tl_v1"
+    },
+    {
+      "scene": "MERCHANT_READY",
+      "language": "zh-Hans",
+      "sourceFile": "MERCHANT_READY_zh-Hans.mp3",
+      "fileName": "merchant_ready_zh_hans.wav",
+      "androidResource": "merchant_ready_zh_hans",
+      "androidChannelId": "user_voice_merchant_ready_zh_hans_v1"
+    },
+    {
+      "scene": "MERCHANT_READY",
+      "language": "zh-Hant",
+      "sourceFile": "MERCHANT_READY_zh-Hant.mp3",
+      "fileName": "merchant_ready_zh_hant.wav",
+      "androidResource": "merchant_ready_zh_hant",
+      "androidChannelId": "user_voice_merchant_ready_zh_hant_v1"
+    },
+    {
+      "scene": "ORDER_CANCELLED",
+      "language": "en",
+      "sourceFile": "ORDER_CANCELLED_en.mp3",
+      "fileName": "order_cancelled_en.wav",
+      "androidResource": "order_cancelled_en",
+      "androidChannelId": "user_voice_order_cancelled_en_v1"
+    },
+    {
+      "scene": "ORDER_CANCELLED",
+      "language": "id",
+      "sourceFile": "ORDER_CANCELLED_id.mp3",
+      "fileName": "order_cancelled_id.wav",
+      "androidResource": "order_cancelled_id",
+      "androidChannelId": "user_voice_order_cancelled_id_v1"
+    },
+    {
+      "scene": "ORDER_CANCELLED",
+      "language": "ms",
+      "sourceFile": "ORDER_CANCELLED_ms.mp3",
+      "fileName": "order_cancelled_ms.wav",
+      "androidResource": "order_cancelled_ms",
+      "androidChannelId": "user_voice_order_cancelled_ms_v1"
+    },
+    {
+      "scene": "ORDER_CANCELLED",
+      "language": "th",
+      "sourceFile": "ORDER_CANCELLED_th.mp3",
+      "fileName": "order_cancelled_th.wav",
+      "androidResource": "order_cancelled_th",
+      "androidChannelId": "user_voice_order_cancelled_th_v1"
+    },
+    {
+      "scene": "ORDER_CANCELLED",
+      "language": "tl",
+      "sourceFile": "ORDER_CANCELLED_tl.mp3",
+      "fileName": "order_cancelled_tl.wav",
+      "androidResource": "order_cancelled_tl",
+      "androidChannelId": "user_voice_order_cancelled_tl_v1"
+    },
+    {
+      "scene": "ORDER_CANCELLED",
+      "language": "zh-Hans",
+      "sourceFile": "ORDER_CANCELLED_zh-Hans.mp3",
+      "fileName": "order_cancelled_zh_hans.wav",
+      "androidResource": "order_cancelled_zh_hans",
+      "androidChannelId": "user_voice_order_cancelled_zh_hans_v1"
+    },
+    {
+      "scene": "ORDER_CANCELLED",
+      "language": "zh-Hant",
+      "sourceFile": "ORDER_CANCELLED_zh-Hant.mp3",
+      "fileName": "order_cancelled_zh_hant.wav",
+      "androidResource": "order_cancelled_zh_hant",
+      "androidChannelId": "user_voice_order_cancelled_zh_hant_v1"
+    },
+    {
+      "scene": "ORDER_DELIVERED",
+      "language": "en",
+      "sourceFile": "ORDER_DELIVERED_en.mp3",
+      "fileName": "order_delivered_en.wav",
+      "androidResource": "order_delivered_en",
+      "androidChannelId": "user_voice_order_delivered_en_v1"
+    },
+    {
+      "scene": "ORDER_DELIVERED",
+      "language": "id",
+      "sourceFile": "ORDER_DELIVERED_id.mp3",
+      "fileName": "order_delivered_id.wav",
+      "androidResource": "order_delivered_id",
+      "androidChannelId": "user_voice_order_delivered_id_v1"
+    },
+    {
+      "scene": "ORDER_DELIVERED",
+      "language": "ms",
+      "sourceFile": "ORDER_DELIVERED_ms.mp3",
+      "fileName": "order_delivered_ms.wav",
+      "androidResource": "order_delivered_ms",
+      "androidChannelId": "user_voice_order_delivered_ms_v1"
+    },
+    {
+      "scene": "ORDER_DELIVERED",
+      "language": "th",
+      "sourceFile": "ORDER_DELIVERED_th.mp3",
+      "fileName": "order_delivered_th.wav",
+      "androidResource": "order_delivered_th",
+      "androidChannelId": "user_voice_order_delivered_th_v1"
+    },
+    {
+      "scene": "ORDER_DELIVERED",
+      "language": "tl",
+      "sourceFile": "ORDER_DELIVERED_tl.mp3",
+      "fileName": "order_delivered_tl.wav",
+      "androidResource": "order_delivered_tl",
+      "androidChannelId": "user_voice_order_delivered_tl_v1"
+    },
+    {
+      "scene": "ORDER_DELIVERED",
+      "language": "zh-Hans",
+      "sourceFile": "ORDER_DELIVERED_zh-Hans.mp3",
+      "fileName": "order_delivered_zh_hans.wav",
+      "androidResource": "order_delivered_zh_hans",
+      "androidChannelId": "user_voice_order_delivered_zh_hans_v1"
+    },
+    {
+      "scene": "ORDER_DELIVERED",
+      "language": "zh-Hant",
+      "sourceFile": "ORDER_DELIVERED_zh-Hant.mp3",
+      "fileName": "order_delivered_zh_hant.wav",
+      "androidResource": "order_delivered_zh_hant",
+      "androidChannelId": "user_voice_order_delivered_zh_hant_v1"
+    },
+    {
+      "scene": "ORDER_DELIVERING",
+      "language": "en",
+      "sourceFile": "ORDER_DELIVERING_en.mp3",
+      "fileName": "order_delivering_en.wav",
+      "androidResource": "order_delivering_en",
+      "androidChannelId": "user_voice_order_delivering_en_v1"
+    },
+    {
+      "scene": "ORDER_DELIVERING",
+      "language": "id",
+      "sourceFile": "ORDER_DELIVERING_id.mp3",
+      "fileName": "order_delivering_id.wav",
+      "androidResource": "order_delivering_id",
+      "androidChannelId": "user_voice_order_delivering_id_v1"
+    },
+    {
+      "scene": "ORDER_DELIVERING",
+      "language": "ms",
+      "sourceFile": "ORDER_DELIVERING_ms.mp3",
+      "fileName": "order_delivering_ms.wav",
+      "androidResource": "order_delivering_ms",
+      "androidChannelId": "user_voice_order_delivering_ms_v1"
+    },
+    {
+      "scene": "ORDER_DELIVERING",
+      "language": "th",
+      "sourceFile": "ORDER_DELIVERING_th.mp3",
+      "fileName": "order_delivering_th.wav",
+      "androidResource": "order_delivering_th",
+      "androidChannelId": "user_voice_order_delivering_th_v1"
+    },
+    {
+      "scene": "ORDER_DELIVERING",
+      "language": "tl",
+      "sourceFile": "ORDER_DELIVERING_tl.mp3",
+      "fileName": "order_delivering_tl.wav",
+      "androidResource": "order_delivering_tl",
+      "androidChannelId": "user_voice_order_delivering_tl_v1"
+    },
+    {
+      "scene": "ORDER_DELIVERING",
+      "language": "zh-Hans",
+      "sourceFile": "ORDER_DELIVERING_zh-Hans.mp3",
+      "fileName": "order_delivering_zh_hans.wav",
+      "androidResource": "order_delivering_zh_hans",
+      "androidChannelId": "user_voice_order_delivering_zh_hans_v1"
+    },
+    {
+      "scene": "ORDER_DELIVERING",
+      "language": "zh-Hant",
+      "sourceFile": "ORDER_DELIVERING_zh-Hant.mp3",
+      "fileName": "order_delivering_zh_hant.wav",
+      "androidResource": "order_delivering_zh_hant",
+      "androidChannelId": "user_voice_order_delivering_zh_hant_v1"
+    },
+    {
+      "scene": "PAYMENT_SUCCEEDED",
+      "language": "en",
+      "sourceFile": "PAYMENT_SUCCEEDED_en.mp3",
+      "fileName": "payment_succeeded_en.wav",
+      "androidResource": "payment_succeeded_en",
+      "androidChannelId": "user_voice_payment_succeeded_en_v1"
+    },
+    {
+      "scene": "PAYMENT_SUCCEEDED",
+      "language": "id",
+      "sourceFile": "PAYMENT_SUCCEEDED_id.mp3",
+      "fileName": "payment_succeeded_id.wav",
+      "androidResource": "payment_succeeded_id",
+      "androidChannelId": "user_voice_payment_succeeded_id_v1"
+    },
+    {
+      "scene": "PAYMENT_SUCCEEDED",
+      "language": "ms",
+      "sourceFile": "PAYMENT_SUCCEEDED_ms.mp3",
+      "fileName": "payment_succeeded_ms.wav",
+      "androidResource": "payment_succeeded_ms",
+      "androidChannelId": "user_voice_payment_succeeded_ms_v1"
+    },
+    {
+      "scene": "PAYMENT_SUCCEEDED",
+      "language": "th",
+      "sourceFile": "PAYMENT_SUCCEEDED_th.mp3",
+      "fileName": "payment_succeeded_th.wav",
+      "androidResource": "payment_succeeded_th",
+      "androidChannelId": "user_voice_payment_succeeded_th_v1"
+    },
+    {
+      "scene": "PAYMENT_SUCCEEDED",
+      "language": "tl",
+      "sourceFile": "PAYMENT_SUCCEEDED_tl.mp3",
+      "fileName": "payment_succeeded_tl.wav",
+      "androidResource": "payment_succeeded_tl",
+      "androidChannelId": "user_voice_payment_succeeded_tl_v1"
+    },
+    {
+      "scene": "PAYMENT_SUCCEEDED",
+      "language": "zh-Hans",
+      "sourceFile": "PAYMENT_SUCCEEDED_zh-Hans.mp3",
+      "fileName": "payment_succeeded_zh_hans.wav",
+      "androidResource": "payment_succeeded_zh_hans",
+      "androidChannelId": "user_voice_payment_succeeded_zh_hans_v1"
+    },
+    {
+      "scene": "PAYMENT_SUCCEEDED",
+      "language": "zh-Hant",
+      "sourceFile": "PAYMENT_SUCCEEDED_zh-Hant.mp3",
+      "fileName": "payment_succeeded_zh_hant.wav",
+      "androidResource": "payment_succeeded_zh_hant",
+      "androidChannelId": "user_voice_payment_succeeded_zh_hant_v1"
+    },
+    {
+      "scene": "RIDER_ACCEPTED",
+      "language": "en",
+      "sourceFile": "RIDER_ACCEPTED_en.mp3",
+      "fileName": "rider_accepted_en.wav",
+      "androidResource": "rider_accepted_en",
+      "androidChannelId": "user_voice_rider_accepted_en_v1"
+    },
+    {
+      "scene": "RIDER_ACCEPTED",
+      "language": "id",
+      "sourceFile": "RIDER_ACCEPTED_id.mp3",
+      "fileName": "rider_accepted_id.wav",
+      "androidResource": "rider_accepted_id",
+      "androidChannelId": "user_voice_rider_accepted_id_v1"
+    },
+    {
+      "scene": "RIDER_ACCEPTED",
+      "language": "ms",
+      "sourceFile": "RIDER_ACCEPTED_ms.mp3",
+      "fileName": "rider_accepted_ms.wav",
+      "androidResource": "rider_accepted_ms",
+      "androidChannelId": "user_voice_rider_accepted_ms_v1"
+    },
+    {
+      "scene": "RIDER_ACCEPTED",
+      "language": "th",
+      "sourceFile": "RIDER_ACCEPTED_th.mp3",
+      "fileName": "rider_accepted_th.wav",
+      "androidResource": "rider_accepted_th",
+      "androidChannelId": "user_voice_rider_accepted_th_v1"
+    },
+    {
+      "scene": "RIDER_ACCEPTED",
+      "language": "tl",
+      "sourceFile": "RIDER_ACCEPTED_tl.mp3",
+      "fileName": "rider_accepted_tl.wav",
+      "androidResource": "rider_accepted_tl",
+      "androidChannelId": "user_voice_rider_accepted_tl_v1"
+    },
+    {
+      "scene": "RIDER_ACCEPTED",
+      "language": "zh-Hans",
+      "sourceFile": "RIDER_ACCEPTED_zh-Hans.mp3",
+      "fileName": "rider_accepted_zh_hans.wav",
+      "androidResource": "rider_accepted_zh_hans",
+      "androidChannelId": "user_voice_rider_accepted_zh_hans_v1"
+    },
+    {
+      "scene": "RIDER_ACCEPTED",
+      "language": "zh-Hant",
+      "sourceFile": "RIDER_ACCEPTED_zh-Hant.mp3",
+      "fileName": "rider_accepted_zh_hant.wav",
+      "androidResource": "rider_accepted_zh_hant",
+      "androidChannelId": "user_voice_rider_accepted_zh_hant_v1"
+    }
+  ]
+}

BIN
specs/035-user-notification-voice/assets/native-wav/merchant_accepted_en.wav


BIN
specs/035-user-notification-voice/assets/native-wav/merchant_accepted_id.wav


BIN
specs/035-user-notification-voice/assets/native-wav/merchant_accepted_ms.wav


BIN
specs/035-user-notification-voice/assets/native-wav/merchant_accepted_th.wav


BIN
specs/035-user-notification-voice/assets/native-wav/merchant_accepted_tl.wav


BIN
specs/035-user-notification-voice/assets/native-wav/merchant_accepted_zh_hans.wav


BIN
specs/035-user-notification-voice/assets/native-wav/merchant_accepted_zh_hant.wav


BIN
specs/035-user-notification-voice/assets/native-wav/merchant_ready_en.wav


BIN
specs/035-user-notification-voice/assets/native-wav/merchant_ready_id.wav


BIN
specs/035-user-notification-voice/assets/native-wav/merchant_ready_ms.wav


BIN
specs/035-user-notification-voice/assets/native-wav/merchant_ready_th.wav


BIN
specs/035-user-notification-voice/assets/native-wav/merchant_ready_tl.wav


BIN
specs/035-user-notification-voice/assets/native-wav/merchant_ready_zh_hans.wav


BIN
specs/035-user-notification-voice/assets/native-wav/merchant_ready_zh_hant.wav


BIN
specs/035-user-notification-voice/assets/native-wav/order_cancelled_en.wav


BIN
specs/035-user-notification-voice/assets/native-wav/order_cancelled_id.wav


BIN
specs/035-user-notification-voice/assets/native-wav/order_cancelled_ms.wav


BIN
specs/035-user-notification-voice/assets/native-wav/order_cancelled_th.wav


BIN
specs/035-user-notification-voice/assets/native-wav/order_cancelled_tl.wav


BIN
specs/035-user-notification-voice/assets/native-wav/order_cancelled_zh_hans.wav


BIN
specs/035-user-notification-voice/assets/native-wav/order_cancelled_zh_hant.wav


BIN
specs/035-user-notification-voice/assets/native-wav/order_delivered_en.wav


BIN
specs/035-user-notification-voice/assets/native-wav/order_delivered_id.wav


BIN
specs/035-user-notification-voice/assets/native-wav/order_delivered_ms.wav


BIN
specs/035-user-notification-voice/assets/native-wav/order_delivered_th.wav


BIN
specs/035-user-notification-voice/assets/native-wav/order_delivered_tl.wav


BIN
specs/035-user-notification-voice/assets/native-wav/order_delivered_zh_hans.wav


BIN
specs/035-user-notification-voice/assets/native-wav/order_delivered_zh_hant.wav


BIN
specs/035-user-notification-voice/assets/native-wav/order_delivering_en.wav


BIN
specs/035-user-notification-voice/assets/native-wav/order_delivering_id.wav


BIN
specs/035-user-notification-voice/assets/native-wav/order_delivering_ms.wav


BIN
specs/035-user-notification-voice/assets/native-wav/order_delivering_th.wav


BIN
specs/035-user-notification-voice/assets/native-wav/order_delivering_tl.wav


BIN
specs/035-user-notification-voice/assets/native-wav/order_delivering_zh_hans.wav


BIN
specs/035-user-notification-voice/assets/native-wav/order_delivering_zh_hant.wav


Unele fișiere nu au fost afișate deoarece prea multe fișiere au fost modificate în acest diff