فهرست منبع

feat(push): add per-app APNs sender

qmj 2 هفته پیش
والد
کامیت
d275516043

+ 185 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/push/ApnsPushSender.java

@@ -0,0 +1,185 @@
+package com.ruoyi.app.push;
+
+import com.alibaba.fastjson2.JSON;
+import com.alibaba.fastjson2.JSONObject;
+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.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.nio.file.Files;
+import java.nio.file.Path;
+import java.security.KeyStore;
+import java.security.PrivateKey;
+import java.security.cert.X509Certificate;
+import java.time.Duration;
+import java.util.EnumMap;
+import java.util.List;
+import java.util.Map;
+
+@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);
+
+    public ApnsPushSender(BusinessPushProperties properties) {
+        this.properties = properties;
+    }
+
+    @PostConstruct
+    public synchronized void initialize() {
+        if (properties.getMode() != BusinessPushProperties.Mode.NATIVE || !clients.isEmpty()) {
+            return;
+        }
+        try {
+            for (RecipientApp app : RecipientApp.values()) {
+                clients.put(app, createClient(app, properties.getApp(app)));
+            }
+        } catch (RuntimeException exception) {
+            close();
+            throw exception;
+        }
+    }
+
+    protected HttpClient createClient(RecipientApp app, BusinessPushProperties.AppProperties config) {
+        try {
+            char[] password = config.getApnsCertificatePassword().toCharArray();
+            KeyStore store = KeyStore.getInstance("PKCS12");
+            try (InputStream stream = Files.newInputStream(Path.of(config.getApnsCertificatePath()))) {
+                store.load(stream, password);
+            }
+            boolean validPrivateKey = false;
+            for (java.util.Enumeration<String> aliases = store.aliases(); aliases.hasMoreElements(); ) {
+                String alias = aliases.nextElement();
+                if (store.getKey(alias, password) instanceof PrivateKey
+                        && store.getCertificate(alias) instanceof X509Certificate certificate) {
+                    validateCertificate(certificate, app);
+                    validPrivateKey = true;
+                }
+            }
+            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();
+        } catch (Exception exception) {
+            throw new IllegalStateException("Cannot initialize APNs certificate for " + app);
+        }
+    }
+
+    static void validateCertificate(X509Certificate certificate, RecipientApp app) {
+        try {
+            certificate.checkValidity();
+        } catch (Exception exception) {
+            throw new IllegalStateException("APNs certificate expired or not yet valid for " + app);
+        }
+    }
+
+    public void send(RecipientApp app, String token, String title, String content, String payload) {
+        HttpClient client = clients.get(app);
+        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 host = properties.getApnsEnvironment() == BusinessPushProperties.ApnsEnvironment.DEVELOPMENT
+                ? DEVELOPMENT_HOST : PRODUCTION_HOST;
+        String body = buildBody(app, title, content, payload);
+        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();
+        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);
+            }
+        } catch (InterruptedException exception) {
+            Thread.currentThread().interrupt();
+            throw new IllegalStateException("APNS_INTERRUPTED");
+        } catch (IOException exception) {
+            throw new IllegalStateException("APNS_TRANSPORT_ERROR");
+        }
+    }
+
+    static String buildBody(RecipientApp app, String title, String content, String payload) {
+        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");
+        JSONObject body = new JSONObject();
+        body.put("aps", aps);
+        if (payload != null) {
+            body.put("payload", payload);
+            try {
+                JSONObject fields = JSON.parseObject(payload);
+                if (fields != null) {
+                    for (String field : BUSINESS_FIELDS) {
+                        Object value = fields.get(field);
+                        if (value != null) {
+                            body.put(field, String.valueOf(value));
+                        }
+                    }
+                }
+            } catch (RuntimeException exception) {
+                log.warn("APNs payload is not JSON; forwarding its original text");
+            }
+        }
+        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();
+    }
+
+    @PreDestroy
+    public synchronized void close() {
+        for (HttpClient client : clients.values()) {
+            client.close();
+        }
+        clients.clear();
+    }
+}

+ 189 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/push/ApnsPushSenderTest.java

@@ -0,0 +1,189 @@
+package com.ruoyi.app.push;
+
+import com.alibaba.fastjson2.JSON;
+import com.alibaba.fastjson2.JSONObject;
+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;
+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;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+class ApnsPushSenderTest {
+    @TempDir
+    Path tempDir;
+
+    @Test
+    void routesThreeAppsToOwnCertificateTopicAndSandbox() throws Exception {
+        StubSender sender = new StubSender(nativeProperties(BusinessPushProperties.ApnsEnvironment.DEVELOPMENT));
+        sender.initialize();
+
+        for (RecipientApp app : RecipientApp.values()) {
+            assertEquals(app.name().toLowerCase() + ".p12", sender.loadedPaths.get(app));
+            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));
+            JSONObject aps = body.getJSONObject("aps");
+            assertEquals("New order", aps.getJSONObject("alert").getString("title"));
+            assertEquals("Order body", aps.getJSONObject("alert").getString("body"));
+            assertEquals(app == RecipientApp.RIDER ? "pushsound.caf" : "default", aps.getString("sound"));
+            assertEquals("123", body.getString("ddId"));
+            assertEquals("order", body.getString("pushType"));
+            assertTrue(body.getString("payload").contains("\"ddId\":123"));
+        }
+    }
+
+    @Test
+    void productionModeUsesProductionGateway() throws Exception {
+        StubSender sender = new StubSender(nativeProperties(BusinessPushProperties.ApnsEnvironment.PRODUCTION));
+        sender.initialize();
+
+        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());
+    }
+
+    @Test
+    void rejectsMalformedAndKeylessCertificatesAtInitialization() throws Exception {
+        BusinessPushProperties properties = nativeProperties(BusinessPushProperties.ApnsEnvironment.PRODUCTION);
+        Path malformed = tempDir.resolve("malformed.p12");
+        Files.writeString(malformed, "not a PKCS12 file");
+        properties.getApp(RecipientApp.USER).setApnsCertificatePath(malformed.toString());
+        assertThrows(IllegalStateException.class, () -> new ApnsPushSender(properties).initialize());
+
+        Path keyless = tempDir.resolve("keyless.p12");
+        KeyStore store = KeyStore.getInstance("PKCS12");
+        store.load(null, null);
+        try (java.io.OutputStream stream = Files.newOutputStream(keyless)) {
+            store.store(stream, "test-password".toCharArray());
+        }
+        properties.getApp(RecipientApp.USER).setApnsCertificatePath(keyless.toString());
+        assertThrows(IllegalStateException.class, () -> new ApnsPushSender(properties).initialize());
+    }
+
+    @Test
+    void rejectsExpiredCertificate() throws Exception {
+        X509Certificate certificate = mock(X509Certificate.class);
+        doThrow(new CertificateExpiredException()).when(certificate).checkValidity();
+
+        assertThrows(IllegalStateException.class,
+                () -> ApnsPushSender.validateCertificate(certificate, RecipientApp.USER));
+    }
+
+    @Test
+    void classifiesApnsErrorWithoutExposingToken() throws Exception {
+        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());
+
+        IllegalStateException error = assertThrows(IllegalStateException.class,
+                () -> sender.send(RecipientApp.USER, "c".repeat(64), "title", "body", "{}"));
+
+        assertTrue(error.getMessage().contains("BadDeviceToken"));
+        assertFalse(error.getMessage().contains("c".repeat(64)));
+    }
+
+    private BusinessPushProperties nativeProperties(BusinessPushProperties.ApnsEnvironment environment) {
+        BusinessPushProperties properties = new BusinessPushProperties();
+        properties.setMode(BusinessPushProperties.Mode.NATIVE);
+        properties.setApnsEnvironment(environment);
+        for (RecipientApp app : RecipientApp.values()) {
+            BusinessPushProperties.AppProperties config = new BusinessPushProperties.AppProperties();
+            config.setApnsCertificatePath(app.name().toLowerCase() + ".p12");
+            config.setApnsCertificatePassword("test-password");
+            config.setApnsTopic("com.example." + app.name().toLowerCase());
+            properties.getApps().put(app, config);
+        }
+        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, String> loadedPaths = new EnumMap<>(RecipientApp.class);
+
+        StubSender(BusinessPushProperties properties) throws Exception {
+            super(properties);
+            for (RecipientApp app : RecipientApp.values()) {
+                HttpClient client = mock(HttpClient.class);
+                @SuppressWarnings("unchecked")
+                HttpResponse<String> response = mock(HttpResponse.class);
+                when(response.statusCode()).thenReturn(200);
+                doReturn(response).when(client).send(any(), any());
+                clients.put(app, client);
+            }
+        }
+
+        @Override
+        protected HttpClient createClient(RecipientApp app, BusinessPushProperties.AppProperties config) {
+            loadedPaths.put(app, config.getApnsCertificatePath());
+            return clients.get(app);
+        }
+    }
+}