Browse Source

fix: 使用Pushy实现三端iOS原生推送

qmj 12 hours ago
parent
commit
4630d23272

+ 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>

+ 82 - 66
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 客户端绑定网关;正式测试不能复用已经连接 sandbox 的业务客户端。
+    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,19 @@ 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);
+        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) {
-        initializeForTest(app);
-        sendToHost(app, token, title, content, payload, PRODUCTION_HOST);
+        initializeClient(app, BusinessPushProperties.ApnsEnvironment.PRODUCTION);
+        sendToClient(app, token, title, content, payload, BusinessPushProperties.ApnsEnvironment.PRODUCTION);
     }
 
-    private synchronized void initializeForTest(RecipientApp app) {
-        if (clients.containsKey(app)) {
+    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,12 +157,23 @@ 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) {
+        ApnsClient client = clients.get(new ClientKey(app, environment));
         if (client == null) {
             throw new IllegalStateException("APNS_NOT_INITIALIZED");
         }
@@ -162,27 +184,26 @@ public class ApnsPushSender {
         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) {
@@ -213,24 +234,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;
+        }
     }
 }

+ 45 - 68
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,9 +87,8 @@ 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
@@ -104,9 +101,8 @@ class ApnsPushSenderTest {
         sender.sendForTestProduction(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(BusinessPushProperties.ApnsEnvironment.PRODUCTION, sender.environments.get(RecipientApp.USER));
         sender.close();
     }
 
@@ -122,9 +118,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 +164,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 +204,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);
         }
     }

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

@@ -0,0 +1,138 @@
+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 productionTestNeverReusesSandboxClientAndClientsAreReusedAndClosed() throws Exception {
+        var properties = new BusinessPushProperties();
+        properties.setMode(BusinessPushProperties.Mode.NATIVE);
+        properties.setApnsEnvironment(BusinessPushProperties.ApnsEnvironment.DEVELOPMENT);
+        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.sendForTestProduction(RecipientApp.USER, "a".repeat(64), "test", "body", "{}");
+        sender.initialize();
+        sender.initialize();
+        sender.send(RecipientApp.USER, "a".repeat(64), "business", "body", "{}");
+        sender.sendForTestProduction(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.sendForTestProduction(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.sendForTestProduction(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.sendForTestProduction(RecipientApp.USER, "a".repeat(64), "title", "body", "{}")).getMessage());
+        doThrow(new TimeoutException()).when(future).get(10, TimeUnit.SECONDS);
+        assertEquals("APNS_TIMEOUT", assertThrows(IllegalStateException.class, () ->
+                sender.sendForTestProduction(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.sendForTestProduction(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.sendForTestProduction(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.sendForTestProduction(RecipientApp.USER, "invalid", "title", "body", "{}")).getMessage());
+        assertEquals("APNS_PAYLOAD_TOO_LARGE", assertThrows(IllegalStateException.class, () ->
+                sender.sendForTestProduction(RecipientApp.USER, "a".repeat(64), "title", "大".repeat(2000), "{}")).getMessage());
+        verifyNoInteractions(client);
+    }
+}

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

@@ -6,7 +6,7 @@
 
 **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 +88,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: 统一外发路由与现有包装方法
@@ -157,3 +157,10 @@ PowerShell 中仅对当前命令临时设置 `$env:JAVA_HOME='C:\Users\99029\.jd
 # 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. 原生测试固定正式客户端,不能复用开发客户端;`uni` 默认和 Android FCM 不变。先完成模拟接受/拒绝与客户端隔离回归,再执行推送定向测试、JDK 21 模块打包和差异核对。真实 APNs 投递由部署后另行验证。

+ 11 - 3
specs/033-native-push-routing/quickstart.md

@@ -31,18 +31,18 @@ development/production分别对应sandbox/正式网关,当前固定production
 
 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 正式网关。请求示例:
 
 ```json
 {
@@ -60,3 +60,11 @@ development/production分别对应sandbox/正式网关,当前固定production
 该接口按需求长期匿名开放,公网部署后任何知道地址的人都可以使用服务端凭据向其掌握的设备 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 不能替代真实投递验收。

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

@@ -138,3 +138,10 @@
 # 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 不在改动范围。
+- 客户端按「接收端 + 开发/生产环境」隔离并复用;测试接口固定正式环境,TestFlight/App Store 均走该网关。不得因正式拒绝自动转 sandbox。
+- 发送成功必须以苹果 `isAccepted()` 为准,最多等待 10 秒。苹果拒绝继续返回 `APNS_BadDeviceToken` 等脱敏类别,超时为 `APNS_TIMEOUT`,网络异常为 `APNS_TRANSPORT_ERROR`,中断为 `APNS_INTERRUPTED`。超时并不证明未发送,不自动重发。
+- SDK 更换不保证消除苹果对设备 Token 的拒绝。证书可加载及模拟测试通过与真实设备可接收分别验收;未经真机投递不能宣称 BadDeviceToken 已修复。

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

@@ -52,3 +52,11 @@ T001–T002 → T003/T004 → T005–T006 → T007 → T008–T009。FCM 与 APN
 - [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 已解决。