Просмотр исходного кода

feat(push): add per-app FCM sender

qmj 3 недель назад
Родитель
Сommit
3a979e1d64

+ 142 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/push/FcmPushSender.java

@@ -0,0 +1,142 @@
+package com.ruoyi.app.push;
+
+import com.alibaba.fastjson2.JSON;
+import com.alibaba.fastjson2.JSONObject;
+import com.google.auth.oauth2.GoogleCredentials;
+import com.google.auth.oauth2.ServiceAccountCredentials;
+import com.google.firebase.FirebaseApp;
+import com.google.firebase.FirebaseOptions;
+import com.google.firebase.messaging.AndroidConfig;
+import com.google.firebase.messaging.FirebaseMessaging;
+import com.google.firebase.messaging.FirebaseMessagingException;
+import com.google.firebase.messaging.Message;
+import com.google.firebase.messaging.MessagingErrorCode;
+import com.google.firebase.messaging.Notification;
+import jakarta.annotation.PostConstruct;
+import jakarta.annotation.PreDestroy;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Component;
+
+import java.io.InputStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.EnumMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.UUID;
+
+@Component
+public class FcmPushSender {
+    private static final Logger log = LoggerFactory.getLogger(FcmPushSender.class);
+    private static final List<String> BUSINESS_FIELDS = List.of("ddId", "state", "type", "pushType");
+
+    private final BusinessPushProperties properties;
+    private final Map<RecipientApp, FirebaseMessaging> messaging = new EnumMap<>(RecipientApp.class);
+    private final List<FirebaseApp> ownedApps = new ArrayList<>();
+    private final Set<String> projectIds = new HashSet<>();
+    private final String appInstance = UUID.randomUUID().toString();
+
+    public FcmPushSender(BusinessPushProperties properties) {
+        this.properties = properties;
+    }
+
+    @PostConstruct
+    public synchronized void initialize() {
+        if (properties.getMode() != BusinessPushProperties.Mode.NATIVE) {
+            return;
+        }
+        if (!messaging.isEmpty()) {
+            return;
+        }
+        try {
+            for (RecipientApp app : RecipientApp.values()) {
+                String path = properties.getApp(app).getFcmCredentialsPath();
+                messaging.put(app, createMessaging(app, path));
+            }
+        } catch (RuntimeException exception) {
+            close();
+            throw exception;
+        }
+    }
+
+    protected FirebaseMessaging createMessaging(RecipientApp app, String credentialsPath) {
+        try (InputStream stream = Files.newInputStream(Path.of(credentialsPath))) {
+            GoogleCredentials credentials = GoogleCredentials.fromStream(stream);
+            if (!(credentials instanceof ServiceAccountCredentials serviceAccount)
+                    || serviceAccount.getProjectId() == null
+                    || !projectIds.add(serviceAccount.getProjectId())) {
+                throw new IllegalStateException("Invalid or duplicated Firebase project for " + app);
+            }
+            FirebaseOptions options = FirebaseOptions.builder()
+                    .setCredentials(credentials)
+                    .setProjectId(serviceAccount.getProjectId())
+                    .build();
+            FirebaseApp firebaseApp = FirebaseApp.initializeApp(options,
+                    "foodie-business-" + appInstance + "-" + app.name().toLowerCase());
+            ownedApps.add(firebaseApp);
+            return FirebaseMessaging.getInstance(firebaseApp);
+        } catch (Exception exception) {
+            throw new IllegalStateException("Cannot initialize FCM credentials for " + app);
+        }
+    }
+
+    public void send(RecipientApp app, String token, String title, String content, String payload) {
+        FirebaseMessaging client = messaging.get(app);
+        if (client == null) {
+            throw new IllegalStateException("FCM_NOT_INITIALIZED");
+        }
+        if (token == null || token.isBlank()) {
+            throw new IllegalStateException("FCM_TOKEN_MISSING");
+        }
+        try {
+            client.send(buildMessage(token, title, content, payload));
+        } catch (FirebaseMessagingException exception) {
+            MessagingErrorCode code = exception.getMessagingErrorCode();
+            String category = code == null ? "UNKNOWN" : code.name();
+            log.warn("FCM push failed, app={}, category={}", app, category);
+            throw new IllegalStateException("FCM_" + category);
+        }
+    }
+
+    static Message buildMessage(String token, String title, String content, String payload) {
+        String safeTitle = title == null ? "" : title;
+        String safeContent = content == null ? "" : content;
+        Message.Builder message = Message.builder()
+                .setToken(token)
+                .setNotification(Notification.builder().setTitle(safeTitle).setBody(safeContent).build())
+                .setAndroidConfig(AndroidConfig.builder().setPriority(AndroidConfig.Priority.HIGH).build())
+                .putData("title", safeTitle)
+                .putData("body", safeContent);
+        if (payload != null) {
+            message.putData("payload", payload);
+            try {
+                JSONObject fields = JSON.parseObject(payload);
+                if (fields != null) {
+                    for (String field : BUSINESS_FIELDS) {
+                        Object value = fields.get(field);
+                        if (value != null) {
+                            message.putData(field, String.valueOf(value));
+                        }
+                    }
+                }
+            } catch (RuntimeException exception) {
+                log.warn("FCM payload is not JSON; forwarding its original text");
+            }
+        }
+        return message.build();
+    }
+
+    @PreDestroy
+    public void close() {
+        for (FirebaseApp app : ownedApps) {
+            app.delete();
+        }
+        ownedApps.clear();
+        messaging.clear();
+        projectIds.clear();
+    }
+}

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

@@ -0,0 +1,154 @@
+package com.ruoyi.app.push;
+
+import com.google.firebase.messaging.FirebaseMessaging;
+import com.google.firebase.messaging.FirebaseMessagingException;
+import com.google.firebase.messaging.Message;
+import com.google.firebase.messaging.MessagingErrorCode;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.lang.reflect.Field;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.EnumMap;
+import java.util.Map;
+
+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.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+class FcmPushSenderTest {
+    @TempDir
+    Path tempDir;
+
+    @Test
+    void sendsEachRecipientThroughItsOwnProjectWithOriginalPayload() throws Exception {
+        BusinessPushProperties properties = nativeProperties();
+        StubSender sender = new StubSender(properties);
+        sender.initialize();
+
+        for (RecipientApp app : RecipientApp.values()) {
+            assertEquals(app.name().toLowerCase() + ".json", sender.loadedPaths.get(app));
+            String token = "native-token-" + app.name();
+            sender.send(app, token, "New order", "Order body",
+                    "{\"ddId\":123,\"state\":2,\"type\":0,\"pushType\":\"order\"}");
+            org.mockito.ArgumentCaptor<Message> captured = org.mockito.ArgumentCaptor.forClass(Message.class);
+            verify(sender.messaging.get(app)).send(captured.capture());
+            Message message = captured.getValue();
+            assertEquals(token, field(message, "token"));
+            @SuppressWarnings("unchecked")
+            Map<String, String> data = (Map<String, String>) field(message, "data");
+            assertEquals("New order", data.get("title"));
+            assertEquals("Order body", data.get("body"));
+            assertEquals("123", data.get("ddId"));
+            assertEquals("2", data.get("state"));
+            assertEquals("0", data.get("type"));
+            assertEquals("order", data.get("pushType"));
+            assertTrue(data.get("payload").contains("\"ddId\":123"));
+            assertFalse(data.values().contains("uni-cid"));
+        }
+    }
+
+    @Test
+    void classifiesUnregisteredTokenWithoutIncludingTokenInError() throws Exception {
+        StubSender sender = new StubSender(nativeProperties());
+        sender.initialize();
+        FirebaseMessagingException failure = mock(FirebaseMessagingException.class);
+        when(failure.getMessagingErrorCode()).thenReturn(MessagingErrorCode.UNREGISTERED);
+        when(sender.messaging.get(RecipientApp.USER).send(any(Message.class))).thenThrow(failure);
+
+        IllegalStateException error = assertThrows(IllegalStateException.class,
+                () -> sender.send(RecipientApp.USER, "sensitive-native-token", "title", "body", "{}"));
+
+        assertTrue(error.getMessage().contains("UNREGISTERED"));
+        assertFalse(error.getMessage().contains("sensitive-native-token"));
+    }
+
+    @Test
+    void classifiesProjectMismatchWithoutIncludingTokenInError() throws Exception {
+        StubSender sender = new StubSender(nativeProperties());
+        sender.initialize();
+        FirebaseMessagingException failure = mock(FirebaseMessagingException.class);
+        when(failure.getMessagingErrorCode()).thenReturn(MessagingErrorCode.SENDER_ID_MISMATCH);
+        when(sender.messaging.get(RecipientApp.MERCHANT).send(any(Message.class))).thenThrow(failure);
+
+        IllegalStateException error = assertThrows(IllegalStateException.class,
+                () -> sender.send(RecipientApp.MERCHANT, "sensitive-native-token", "title", "body", "{}"));
+
+        assertTrue(error.getMessage().contains("SENDER_ID_MISMATCH"));
+        assertFalse(error.getMessage().contains("sensitive-native-token"));
+    }
+
+    @Test
+    void repeatedInitializationDoesNotReloadProjects() {
+        StubSender sender = new StubSender(nativeProperties());
+
+        sender.initialize();
+        sender.initialize();
+
+        assertEquals(3, sender.loadCount);
+    }
+
+    @Test
+    void invalidCredentialFileFailsNativeInitialization() throws Exception {
+        BusinessPushProperties properties = nativeProperties();
+        Path invalidFile = tempDir.resolve("invalid.json");
+        Files.writeString(invalidFile, "{}");
+        properties.getApp(RecipientApp.USER).setFcmCredentialsPath(invalidFile.toString());
+
+        IllegalStateException error = assertThrows(IllegalStateException.class,
+                () -> new FcmPushSender(properties).initialize());
+
+        assertTrue(error.getMessage().contains("USER"));
+        assertFalse(error.getMessage().contains(invalidFile.toString()));
+    }
+
+    @Test
+    void uniModeDoesNotLoadCredentials() {
+        FcmPushSender sender = new FcmPushSender(new BusinessPushProperties());
+        sender.initialize();
+        sender.close();
+    }
+
+    private BusinessPushProperties nativeProperties() {
+        BusinessPushProperties properties = new BusinessPushProperties();
+        properties.setMode(BusinessPushProperties.Mode.NATIVE);
+        for (RecipientApp app : RecipientApp.values()) {
+            BusinessPushProperties.AppProperties config = new BusinessPushProperties.AppProperties();
+            config.setFcmCredentialsPath(app.name().toLowerCase() + ".json");
+            properties.getApps().put(app, config);
+        }
+        return properties;
+    }
+
+    private Object field(Object object, String name) throws Exception {
+        Field field = object.getClass().getDeclaredField(name);
+        field.setAccessible(true);
+        return field.get(object);
+    }
+
+    private static class StubSender extends FcmPushSender {
+        private final Map<RecipientApp, FirebaseMessaging> messaging = new EnumMap<>(RecipientApp.class);
+        private final Map<RecipientApp, String> loadedPaths = new EnumMap<>(RecipientApp.class);
+        private int loadCount;
+
+        StubSender(BusinessPushProperties properties) {
+            super(properties);
+            for (RecipientApp app : RecipientApp.values()) {
+                messaging.put(app, mock(FirebaseMessaging.class));
+            }
+        }
+
+        @Override
+        protected FirebaseMessaging createMessaging(RecipientApp app, String credentialsPath) {
+            loadCount++;
+            loadedPaths.put(app, credentialsPath);
+            return messaging.get(app);
+        }
+    }
+}