Browse Source

调整APNs原生测试推送按配置选择开发或正式网关

qmj 20 hours ago
parent
commit
9ae292cc67

+ 7 - 6
ruoyi-admin/src/main/java/com/ruoyi/app/push/ApnsPushSender.java

@@ -34,7 +34,7 @@ public class ApnsPushSender {
     private static final Logger log = LoggerFactory.getLogger(ApnsPushSender.class);
     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 List<String> BUSINESS_FIELDS = List.of("ddId", "state", "type", "pushType");
     private final BusinessPushProperties properties;
     private final BusinessPushProperties properties;
-    // Pushy 客户端绑定网关;正式测试不能复用已经连接 sandbox 的业务客户端。
+    // Pushy 客户端绑定网关,按环境隔离,避免复用与当前配置不符的连接。
     private record ClientKey(RecipientApp app, BusinessPushProperties.ApnsEnvironment environment) { }
     private record ClientKey(RecipientApp app, BusinessPushProperties.ApnsEnvironment environment) { }
     private final Map<ClientKey, ApnsClient> clients = new ConcurrentHashMap<>();
     private final Map<ClientKey, ApnsClient> clients = new ConcurrentHashMap<>();
     private NioEventLoopGroup eventLoopGroup;
     private NioEventLoopGroup eventLoopGroup;
@@ -139,11 +139,12 @@ public class ApnsPushSender {
         sendToClient(app, token, title, content, payload, properties.getApnsEnvironment());
         sendToClient(app, token, title, content, payload, properties.getApnsEnvironment());
     }
     }
 
 
-    /** 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) {
-        initializeClient(app, BusinessPushProperties.ApnsEnvironment.PRODUCTION);
-        sendToClient(app, token, title, content, payload, BusinessPushProperties.ApnsEnvironment.PRODUCTION);
+    /** 原生测试也遵循所配 APNs 环境;只按需加载目标端,不切换业务推送通道。 */
+    public void sendForTest(RecipientApp app, String token, String title,
+                            String content, String payload) {
+        BusinessPushProperties.ApnsEnvironment environment = properties.getApnsEnvironment();
+        initializeClient(app, environment);
+        sendToClient(app, token, title, content, payload, environment);
     }
     }
 
 
     private synchronized void initializeClient(RecipientApp app, BusinessPushProperties.ApnsEnvironment environment) {
     private synchronized void initializeClient(RecipientApp app, BusinessPushProperties.ApnsEnvironment environment) {

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

@@ -49,7 +49,7 @@ public class NativePushTestController {
                 fcm.sendForTest(app, request.getDeviceToken(), request.getTitle(),
                 fcm.sendForTest(app, request.getDeviceToken(), request.getTitle(),
                         request.getContent(), request.getPayload());
                         request.getContent(), request.getPayload());
             } else {
             } else {
-                apns.sendForTestProduction(app, request.getDeviceToken(), request.getTitle(),
+                apns.sendForTest(app, request.getDeviceToken(), request.getTitle(),
                         request.getContent(), request.getPayload());
                         request.getContent(), request.getPayload());
             }
             }
             return AjaxResult.success(MessageUtils.message("native.push.test.accepted"));
             return AjaxResult.success(MessageUtils.message("native.push.test.accepted"));

+ 2 - 1
ruoyi-admin/src/main/resources/application.yml

@@ -55,7 +55,8 @@ omgpay:
 # 当前业务通道仍沿用uniPush,测试接口可独立发送原生推送;切换通道需直接修改mode并重启。
 # 当前业务通道仍沿用uniPush,测试接口可独立发送原生推送;切换通道需直接修改mode并重启。
 business-push:
 business-push:
   mode: uni
   mode: uni
-  apns-environment: production
+  # 当前测试包使用 sandbox;正式签名包(含 TestFlight/App Store)部署时改为 production。
+  apns-environment: development
   apps:
   apps:
     user:
     user:
       fcm-credentials-path: "classpath:business-push/user-fcm-service-account.json"
       fcm-credentials-path: "classpath:business-push/user-fcm-service-account.json"

+ 6 - 5
ruoyi-admin/src/test/java/com/ruoyi/app/push/ApnsPushSenderTest.java

@@ -91,18 +91,19 @@ class ApnsPushSenderTest {
         assertEquals(BusinessPushProperties.ApnsEnvironment.PRODUCTION, sender.environments.get(RecipientApp.USER));
         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);
         properties.setMode(BusinessPushProperties.Mode.UNI);
         StubSender sender = new StubSender(properties);
         StubSender sender = new StubSender(properties);
 
 
         sender.initialize();
         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);
         assertEquals(Map.of(RecipientApp.USER, "user.p12"), sender.loadedPaths);
         verify(sender.clients.get(RecipientApp.USER)).sendNotification(any(SimpleApnsPushNotification.class));
         verify(sender.clients.get(RecipientApp.USER)).sendNotification(any(SimpleApnsPushNotification.class));
-        assertEquals(BusinessPushProperties.ApnsEnvironment.PRODUCTION, sender.environments.get(RecipientApp.USER));
+        assertEquals(environment, sender.environments.get(RecipientApp.USER));
         sender.close();
         sender.close();
     }
     }
 
 

+ 14 - 12
ruoyi-admin/src/test/java/com/ruoyi/app/push/ApnsPushyTransportTest.java

@@ -18,10 +18,10 @@ import static org.mockito.Mockito.*;
 /** 发送Apple类型化通知并以Apple接受结果为准,不把队列成功当投递接受。 */
 /** 发送Apple类型化通知并以Apple接受结果为准,不把队列成功当投递接受。 */
 class ApnsPushyTransportTest {
 class ApnsPushyTransportTest {
     @Test
     @Test
-    void productionTestNeverReusesSandboxClientAndClientsAreReusedAndClosed() throws Exception {
+    void configuredTestEnvironmentNeverReusesOtherGatewayAndClientsAreReusedAndClosed() throws Exception {
         var properties = new BusinessPushProperties();
         var properties = new BusinessPushProperties();
         properties.setMode(BusinessPushProperties.Mode.NATIVE);
         properties.setMode(BusinessPushProperties.Mode.NATIVE);
-        properties.setApnsEnvironment(BusinessPushProperties.ApnsEnvironment.DEVELOPMENT);
+        properties.setApnsEnvironment(BusinessPushProperties.ApnsEnvironment.PRODUCTION);
         for (RecipientApp app : RecipientApp.values()) {
         for (RecipientApp app : RecipientApp.values()) {
             var config = new BusinessPushProperties.AppProperties();
             var config = new BusinessPushProperties.AppProperties();
             config.setApnsCertificatePath(app.name() + ".p12");
             config.setApnsCertificatePath(app.name() + ".p12");
@@ -41,11 +41,13 @@ class ApnsPushyTransportTest {
             }
             }
         };
         };
         // 测试客户端可以先初始化;之后 native 启动仍须加载三端业务客户端。
         // 测试客户端可以先初始化;之后 native 启动仍须加载三端业务客户端。
-        sender.sendForTestProduction(RecipientApp.USER, "a".repeat(64), "test", "body", "{}");
+        sender.sendForTest(RecipientApp.USER, "a".repeat(64), "test", "body", "{}");
+        properties.setApnsEnvironment(BusinessPushProperties.ApnsEnvironment.DEVELOPMENT);
         sender.initialize();
         sender.initialize();
         sender.initialize();
         sender.initialize();
         sender.send(RecipientApp.USER, "a".repeat(64), "business", "body", "{}");
         sender.send(RecipientApp.USER, "a".repeat(64), "business", "body", "{}");
-        sender.sendForTestProduction(RecipientApp.USER, "a".repeat(64), "test", "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);
         assertEquals(java.util.List.of("USER:PRODUCTION", "USER:DEVELOPMENT", "MERCHANT:DEVELOPMENT", "RIDER:DEVELOPMENT"), factoryCalls);
         verify(production, times(2)).sendNotification(any(SimpleApnsPushNotification.class));
         verify(production, times(2)).sendNotification(any(SimpleApnsPushNotification.class));
         verify(sandbox).sendNotification(any(SimpleApnsPushNotification.class));
         verify(sandbox).sendNotification(any(SimpleApnsPushNotification.class));
@@ -97,7 +99,7 @@ class ApnsPushyTransportTest {
             }
             }
         };
         };
         IllegalStateException rejected = assertThrows(IllegalStateException.class, () ->
         IllegalStateException rejected = assertThrows(IllegalStateException.class, () ->
-                sender.sendForTestProduction(RecipientApp.USER, "a".repeat(64), "title", "body", "{\"ddId\":123}"));
+                sender.sendForTest(RecipientApp.USER, "a".repeat(64), "title", "body", "{\"ddId\":123}"));
         assertEquals("APNS_BadDeviceToken", rejected.getMessage());
         assertEquals("APNS_BadDeviceToken", rejected.getMessage());
         var capture = org.mockito.ArgumentCaptor.forClass(SimpleApnsPushNotification.class);
         var capture = org.mockito.ArgumentCaptor.forClass(SimpleApnsPushNotification.class);
         verify(client).sendNotification(capture.capture());
         verify(client).sendNotification(capture.capture());
@@ -108,31 +110,31 @@ class ApnsPushyTransportTest {
         assertEquals(DeliveryPriority.IMMEDIATE, notification.getPriority());
         assertEquals(DeliveryPriority.IMMEDIATE, notification.getPriority());
         assertEquals("123", JSON.parseObject(notification.getPayload()).getString("ddId"));
         assertEquals("123", JSON.parseObject(notification.getPayload()).getString("ddId"));
         when(response.isAccepted()).thenReturn(true);
         when(response.isAccepted()).thenReturn(true);
-        assertDoesNotThrow(() -> sender.sendForTestProduction(RecipientApp.USER, "a".repeat(64), "title", "body", "{}"));
+        assertDoesNotThrow(() -> sender.sendForTest(RecipientApp.USER, "a".repeat(64), "title", "body", "{}"));
 
 
         when(response.isAccepted()).thenReturn(false);
         when(response.isAccepted()).thenReturn(false);
         when(response.getRejectionReason()).thenReturn("token=" + "a".repeat(64));
         when(response.getRejectionReason()).thenReturn("token=" + "a".repeat(64));
         assertEquals("APNS_REJECTED", assertThrows(IllegalStateException.class, () ->
         assertEquals("APNS_REJECTED", assertThrows(IllegalStateException.class, () ->
-                sender.sendForTestProduction(RecipientApp.USER, "a".repeat(64), "title", "body", "{}")).getMessage());
+                sender.sendForTest(RecipientApp.USER, "a".repeat(64), "title", "body", "{}")).getMessage());
         doThrow(new TimeoutException()).when(future).get(10, TimeUnit.SECONDS);
         doThrow(new TimeoutException()).when(future).get(10, TimeUnit.SECONDS);
         assertEquals("APNS_TIMEOUT", assertThrows(IllegalStateException.class, () ->
         assertEquals("APNS_TIMEOUT", assertThrows(IllegalStateException.class, () ->
-                sender.sendForTestProduction(RecipientApp.USER, "a".repeat(64), "title", "body", "{}")).getMessage());
+                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);
         doThrow(new ExecutionException(new Exception("token=" + "a".repeat(64)))).when(future).get(10, TimeUnit.SECONDS);
         assertEquals("APNS_TRANSPORT_ERROR", assertThrows(IllegalStateException.class, () ->
         assertEquals("APNS_TRANSPORT_ERROR", assertThrows(IllegalStateException.class, () ->
-                sender.sendForTestProduction(RecipientApp.USER, "a".repeat(64), "title", "body", "{}")).getMessage());
+                sender.sendForTest(RecipientApp.USER, "a".repeat(64), "title", "body", "{}")).getMessage());
         doThrow(new InterruptedException()).when(future).get(10, TimeUnit.SECONDS);
         doThrow(new InterruptedException()).when(future).get(10, TimeUnit.SECONDS);
         try {
         try {
             assertEquals("APNS_INTERRUPTED", assertThrows(IllegalStateException.class, () ->
             assertEquals("APNS_INTERRUPTED", assertThrows(IllegalStateException.class, () ->
-                    sender.sendForTestProduction(RecipientApp.USER, "a".repeat(64), "title", "body", "{}")).getMessage());
+                    sender.sendForTest(RecipientApp.USER, "a".repeat(64), "title", "body", "{}")).getMessage());
             assertTrue(Thread.currentThread().isInterrupted());
             assertTrue(Thread.currentThread().isInterrupted());
         } finally {
         } finally {
             Thread.interrupted();
             Thread.interrupted();
         }
         }
         clearInvocations(client);
         clearInvocations(client);
         assertEquals("APNS_TOKEN_INVALID", assertThrows(IllegalStateException.class, () ->
         assertEquals("APNS_TOKEN_INVALID", assertThrows(IllegalStateException.class, () ->
-                sender.sendForTestProduction(RecipientApp.USER, "invalid", "title", "body", "{}")).getMessage());
+                sender.sendForTest(RecipientApp.USER, "invalid", "title", "body", "{}")).getMessage());
         assertEquals("APNS_PAYLOAD_TOO_LARGE", assertThrows(IllegalStateException.class, () ->
         assertEquals("APNS_PAYLOAD_TOO_LARGE", assertThrows(IllegalStateException.class, () ->
-                sender.sendForTestProduction(RecipientApp.USER, "a".repeat(64), "title", "大".repeat(2000), "{}")).getMessage());
+                sender.sendForTest(RecipientApp.USER, "a".repeat(64), "title", "大".repeat(2000), "{}")).getMessage());
         verifyNoInteractions(client);
         verifyNoInteractions(client);
     }
     }
 }
 }

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

@@ -28,12 +28,12 @@ class NativePushTestControllerTest {
 
 
             assertEquals(200, result.get("code"));
             assertEquals(200, result.get("code"));
             verify(fcm).sendForTest(RecipientApp.RIDER, "fcm-token", "title", "content", "{}");
             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
     @Test
-    void iosRequestUsesProductionApns() {
+    void iosRequestUsesApnsTestSenderWithConfiguredEnvironment() {
         try (var messages = mockStatic(MessageUtils.class)) {
         try (var messages = mockStatic(MessageUtils.class)) {
             messages.when(() -> MessageUtils.message("native.push.test.accepted")).thenReturn("accepted");
             messages.when(() -> MessageUtils.message("native.push.test.accepted")).thenReturn("accepted");
             NativePushTestRequest request = request("user", "ios", "a".repeat(64));
             NativePushTestRequest request = request("user", "ios", "a".repeat(64));
@@ -41,7 +41,7 @@ class NativePushTestControllerTest {
             AjaxResult result = controller.send(request);
             AjaxResult result = controller.send(request);
 
 
             assertEquals(200, result.get("code"));
             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", "{}");
         }
         }
     }
     }
 
 

+ 5 - 1
specs/033-native-push-routing/plan.md

@@ -163,4 +163,8 @@ PowerShell 中仅对当前命令临时设置 `$env:JAVA_HOME='C:\Users\99029\.jd
 1. 采用 `D:/QtwCode/CTE/qtw-push-server/qtw-push-server` 使用的 `com.turo:pushy:0.13.10`,仅替换 `ApnsPushSender` 的 HTTP/2 传输,不迁移参考工程的 WFC/VoIP 业务协议或异步先返回成功的行为。
 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 线程,停机关闭客户端和线程组。
 2. 通过 classpath 流加载三端原有 `.p12`,保留私钥、有效期校验与 JSON 补空值兼容。使用 `ApnsClientBuilder.setClientCredentials`;按接收端和 APNs 环境复用独立客户端,共用有界 I/O 线程,停机关闭客户端和线程组。
 3. 发送 `SimpleApnsPushNotification`,保留 `alert`、优先级 10、原 payload、骑手铃声。最多等待苹果响应 10 秒,拒绝、超时、网络异常、中断均返回脱敏类别;不自动换网关或重试,不将入队成功当作苹果接受。
 3. 发送 `SimpleApnsPushNotification`,保留 `alert`、优先级 10、原 payload、骑手铃声。最多等待苹果响应 10 秒,拒绝、超时、网络异常、中断均返回脱敏类别;不自动换网关或重试,不将入队成功当作苹果接受。
-4. 原生测试固定正式客户端,不能复用开发客户端;`uni` 默认和 Android FCM 不变。先完成模拟接受/拒绝与客户端隔离回归,再执行推送定向测试、JDK 21 模块打包和差异核对。真实 APNs 投递由部署后另行验证。
+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 的失败,再完成推送定向测试和模块打包;不在本机发送真实外部推送。

+ 6 - 6
specs/033-native-push-routing/quickstart.md

@@ -17,7 +17,7 @@ APNs配置JSON原文件的packetName/pwd分别提供topic和证书密码;2026-
 | 设置 | 本地配置项 | 当前值/要求 |
 | 设置 | 本地配置项 | 当前值/要求 |
 | --- | --- | --- |
 | --- | --- | --- |
 | 通道 | `business-push.mode` | 固定`uni`;如启用`native`直接改YAML并重启 |
 | 通道 | `business-push.mode` | 固定`uni`;如启用`native`直接改YAML并重启 |
-| APNs环境 | `business-push.apns-environment` | 固定`production`;TestFlight/App Store均用正式环境 |
+| APNs环境 | `business-push.apns-environment` | 当前`development`(sandbox);正式签名包(含TestFlight/App Store)设为`production` |
 | 三端FCM路径 | `apps.{user/merchant/rider}.fcm-credentials-path` | 固定各端classpath JSON |
 | 三端FCM路径 | `apps.{user/merchant/rider}.fcm-credentials-path` | 固定各端classpath JSON |
 | 三端APNs路径 | `apps.{user/merchant/rider}.apns-certificate-path` | 固定各端classpath .p12 |
 | 三端APNs路径 | `apps.{user/merchant/rider}.apns-certificate-path` | 固定各端classpath .p12 |
 | 三端配套JSON路径 | `apps.{user/merchant/rider}.apns-config-path` | 固定各端classpath配套JSON |
 | 三端配套JSON路径 | `apps.{user/merchant/rider}.apns-config-path` | 固定各端classpath配套JSON |
@@ -25,7 +25,7 @@ APNs配置JSON原文件的packetName/pwd分别提供topic和证书密码;2026-
 
 
 不再通过BUSINESS_PUSH系列环境变量修改配置;直接维护YAML。两项APNs值均固定后,发送器优先使用YAML,无需再从JSON补空值;未来更换证书时须同步更新YAML中的密码/topic,只改JSON不会覆盖这些固定值。uni模式不初始化业务原生凭据;native模式启动时校验三端并加载资源,缺失/不可读/无私钥/过期会失败。匿名测试仍按需初始化所选端。
 不再通过BUSINESS_PUSH系列环境变量修改配置;直接维护YAML。两项APNs值均固定后,发送器优先使用YAML,无需再从JSON补空值;未来更换证书时须同步更新YAML中的密码/topic,只改JSON不会覆盖这些固定值。uni模式不初始化业务原生凭据;native模式启动时校验三端并加载资源,缺失/不可读/无私钥/过期会失败。匿名测试仍按需初始化所选端。
 
 
-development/production分别对应sandbox/正式网关,当前固定production;匿名测试始终走正式网关。已核对三端packetName与证书主题相符,但外部真实投递仍须真机验证。三端证书到期日为2027-07-30;改配置或资源后重新构建部署JAR并验证,不通过重新生成密码解决BadDeviceToken。
+development/production分别对应sandbox/正式网关,当前配置development;匿名测试和业务原生发送均遵循该配置,不再固定正式网关。正式签名包部署前改为production。APNs环境取决于安装包签名与Token,不能仅凭业务服务器是测试还是生产来判断;TestFlight/App Store包即使连接测试服务器,也仍须使用production。已核对三端packetName与证书主题相符,但外部真实投递仍须真机验证。三端证书到期日为2027-07-30;改配置或资源后重新构建部署JAR并验证,不通过重新生成密码解决BadDeviceToken。
 
 
 ## 启用前条件
 ## 启用前条件
 
 
@@ -42,7 +42,7 @@ development/production分别对应sandbox/正式网关,当前固定production
 
 
 ## ApiPost 原生推送测试(业务模式无需切换)
 ## ApiPost 原生推送测试(业务模式无需切换)
 
 
-常驻接口 `POST /system/push/testNative` 允许匿名访问,`Content-Type: application/json`,无需账号 ID 或后台 token。即使 YAML 的 `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
 ```json
 {
 {
@@ -55,7 +55,7 @@ development/production分别对应sandbox/正式网关,当前固定production
 }
 }
 ```
 ```
 
 
-`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 发送消息,可能造成滥用和推送配额消耗;如不接受此风险,应在网关限制可访问来源或后续改为鉴权接口。
 该接口按需求长期匿名开放,公网部署后任何知道地址的人都可以使用服务端凭据向其掌握的设备 Token 发送消息,可能造成滥用和推送配额消耗;如不接受此风险,应在网关限制可访问来源或后续改为鉴权接口。
 
 
@@ -63,8 +63,8 @@ development/production分别对应sandbox/正式网关,当前固定production
 
 
 ## iOS 发送实现(2026-10-26)
 ## iOS 发送实现(2026-10-26)
 
 
-后端改用参考工程相同的 Pushy 0.13.10,三端现有普通推送证书和 topic 继续使用,不需要换 App 接口或增加配置。业务原生模式按所配环境发送,测试接口固定正式环境;两者使用独立缓存客户端。
+后端改用参考工程相同的 Pushy 0.13.10,三端现有普通推送证书和 topic 继续使用,不需要换 App 接口或增加配置。业务原生模式和测试接口都按所配环境发送,客户端按接收端和环境缓存复用。
 
 
 测试接口等待苹果返回结果:接受后才返回成功,拒绝继续显示 `APNS_BadDeviceToken` 等类别;等待超过 10 秒返回 `APNS_TIMEOUT`,网络错误返回 `APNS_TRANSPORT_ERROR`。超时不表示苹果一定未接受,请勿直接据此重复发送。日志记录拒绝的端、环境、类别,不记录完整 Token 或密钥。
 测试接口等待苹果返回结果:接受后才返回成功,拒绝继续显示 `APNS_BadDeviceToken` 等类别;等待超过 10 秒返回 `APNS_TIMEOUT`,网络错误返回 `APNS_TRANSPORT_ERROR`。超时不表示苹果一定未接受,请勿直接据此重复发送。日志记录拒绝的端、环境、类别,不记录完整 Token 或密钥。
 
 
-本地证书加载和模拟苹果响应可通过自动测试验证;是否消除这台设备的 BadDeviceToken,仍须部署新 JAR 后用对应 App 当前上报的正式 Token 调用测试接口并核对真机通知。替换 SDK 不能替代真实投递验收。
+本地证书加载和模拟苹果响应可通过自动测试验证;是否消除这台设备的 BadDeviceToken,仍须部署新 JAR 后用对应 App 当前上报且匹配网关的 Token 调用测试接口并核对真机通知。替换 SDK 不能替代真实投递验收。

+ 7 - 3
specs/033-native-push-routing/spec.md

@@ -108,7 +108,7 @@
 - **FR-011**: 实现范围 MUST 排除音视频通话推送、已废弃订单/支付入口及 App 端代码;不新增推送业务场景或改变登录互斥规则。
 - **FR-011**: 实现范围 MUST 排除音视频通话推送、已废弃订单/支付入口及 App 端代码;不新增推送业务场景或改变登录互斥规则。
 - **FR-012**: 本功能 MUST 沿用现有 `info_user` 单设备字段,不新增数据库字段或设备表。
 - **FR-012**: 本功能 MUST 沿用现有 `info_user` 单设备字段,不新增数据库字段或设备表。
 - **FR-013**: 外卖和闪送新单向多名骑手开放时,仅在 `native` 模式对 Android 骑手按相同标题、正文及 payload 分组,使用骑手端 FCM multicast 发送;iOS 骑手保留逐设备 APNs 请求,`uni` 模式保留原有逐骑手发送方式,不接入旧批量方法。各骑手的语言、消息中心记录和发送失败处理 MUST 保持独立,原生失败不得回退 uniPush。
 - **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)*
 ### Key Entities *(include if feature involves data)*
 
 
@@ -126,7 +126,7 @@
 - **SC-003**: 受测通知在正常发送、token 缺失和发送异常场景下,单次均不出现双通道外发;现有会入库的路径每次最多生成一条消息记录。
 - **SC-003**: 受测通知在正常发送、token 缺失和发送异常场景下,单次均不出现双通道外发;现有会入库的路径每次最多生成一条消息记录。
 - **SC-004**: 模拟 FCM/APNs 不可用或 token 无效时,原业务操作结果与不发送推送时一致;失败可通过日志定位到接收端、平台与错误类别,日志不包含完整 token 或凭据。
 - **SC-004**: 模拟 FCM/APNs 不可用或 token 无效时,原业务操作结果与不发送推送时一致;失败可通过日志定位到接收端、平台与错误类别,日志不包含完整 token 或凭据。
 - **SC-005**: 测试配置切回 `uni` 并重启后,后续受测通知恢复原 uniPush 发送路径;音视频通话路径保持原行为。
 - **SC-005**: 测试配置切回 `uni` 并重启后,后续受测通知恢复原 uniPush 发送路径;音视频通话路径保持原行为。
-- **SC-006**: 保持业务模式为 `uni` 时,匿名测试接口仍能按所传端类型与平台分别向 FCM/APNs 正式网关发送,不需要修改配置或重启;错误响应不泄露设备 token,业务发送路线保持原样。
+- **SC-006**: 保持业务模式为 `uni` 时,匿名测试接口仍能按所传端类型与平台分别向 FCM/所配置的 APNs 网关发送,无需切换业务通道;`development` 和 `production` 的定向测试分别选中对应客户端,错误响应不泄露设备 token,业务发送路线保持原样。
 
 
 ## Assumptions
 ## Assumptions
 
 
@@ -142,6 +142,10 @@
 ## 2026-10-26 iOS 发送层对齐
 ## 2026-10-26 iOS 发送层对齐
 
 
 - 普通 APNs 通知改用参考推送工程同版本 Pushy 0.13.10;保留三端独立证书/topic、固定本地配置、消息 payload 和骑手铃声。Android、App 代码和 VoIP 不在改动范围。
 - 普通 APNs 通知改用参考推送工程同版本 Pushy 0.13.10;保留三端独立证书/topic、固定本地配置、消息 payload 和骑手铃声。Android、App 代码和 VoIP 不在改动范围。
-- 客户端按「接收端 + 开发/生产环境」隔离并复用;测试接口固定正式环境,TestFlight/App Store 均走该网关。不得因正式拒绝自动转 sandbox。
+- 客户端按「接收端 + 开发/生产环境」隔离并复用;测试接口和业务原生发送均按 `apns-environment` 选择网关。开发签名包对应 `development`,TestFlight/App Store 对应 `production`。不得因网关拒绝自动切换环境。
 - 发送成功必须以苹果 `isAccepted()` 为准,最多等待 10 秒。苹果拒绝继续返回 `APNS_BadDeviceToken` 等脱敏类别,超时为 `APNS_TIMEOUT`,网络异常为 `APNS_TRANSPORT_ERROR`,中断为 `APNS_INTERRUPTED`。超时并不证明未发送,不自动重发。
 - 发送成功必须以苹果 `isAccepted()` 为准,最多等待 10 秒。苹果拒绝继续返回 `APNS_BadDeviceToken` 等脱敏类别,超时为 `APNS_TIMEOUT`,网络异常为 `APNS_TRANSPORT_ERROR`,中断为 `APNS_INTERRUPTED`。超时并不证明未发送,不自动重发。
 - SDK 更换不保证消除苹果对设备 Token 的拒绝。证书可加载及模拟测试通过与真实设备可接收分别验收;未经真机投递不能宣称 BadDeviceToken 已修复。
 - SDK 更换不保证消除苹果对设备 Token 的拒绝。证书可加载及模拟测试通过与真实设备可接收分别验收;未经真机投递不能宣称 BadDeviceToken 已修复。
+
+## 2026-10-26 APNs 环境规则修正(覆盖此前固定正式网关的规定)
+
+用户确认当前待测 Token 需要苹果测试网关。当前仓库 YAML 改为 `apns-environment: development`,测试接口不再固定 production;正式签名包部署时显式改为 `production` 后重新构建/重启。采用本地固定配置,不新增环境变量或 App 参数,不根据业务服务器名称、TestFlight/App Store 安装来源推断 APNs 环境。Token 必须匹配安装包实际签名中的 APNs 环境,测试服务器上的正式签名包也要使用 production。

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

@@ -60,3 +60,13 @@ T001–T002 → T003/T004 → T005–T006 → T007 → T008–T009。FCM 与 APN
 - [x] T021 验证正式测试与开发业务客户端隔离、复用、关闭、超时/中断/异常及三端证书加载;完成推送定向测试、JDK 21 模块构建与 diff 检查,记录真实设备投递未验证。
 - [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 已解决。
 验证记录: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 投递,测试证明配置路由已修正,不代表真机已收到通知。