Sfoglia il codice sorgente

feat(push): bundle per-app native push credentials

qmj 2 settimane fa
parent
commit
4fa9716326
22 ha cambiato i file con 246 aggiunte e 40 eliminazioni
  1. 30 4
      ruoyi-admin/src/main/java/com/ruoyi/app/push/ApnsPushSender.java
  2. 11 1
      ruoyi-admin/src/main/java/com/ruoyi/app/push/BusinessPushProperties.java
  3. 1 3
      ruoyi-admin/src/main/java/com/ruoyi/app/push/FcmPushSender.java
  4. 23 0
      ruoyi-admin/src/main/java/com/ruoyi/app/push/PushCredentialResource.java
  5. 9 6
      ruoyi-admin/src/main/resources/application.yml
  6. 4 0
      ruoyi-admin/src/main/resources/business-push/merchant-apns-config.json
  7. BIN
      ruoyi-admin/src/main/resources/business-push/merchant-apns.p12
  8. 13 0
      ruoyi-admin/src/main/resources/business-push/merchant-fcm-service-account.json
  9. 4 0
      ruoyi-admin/src/main/resources/business-push/rider-apns-config.json
  10. BIN
      ruoyi-admin/src/main/resources/business-push/rider-apns.p12
  11. 13 0
      ruoyi-admin/src/main/resources/business-push/rider-fcm-service-account.json
  12. 4 0
      ruoyi-admin/src/main/resources/business-push/user-apns-config.json
  13. BIN
      ruoyi-admin/src/main/resources/business-push/user-apns.p12
  14. 13 0
      ruoyi-admin/src/main/resources/business-push/user-fcm-service-account.json
  15. 41 0
      ruoyi-admin/src/test/java/com/ruoyi/app/push/ApnsPushSenderTest.java
  16. 16 0
      ruoyi-admin/src/test/java/com/ruoyi/app/push/BusinessPushPropertiesTest.java
  17. 14 0
      ruoyi-admin/src/test/java/com/ruoyi/app/push/FcmPushSenderTest.java
  18. 1 1
      specs/012-im-user-integration/code-status.md
  19. 17 8
      specs/033-native-push-routing/plan.md
  20. 17 9
      specs/033-native-push-routing/quickstart.md
  21. 8 7
      specs/033-native-push-routing/spec.md
  22. 7 1
      specs/033-native-push-routing/tasks.md

+ 30 - 4
ruoyi-admin/src/main/java/com/ruoyi/app/push/ApnsPushSender.java

@@ -17,8 +17,6 @@ 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;
@@ -48,7 +46,9 @@ public class ApnsPushSender {
         }
         try {
             for (RecipientApp app : RecipientApp.values()) {
-                clients.put(app, createClient(app, properties.getApp(app)));
+                BusinessPushProperties.AppProperties config = properties.getApp(app);
+                loadApnsConfig(app, config);
+                clients.put(app, createClient(app, config));
             }
         } catch (RuntimeException exception) {
             close();
@@ -56,11 +56,37 @@ public class ApnsPushSender {
         }
     }
 
+    private void loadApnsConfig(RecipientApp app, BusinessPushProperties.AppProperties config) {
+        if (config.getApnsConfigPath() == null || config.getApnsConfigPath().isBlank()
+                || (config.getApnsCertificatePassword() != null && !config.getApnsCertificatePassword().isBlank()
+                && config.getApnsTopic() != null && !config.getApnsTopic().isBlank())) {
+            return;
+        }
+        try (InputStream stream = PushCredentialResource.open(config.getApnsConfigPath())) {
+            JSONObject details = JSON.parseObject(new String(stream.readAllBytes(), StandardCharsets.UTF_8));
+            if (details == null) {
+                throw new IllegalStateException("Empty APNs config");
+            }
+            if (config.getApnsCertificatePassword() == null || config.getApnsCertificatePassword().isBlank()) {
+                config.setApnsCertificatePassword(details.getString("pwd"));
+            }
+            if (config.getApnsTopic() == null || config.getApnsTopic().isBlank()) {
+                config.setApnsTopic(details.getString("packetName"));
+            }
+            if (config.getApnsCertificatePassword() == null || config.getApnsCertificatePassword().isBlank()
+                    || config.getApnsTopic() == null || config.getApnsTopic().isBlank()) {
+                throw new IllegalStateException("Incomplete APNs config");
+            }
+        } catch (Exception exception) {
+            throw new IllegalStateException("Cannot initialize APNs config for " + app);
+        }
+    }
+
     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()))) {
+            try (InputStream stream = PushCredentialResource.open(config.getApnsCertificatePath())) {
                 store.load(stream, password);
             }
             boolean validPrivateKey = false;

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

@@ -29,7 +29,8 @@ public class BusinessPushProperties {
         for (RecipientApp app : RecipientApp.values()) {
             AppProperties config = apps.get(app);
             if (config == null || blank(config.fcmCredentialsPath) || blank(config.apnsCertificatePath)
-                    || blank(config.apnsCertificatePassword) || blank(config.apnsTopic)) {
+                    || (blank(config.apnsConfigPath)
+                    && (blank(config.apnsCertificatePassword) || blank(config.apnsTopic)))) {
                 throw new IllegalStateException("Incomplete native push configuration for " + app);
             }
         }
@@ -70,6 +71,7 @@ public class BusinessPushProperties {
     public static class AppProperties {
         private String fcmCredentialsPath;
         private String apnsCertificatePath;
+        private String apnsConfigPath;
         private String apnsCertificatePassword;
         private String apnsTopic;
 
@@ -93,6 +95,14 @@ public class BusinessPushProperties {
             return apnsCertificatePassword;
         }
 
+        public String getApnsConfigPath() {
+            return apnsConfigPath;
+        }
+
+        public void setApnsConfigPath(String apnsConfigPath) {
+            this.apnsConfigPath = apnsConfigPath;
+        }
+
         public void setApnsCertificatePassword(String apnsCertificatePassword) {
             this.apnsCertificatePassword = apnsCertificatePassword;
         }

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

@@ -20,8 +20,6 @@ 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;
@@ -65,7 +63,7 @@ public class FcmPushSender {
     }
 
     protected FirebaseMessaging createMessaging(RecipientApp app, String credentialsPath) {
-        try (InputStream stream = Files.newInputStream(Path.of(credentialsPath))) {
+        try (InputStream stream = PushCredentialResource.open(credentialsPath)) {
             GoogleCredentials credentials = GoogleCredentials.fromStream(stream);
             if (!(credentials instanceof ServiceAccountCredentials serviceAccount)
                     || serviceAccount.getProjectId() == null

+ 23 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/push/PushCredentialResource.java

@@ -0,0 +1,23 @@
+package com.ruoyi.app.push;
+
+import org.springframework.core.io.ClassPathResource;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+/** Opens bundled push credentials while retaining absolute file path compatibility. */
+final class PushCredentialResource {
+    private static final String CLASSPATH_PREFIX = "classpath:";
+
+    private PushCredentialResource() {
+    }
+
+    static InputStream open(String location) throws IOException {
+        if (location.startsWith(CLASSPATH_PREFIX)) {
+            return new ClassPathResource(location.substring(CLASSPATH_PREFIX.length())).getInputStream();
+        }
+        return Files.newInputStream(Path.of(location));
+    }
+}

+ 9 - 6
ruoyi-admin/src/main/resources/application.yml

@@ -56,18 +56,21 @@ business-push:
   apns-environment: "${BUSINESS_PUSH_APNS_ENVIRONMENT:production}"
   apps:
     user:
-      fcm-credentials-path: "${BUSINESS_PUSH_USER_FCM_CREDENTIALS_PATH:}"
-      apns-certificate-path: "${BUSINESS_PUSH_USER_APNS_CERTIFICATE_PATH:}"
+      fcm-credentials-path: "${BUSINESS_PUSH_USER_FCM_CREDENTIALS_PATH:classpath:business-push/user-fcm-service-account.json}"
+      apns-certificate-path: "${BUSINESS_PUSH_USER_APNS_CERTIFICATE_PATH:classpath:business-push/user-apns.p12}"
+      apns-config-path: "${BUSINESS_PUSH_USER_APNS_CONFIG_PATH:classpath:business-push/user-apns-config.json}"
       apns-certificate-password: "${BUSINESS_PUSH_USER_APNS_CERTIFICATE_PASSWORD:}"
       apns-topic: "${BUSINESS_PUSH_USER_APNS_TOPIC:}"
     merchant:
-      fcm-credentials-path: "${BUSINESS_PUSH_MERCHANT_FCM_CREDENTIALS_PATH:}"
-      apns-certificate-path: "${BUSINESS_PUSH_MERCHANT_APNS_CERTIFICATE_PATH:}"
+      fcm-credentials-path: "${BUSINESS_PUSH_MERCHANT_FCM_CREDENTIALS_PATH:classpath:business-push/merchant-fcm-service-account.json}"
+      apns-certificate-path: "${BUSINESS_PUSH_MERCHANT_APNS_CERTIFICATE_PATH:classpath:business-push/merchant-apns.p12}"
+      apns-config-path: "${BUSINESS_PUSH_MERCHANT_APNS_CONFIG_PATH:classpath:business-push/merchant-apns-config.json}"
       apns-certificate-password: "${BUSINESS_PUSH_MERCHANT_APNS_CERTIFICATE_PASSWORD:}"
       apns-topic: "${BUSINESS_PUSH_MERCHANT_APNS_TOPIC:}"
     rider:
-      fcm-credentials-path: "${BUSINESS_PUSH_RIDER_FCM_CREDENTIALS_PATH:}"
-      apns-certificate-path: "${BUSINESS_PUSH_RIDER_APNS_CERTIFICATE_PATH:}"
+      fcm-credentials-path: "${BUSINESS_PUSH_RIDER_FCM_CREDENTIALS_PATH:classpath:business-push/rider-fcm-service-account.json}"
+      apns-certificate-path: "${BUSINESS_PUSH_RIDER_APNS_CERTIFICATE_PATH:classpath:business-push/rider-apns.p12}"
+      apns-config-path: "${BUSINESS_PUSH_RIDER_APNS_CONFIG_PATH:classpath:business-push/rider-apns-config.json}"
       apns-certificate-password: "${BUSINESS_PUSH_RIDER_APNS_CERTIFICATE_PASSWORD:}"
       apns-topic: "${BUSINESS_PUSH_RIDER_APNS_TOPIC:}"
 

+ 4 - 0
ruoyi-admin/src/main/resources/business-push/merchant-apns-config.json

@@ -0,0 +1,4 @@
+{
+"packetName":"com.twanmsdsj.app",
+"pwd":"1234"
+}

BIN
ruoyi-admin/src/main/resources/business-push/merchant-apns.p12


+ 13 - 0
ruoyi-admin/src/main/resources/business-push/merchant-fcm-service-account.json

@@ -0,0 +1,13 @@
+{
+  "type": "service_account",
+  "project_id": "twanmsdyh",
+  "private_key_id": "ff2bb63bb1a92184e0b61555413ee2aa2f66e7cd",
+  "private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDP61/CjOOes2B1\nBx4m7Ue43X/0mZUv9/GSczhNkVvnTKvimrao+UeARkRiG7xl2EG6M5NYEvM9/Gfy\nkOcvihucVMnn1U8rAUQgZZriVYHSAlDCBxqLs6x7aFYJ6fc9tTxss9oS8/IAWT53\n4qy0NnoGz1S5MoC9R4VE+zQT91497IHGxgpt9sGWm3YnSlhg54p44NUe/5dJ55FL\nbRYuTdY7Zo12J8otni3tf1w1JrbudWKuRlpXFo6L3XqN0b5YTALK7Heym0w1/Od+\nfUt+Y5yGmbnnVpkG0gTG6j06vTr9CrTDrX9ixG2uy8mA7FP3mHnraI6+9tVcjqWH\nuexaqN0NAgMBAAECggEAJalN5DCe/l0hiLEsXgK27h55EfZL1q+Du3JRbYN58TCr\nJdW+ZmcY83xG4LqDarM8XJ0BAIAWPVEeBwsGRU8v5yZYmDzEJVcKXw/oeTzh0geH\nV03wGgQGpyfkBdGybiQI+OuvAaXcXQHNVeviOpZvjhI6F321YhO1aSRTb5ZsJK0B\nvIBEh5j4Qf2FTAcpSJlh8TfvIVOZl443Cpzp5ZRuyiyNWrrXVQvkAnkvl1iN6Pge\nVunUcGAD0XYlUdn04WgzdmW40fsEuGCirWUGoCcomBRIc0fO/kNGTn+6Wo9PraMQ\nKW5Lm9Tlv9S656buWusDjj+z+BN5VJ6H2zkQ9f4sAQKBgQDx1+kdaM+pQirC4b2+\nW/8qjBzHCgWxu0EUdFwglZGc3DVMN6fASdVFCpy25ugvS6EJuDo2vM+6xdQtr115\nTv+S1fOVSZFftnz4Y/PIZ6PFbovz2sLYTKNBKXZpwxv/vaFBXa/40B0c0a3Fa30r\netxOwNtMiqlZQT5VaF1aHAOHAQKBgQDcFxp+ZM4Twka/9jN82L/dGP+nUX5qlt7u\nr7BxvJHGodJFP8ozKvz3f+WkUuCsOIjBRWdPGFu9xE/YORuvj3QkQBRlmPtJm5zw\nHdfQkZFO3JnvjMbFJYc70vn26gpKsIFT5xHLy1ZkEAWyNtITzzxBZOxQKWcYbjaT\nUymsbG0CDQKBgQCy3eSdCsLjxXtI7Zrfar07Y6t5DpGNlRgaH8FvnA+5Oyj0OQmJ\n71GcIbAsmnVJXZ86SBZOG7FryxsVuujgaqwdlr30QRgS+S5Oq+ZG51g6EnvSHI8V\nWQE5lYftyw8vQ7o7VSZsWMffY7kpFUnbWaANYxgO8VM1H0sR8gFxPtzhAQKBgAGC\n3xmzE38rPFUhGaVnz5lzDH5XnuiYdJtq9WkyhEW64ri7AWZsiyQf6rHAJ+7GzJmX\nImh2oFqjOHEho0EHWSLOldVEwaT1vpQfgFXcSgUimqP8cXj1EwKkJjc90ChHIfwz\nf/nIQaWaPo/EtJiSObWxPbYnMqAGUbWdQpH+hJkZAoGAGRt3hlNJN7Q4Spn+icc3\nwuD2GziMhVVl3/KdVbyDDf86p1LqQ0r6YsbOog2jX/eJTG98cGw+c9zuE/H22B2H\nGttd/9xHE/mnm7cHSsJHePEa9mU6tattref2ZvR2F8/Smo6y0riM/+rYYCXZQu68\nzIXcOuCEJfCUu0Pz2Wsbxm0=\n-----END PRIVATE KEY-----\n",
+  "client_email": "firebase-adminsdk-fbsvc@twanmsdyh.iam.gserviceaccount.com",
+  "client_id": "101216374296735054312",
+  "auth_uri": "https://accounts.google.com/o/oauth2/auth",
+  "token_uri": "https://oauth2.googleapis.com/token",
+  "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
+  "client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/firebase-adminsdk-fbsvc%40twanmsdyh.iam.gserviceaccount.com",
+  "universe_domain": "googleapis.com"
+}

+ 4 - 0
ruoyi-admin/src/main/resources/business-push/rider-apns-config.json

@@ -0,0 +1,4 @@
+{
+"packetName":"com.twanmsdqs.app",
+"pwd":"1234"
+}

BIN
ruoyi-admin/src/main/resources/business-push/rider-apns.p12


+ 13 - 0
ruoyi-admin/src/main/resources/business-push/rider-fcm-service-account.json

@@ -0,0 +1,13 @@
+{
+  "type": "service_account",
+  "project_id": "twanmsdqs",
+  "private_key_id": "0b0916fc3e16a79163a5e19b8621dab4f7cf3dc5",
+  "private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDB7PuCNofoK90P\nF1yydqZ2tjAdn6mYCtpcZNKGqKAA/sm5h6UpvtgwUu8rGhxgbRCxShDjodnZn3L5\n9A1Epps6qLKHqIb+gThY6j2g1fOu1QNTfi68QpCLUgmszsbHs0dSoWGjnngr8LPI\nMrObc+jQZINwdYWfUjkqQggwsIRZycvtxGSh/YFrLPiI5iH40RtJCIlL2kXvaSVc\nMO0ILmPxYE3jNJ7foJ8o3FMuuWNrp3sCHB9Zw69n5mBpdro3qtUBn88l44WeTFTJ\ng971mCpEybw50IbtDMAxqZ+4GoJo0yeXQHNpw2RciJF937ie0DSUeKV3G7faQhS9\nk9ssFsflAgMBAAECggEAK4oz4OUx5+Z7RDFkWlxJ3SLkFC6eEvky1CY930Z6YCbU\nb0+dfU0wOE9Si/yZjOvH4OpGdvlGknYaiDSrivOWLBomtU8Y+gjUuB/ldNg8lU8V\np1gQEawnTGR/sBkf58PgelSp+aSxifoJv2Pkz/z681jx6ty0AtSg6ZQ4RwhGuuqv\nn6myHUkDl30seSVqYMRmHQ6m8SlQqra9LvQ6aMvkk7G4JeJIRCmL7BV4lATOav8b\nRfhcuf/drtD2nVPBv38YLwJSPjFRuLByExH57KCAmS8ixzejsO1xluxc4DCB9Wrt\nccsjMdYvFcI8PXywuh/cQMqJ2dWkHuoa2ecrA/8jsQKBgQD0bk4F3w+rkdz2Mq1d\nFhpxiIq1BotlGHyiE+sv+XiKFmr7DD+Nrzol0ZEdqtvX4R0olPwroKmp9YGBCsF8\nCTlwS9rdyiofDObZkaq+hN5VGFYYPEFbcTtecuKVLAE02TsF4cGZfWCzisrZ0gEo\nCU7GWyxpfNa+v0lb/9R7x3ESNQKBgQDLGrjJdKPD2/PSVHL3HpNY1qXRmDxtHZaA\ntDvHzOR2dD9wGfr3gqujf6jDttx7BVBeWfDok99Y7JLxpEAb309347+AY0l2eDxg\nyStUF++cVK9i5eUZxW+EZ5Dlv9al9SYiDZbg1LRXYqcqSmBBVIbJdg9Xwp8qZKlh\nth9b/2aU8QKBgB85iBYP7LyKIaQBzUu66x3dx3pgn6YGfsVlrgGu6MsbGgeABQ5g\neBkksFkN3o+xXjw07BHWsFXZ2vVVHmpt8zx5NDho/wCAcW0RwrIb3xsXxMfLyr2X\ntXHXU0KjIHRT28pgkI6/CX9mF4vD3An1Rm7obr/7BqPPY9uwzsARNkyRAoGBAIx3\nPaduOLiDTTyA1UXdapB9Ww7614erd+QpQlLrfHID8b/I6A333FbmOkSpGhEm2q92\nSqu/YS9x5iqiqwfbyG0lINNdV3hMrY1Oc93j8/5Nf0LJtkm9h2Ot8MNEiuIaCrZw\naZGy6cqp2uzfpXRpThLUyMWsP0IlfhOqzkNk/oAxAoGAB1lpG52C29HkkMpz34Wk\n+E5b6WrMaUClu7FauKAgIQvXx2fYEaD1msB1fbKm5+FGlFxVV8r3fDpcz1BvT/yR\nHu2IzUQrAagdpLtIQbVOe1Ger3CfxkmzC0QMUerBQnf2fRolnO/kP5mp3CGb2KcZ\nuvaT6QE9tTjHc7JW8wBV97c=\n-----END PRIVATE KEY-----\n",
+  "client_email": "firebase-adminsdk-fbsvc@twanmsdqs.iam.gserviceaccount.com",
+  "client_id": "111206564462969425515",
+  "auth_uri": "https://accounts.google.com/o/oauth2/auth",
+  "token_uri": "https://oauth2.googleapis.com/token",
+  "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
+  "client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/firebase-adminsdk-fbsvc%40twanmsdqs.iam.gserviceaccount.com",
+  "universe_domain": "googleapis.com"
+}

+ 4 - 0
ruoyi-admin/src/main/resources/business-push/user-apns-config.json

@@ -0,0 +1,4 @@
+{
+"packetName":"com.twanmsdyh.app",
+"pwd":"1234"
+}

BIN
ruoyi-admin/src/main/resources/business-push/user-apns.p12


+ 13 - 0
ruoyi-admin/src/main/resources/business-push/user-fcm-service-account.json

@@ -0,0 +1,13 @@
+{
+  "type": "service_account",
+  "project_id": "twanmsdyh-26c23",
+  "private_key_id": "b3023c7b0ea8176dbd2e1a33a19e2f5e639346fb",
+  "private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQDUpiSIdEjCR+un\nfcSH3Tb9GiH1174YhJ7LIoRmFgZFdIjKEQNb0TOHg2CZQ9asYHbuHl1dwiJctaTH\nBArCboHRej5idLMtU+H08SARHTyDGXENwnT3xl6pEhYkDIJNhPFMWqqJr/AIBRX4\nAJ3c7CG2fPAYc8vYGKAXmunNrkPq7nzyfpLlueDZ06wxCOnkBW668faDQHOrtuGK\n6eB3Mk6yW92RwX4i1lVTlTutmciXOLiIA1VEOv/uf/GgacG5n7rj0NATZkU4vK8j\nc/jKl2BIw2oD/GSJljFu8Se2S/aGneYI50581ZsFvFmlaPV4xnUgvn5pOhidEHqj\n0LQC5+oHAgMBAAECggEAY6lWN+mf7AX/COmlwTNJQUgNIIYzXpRRQ8zyllG+MLEU\nJvfgRhj++IT3Mf2cT/+J3cG3amjA6AhtqKRZUpSuHPoY4NXm39iPbtfPepXkNWEo\nHhXD8hgzZzPF0rFWJHSpDmrzwEIH0gpoKGpJki/NART/BcbHg4jPByEgcLg6tpCi\noBDk2y1ejNQEsn3RRnjuX24AcX9YDDV8XnG5XjyKkQsx0GBGLGpDQI9rnzyiSXv1\n005Bm+84sJ2L7eo+lhk2kyYGNbOiqML2vOorw+PJw1+qVZDF0uYRFu9B31NWOraH\nWdM6dFKKhXvviUQBft3z8gAqk6Xs9WUhmSUm7wiJCQKBgQDsa/GzLdDOZ6aaEeE4\nFQdtNruRpDFfUJdyhchU9wokZKPNRfGRJSF1jYb0hdG+K7Ql/3qPF18Mfuw3amVJ\nHKN+4AFWqFBA9cHKQ3tc0WmZ/TNplCcrEF9m3mK6kzo/otny2cmVxPRbvwQYLf0c\n58o328esNju3V5Bfm3pvvRDvYwKBgQDmQjn8bknKQfJhwWKwf2LILdqHtumYB/aQ\nWd56LFGqUc4TpnfIJV4hIsHFtlGzTgk3Zk2S0tiP1E7i+v+CAOlb7ObXfoYXGEBR\nXlnNgYNXZpi/Sn4babqlar0tXQkERuIyLLgejiXrAQVxcHUQ7TR1WHdlQgzecpC5\nmyeVXAXWDQKBgHnhpDcVNYJTGSZwdUUuQifhgY0xsmpK5+iYY5C50ra0U2Wt2z1S\ngQ9TKPHCU0DQ0wyLyqkeCVgtzqU66oBojTkJTmdYWhdDGualJ2HQa7qBOBID2LVw\nuV4U45z/7ZWE5Qs3j57wbHEn5np4qvZN4Igve56iTuLT94/yXYphU+u1AoGAMj7P\nYns6lO0xFA3cGEGj76S55BWRAcDzF+rUdnqHU5BIVNdlmgI1eSuZdyhiyarErhcf\n5GMoKLKeiD9XVCoYwht/ABU284EyP89D72lMnNzhhhSeUiMPPJt/Lo/3sLw7DdHe\nV6STN6XgZzGwCgKJCPZGD42LO1tqo2XtDFWpXF0CgYBWcyTwYG32Z/7nkvxEf0fy\n6RVQBs1uNjeX7UDeMXWVgkDaO0i731btmM4E5sygP3YnHTYSti+cATywqyBuZhFe\nHNscW1/VLLedCSAff6DRU/g6+2bldgWwMjq+DDLwZa0eOaNOf5/RDNClOeNHTvW/\npB9Gdk4d0Sgs/yHl/+O4oQ==\n-----END PRIVATE KEY-----\n",
+  "client_email": "firebase-adminsdk-fbsvc@twanmsdyh-26c23.iam.gserviceaccount.com",
+  "client_id": "100941029914068562072",
+  "auth_uri": "https://accounts.google.com/o/oauth2/auth",
+  "token_uri": "https://oauth2.googleapis.com/token",
+  "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
+  "client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/firebase-adminsdk-fbsvc%40twanmsdyh-26c23.iam.gserviceaccount.com",
+  "universe_domain": "googleapis.com"
+}

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

@@ -23,6 +23,7 @@ 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.assertDoesNotThrow;
 import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -77,6 +78,32 @@ class ApnsPushSenderTest {
         assertEquals("api.push.apple.com", captured.getValue().uri().getHost());
     }
 
+    @Test
+    void classpathApnsConfigsSelectEachAppsOwnTopic() throws Exception {
+        BusinessPushProperties properties = classpathProperties();
+        StubSender sender = new StubSender(properties);
+        sender.initialize();
+        Map<RecipientApp, String> topics = Map.of(
+                RecipientApp.USER, "com.twanmsdyh.app",
+                RecipientApp.MERCHANT, "com.twanmsdsj.app",
+                RecipientApp.RIDER, "com.twanmsdqs.app");
+
+        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());
+        }
+    }
+
+    @Test
+    void classpathApnsCertificatesUnlockWithEachAppsConfig() {
+        ApnsPushSender sender = new ApnsPushSender(classpathProperties());
+
+        assertDoesNotThrow(sender::initialize);
+        sender.close();
+    }
+
     @Test
     void rejectsMalformedAndKeylessCertificatesAtInitialization() throws Exception {
         BusinessPushProperties properties = nativeProperties(BusinessPushProperties.ApnsEnvironment.PRODUCTION);
@@ -135,6 +162,20 @@ class ApnsPushSenderTest {
         return properties;
     }
 
+    private BusinessPushProperties classpathProperties() {
+        BusinessPushProperties properties = new BusinessPushProperties();
+        properties.setMode(BusinessPushProperties.Mode.NATIVE);
+        properties.setApnsEnvironment(BusinessPushProperties.ApnsEnvironment.DEVELOPMENT);
+        for (RecipientApp app : RecipientApp.values()) {
+            String name = app.name().toLowerCase();
+            BusinessPushProperties.AppProperties config = new BusinessPushProperties.AppProperties();
+            config.setApnsCertificatePath("classpath:business-push/" + name + "-apns.p12");
+            config.setApnsConfigPath("classpath:business-push/" + name + "-apns-config.json");
+            properties.getApps().put(app, config);
+        }
+        return properties;
+    }
+
     private String bodyOf(HttpRequest request) throws Exception {
         CompletableFuture<byte[]> completed = new CompletableFuture<>();
         ByteArrayOutputStream output = new ByteArrayOutputStream();

+ 16 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/push/BusinessPushPropertiesTest.java

@@ -46,6 +46,22 @@ class BusinessPushPropertiesTest {
                 });
     }
 
+    @Test
+    void nativeModeAcceptsPerAppClasspathConfigInsteadOfInlineApnsSecrets() {
+        contextRunner.withPropertyValues(
+                "business-push.mode=native",
+                "business-push.apps.user.fcm-credentials-path=classpath:business-push/user-fcm-service-account.json",
+                "business-push.apps.user.apns-certificate-path=classpath:business-push/user-apns.p12",
+                "business-push.apps.user.apns-config-path=classpath:business-push/user-apns-config.json",
+                "business-push.apps.merchant.fcm-credentials-path=classpath:business-push/merchant-fcm-service-account.json",
+                "business-push.apps.merchant.apns-certificate-path=classpath:business-push/merchant-apns.p12",
+                "business-push.apps.merchant.apns-config-path=classpath:business-push/merchant-apns-config.json",
+                "business-push.apps.rider.fcm-credentials-path=classpath:business-push/rider-fcm-service-account.json",
+                "business-push.apps.rider.apns-certificate-path=classpath:business-push/rider-apns.p12",
+                "business-push.apps.rider.apns-config-path=classpath:business-push/rider-apns-config.json")
+                .run(context -> assertTrue(context.isRunning()));
+    }
+
     @Test
     void nativeModeRejectsUnknownApnsEnvironment() {
         String[] values = completeNativeProperties();

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

@@ -14,6 +14,7 @@ import java.util.EnumMap;
 import java.util.Map;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
 import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -119,6 +120,19 @@ class FcmPushSenderTest {
         sender.close();
     }
 
+    @Test
+    void nativeModeLoadsAllThreeNamedClasspathServiceAccounts() {
+        BusinessPushProperties properties = nativeProperties();
+        for (RecipientApp app : RecipientApp.values()) {
+            properties.getApp(app).setFcmCredentialsPath("classpath:business-push/"
+                    + app.name().toLowerCase() + "-fcm-service-account.json");
+        }
+        FcmPushSender sender = new FcmPushSender(properties);
+
+        assertDoesNotThrow(sender::initialize);
+        sender.close();
+    }
+
     private BusinessPushProperties nativeProperties() {
         BusinessPushProperties properties = new BusinessPushProperties();
         properties.setMode(BusinessPushProperties.Mode.NATIVE);

+ 1 - 1
specs/012-im-user-integration/code-status.md

@@ -25,7 +25,7 @@
 ## 推送通道现状
 
 - `PayPush` 封装 `userPush / shPush / qsPush`(及 `*HandleLocal` 带 i18n 版)。这些普通业务包装方法现委托 `BusinessPushService`;底层 uni 云函数 `msduser/msdrider/msdstore` 保留。
-- `business-push.mode` **默认 `uni`,原生通道尚未启用**。服务端已实现按用户/商家/骑手三端分别路由 Android FCM 和 iOS APNs;切换为 `native` 要部署三端外部凭据并重启,且需要 App 端提供真实 `deviceToken`。配置和真机检查见 `specs/033-native-push-routing/quickstart.md`。
+- `business-push.mode` **默认 `uni`,原生通道尚未启用**。服务端已实现按用户/商家/骑手三端分别路由 Android FCM 和 iOS APNs;三端凭据按端命名、随 `ruoyi-admin` 资源打包,但切换为 `native` 仍需 App 端提供真实 `deviceToken` 并重启。配置和真机检查见 `specs/033-native-push-routing/quickstart.md`。
 - `cid` 只供 uniPush;原生路由按当前账号的 `cidType` (`android`/`ios`) 与 `deviceToken` 选择 FCM/APNs,不能使用 `cid` 或 `voIPToken` 替代。`PayPush` 旧包装签名中的 `cidType` 参数仍不承担平台选择。
 - `PushEvent`→`push_message` 仍由原业务路径入库,外部发送通道不代替消息中心。音视频通话、废弃入口和临时测试推送接口未迁入此路由。
 - 异步推送用 `AsyncManager.me().execute(TimerTask)`(cte 用 `@Async` + `TransactionSynchronization.afterCommit`)。

+ 17 - 8
specs/033-native-push-routing/plan.md

@@ -15,7 +15,7 @@
 - 当前工作分支为 `test-202609v2`;用户要求不创建新分支。保留工作区已有未提交修改,尤其不修改与功能无关的 `InfoUserController.java` 测试接口。
 - `business-push.mode` 只允许 `uni`/`native`,缺省为 `uni`,切换后重启生效;不做后台实时开关。默认模式不能要求原生凭据。
 - `cid` 仅用于 uniPush,`deviceToken` 仅用于普通 FCM/APNs,`voIPToken` 不参与;单账号单设备,不改数据库结构。
-- 三端 Firebase 服务账号属于不同项目,必须初始化三个命名 `FirebaseApp`;iOS 使用三端各自的普通推送 `.p12`,按部署环境选择 APNs 开发/生产地址。凭据路径和密码从外部环境注入,不提交密钥内容。
+- 三端 Firebase 服务账号属于不同项目,必须初始化三个命名 `FirebaseApp`;iOS 使用三端各自的普通推送 `.p12`,按部署环境选择 APNs 开发/生产地址。2026-09-29 追加要求将三端真实凭据明确命名后提交 `resources/business-push/`,随 JAR 打包;仓库及构建产物按含密钥材料管理,禁止日志输出私钥或密码。
 - 只迁移在用的普通业务通知;音视频通话、已废弃入口和临时测试推送接口保持原行为。已有 `PushEvent` 入库时机与次数不改变,外发失败不影响业务且不自动转发 uniPush。
 - 后端集成代码放在 `ruoyi-admin`,不让 `ruoyi-system` 反向依赖应用层;不执行数据库 SQL。仅当前 Maven 命令临时使用 JDK 21,保留现有换行风格。
 
@@ -24,7 +24,7 @@
 1. 原生模式有 `deviceToken`、无 `cid` 的用户仍能被收件人筛选并发送;默认 uni 模式仍按 CID 判断。
 2. 用户、骑手、商家(含夜市及子账号)不会混用 Firebase 项目或 APNs topic;相同设备的商家收件人去重按当前通道地址。
    门店订单收件人仍是分管账号优先、主账号兜底,不同时发给两类账号。
-3. iOS 开发/生产证书与网关一致;缺失、过期、无法解锁或无私钥的证书在原生模式启动时明确失败。
+3. iOS 开发/生产网关切换复用同端证书;缺失、过期、无法解锁或无私钥的证书在原生模式启动时明确失败。
 4. LINE Pay/OMG 等本来已经自行发布 `PushEvent` 的路径不会因迁移产生第二条消息记录,且 CID 为空时原生发送仍可进行。
 5. `VideoCallController`、`/testPushMessage` 和测试批量接口继续走现有行为;业务通知 payload 的 `ddId/state/type/pushType` 不丢失。
 
@@ -39,7 +39,7 @@
 
 ## External Configuration Contract
 
-`application.yml` 仅保留 `business-push.mode: uni` 和环境变量占位符;部署时分别提供 `business-push.apps.user`、`business-push.apps.merchant`、`business-push.apps.rider` 下的 `fcm-credentials-path`、`apns-certificate-path`、`apns-certificate-password`、`apns-topic`。`business-push.apns-environment` 为 `development` 或 `production`。只有 `native` 模式校验并加载三端凭据;配置中没有凭据内容。iOS 证书须对应所选环境与 topic。
+`application.yml` 保持 `business-push.mode: uni`,三端文件路径设为有 `user`、`merchant`、`rider` 前缀的 `classpath:business-push/...` 资源。三端各提交一份独立 FCM 服务账号、一份 APNs 普通 `.p12` 和一份含 `packetName/pwd` 的配置 JSON;原生模式从该配置读取 topic 和证书密码。`business-push.apns-environment` 为 `development` 或 `production`,两环境使用每端同一张证书,只切换网关。原有外部文件路径及显式密码/topic 配置继续兼容。默认 `uni` 模式不读取凭据。
 
 FCM 请求的 data 至少包含字符串形式的 `title`、`body`、原始 `payload`,并保留现有可解析 payload 的 `ddId/state/type/pushType` 字段;APNs 使用 `aps.alert.title/body`、普通通知 sound 和同名业务字段。iOS 骑手端沿用现有 `pushsound.caf`,其余 iOS 端使用默认声音;Android FCM 使用默认声音,并为骑手标记 `qsRing` 通知渠道,最终自定义铃声取决于 App 是否创建该渠道(App 端不在本任务内)。过大或无法解析的 payload 必须记录可定位的失败类别,不得以截断方式改变业务字段。
 
@@ -48,7 +48,7 @@ FCM 请求的 data 至少包含字符串形式的 `title`、`body`、原始 `pay
 | 文件 | 职责 |
 |---|---|
 | `ruoyi-admin/src/main/java/com/ruoyi/app/push/RecipientApp.java` | USER / MERCHANT / RIDER 显式接收端标识 |
-| `ruoyi-admin/src/main/java/com/ruoyi/app/push/BusinessPushProperties.java` | 全局模式、APNs 环境、三端外部凭据路径与 topic 的配置绑定和原生模式校验 |
+| `ruoyi-admin/src/main/java/com/ruoyi/app/push/BusinessPushProperties.java` | 全局模式、APNs 环境、三端 classpath/外部凭据路径与 APNs 配置路径绑定和原生模式校验 |
 | `ruoyi-admin/src/main/java/com/ruoyi/app/push/FcmPushSender.java` | 三个命名 FirebaseApp;Android 单发、请求构造与失效错误分类 |
 | `ruoyi-admin/src/main/java/com/ruoyi/app/push/ApnsPushSender.java` | 三个证书型 HTTP/2 客户端;APNs alert、custom payload 与响应处理 |
 | `ruoyi-admin/src/main/java/com/ruoyi/app/push/BusinessPushService.java` | 统一外发路由、当前账号读取、通道地址判断及安全日志 |
@@ -77,7 +77,7 @@ FCM 请求的 data 至少包含字符串形式的 `title`、`body`、原始 `pay
 
 - [ ] Write tests: each app resolves its own named Firebase project; sends to `token`, never CID; title/content plus existing payload fields survive as string data; `UNREGISTERED` and project mismatch are classified without full token logging; repeated initialization does not conflict.
 - [ ] Run `mvn -pl ruoyi-admin -am -Dtest=FcmPushSenderTest -Dsurefire.failIfNoSpecifiedTests=false test` with JDK 21 and verify failure before implementation.
-- [ ] Initialize credentials from protected filesystem paths, build FCM notification + Android high-priority data, and send through the app-specific `FirebaseMessaging` instance; do not adopt CTE's single-default-app/CID assumptions.
+- [ ] Initialize credentials from the configured classpath or filesystem path, build FCM notification + Android high-priority data, and send through the app-specific `FirebaseMessaging` instance; do not adopt CTE's single-default-app/CID assumptions.
 - [ ] Re-run the same directed command and confirm pass; inspect staged names, commit only Task 2 files.
 
 ## Task 3: 三端 iOS APNs 普通通知
@@ -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 selected-environment PKCS#12 into per-app TLS client, reuse JDK HTTP/2 connections, send to `/3/device/{token}`; 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 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.
 - [ ] Re-run the same directed command and confirm pass; inspect staged names, commit only Task 3 files.
 
 ## Task 4: 统一外发路由与现有包装方法
@@ -117,7 +117,7 @@ FCM 请求的 data 至少包含字符串形式的 `title`、`body`、原始 `pay
 
 **Files:** Create `specs/033-native-push-routing/quickstart.md`; update `specs/012-im-user-integration/code-status.md`, `docs/order-push-overview.md`, `specs/033-native-push-routing/tasks.md`.
 
-- [ ] Document credential deployment (paths/env vars only, no values), dev/prod APNs selection, default-off, restart cutover/rollback, certificate expiry monitoring and real-device smoke checklist.
+- [ ] Document bundled classpath credentials (filenames only, no secret values), dev/prod APNs gateway selection, default-off, restart cutover/rollback, certificate expiry monitoring and real-device smoke checklist.
 - [ ] Run `mvn -pl ruoyi-admin -am -Dtest=BusinessPushPropertiesTest,FcmPushSenderTest,ApnsPushSenderTest,BusinessPushServiceTest,MerchantNotificationRouterTest,DeliveryOrderNotificationServiceTest,OmgPaymentNotifyServiceTest,PosOrderShOprateControllerTest,PosOrderQsOprateControllerTest,PosOrderAdminStatusControllerTest,LinePayControllerTest -Dsurefire.failIfNoSpecifiedTests=false test` under JDK 21; then run `mvn -pl ruoyi-admin -am -DskipTests package` and inspect exit codes.
 - [ ] Inspect `git diff --check`, full diff and staged file list for unrelated edits, secrets, encoding or line-ending churn; commit only documentation/verification updates. Record any unrun live FCM/APNs test explicitly.
 
@@ -132,4 +132,13 @@ FCM 请求的 data 至少包含字符串形式的 `title`、`body`、原始 `pay
 
 ## Verification Command Setup
 
-PowerShell 中仅对当前命令临时设置 `$env:JAVA_HOME='C:\Users\99029\.jdks\graalvm-jdk-21.0.12+7.1'` 和 `$env:PATH="$env:JAVA_HOME\bin;$env:PATH"`。2026-09-29 已运行 13 类定向测试(83 个用例,0 失败)及 `mvn -pl ruoyi-admin -am -DskipTests package`,退出码均为 0;真实 FCM/APNs 凭据、设备投递和 App 展示未在本机验证,启用前按 `quickstart.md` 执行真机验收。
+PowerShell 中仅对当前命令临时设置 `$env:JAVA_HOME='C:\Users\99029\.jdks\graalvm-jdk-21.0.12+7.1'` 和 `$env:PATH="$env:JAVA_HOME\bin;$env:PATH"`。2026-09-29 已运行 13 类定向测试(83 个用例,0 失败)、本次全模块 933 个测试(0 失败)及 `mvn -pl ruoyi-admin -am -DskipTests package`,退出码均为 0;已验证三端随包凭据可在本地加载,真实 FCM/APNs 设备投递和 App 展示未在本机验证,启用前按 `quickstart.md` 执行真机验收。
+
+## Task 7: 随包 classpath 凭据(2026-09-29 追加)
+
+**Files:** `BusinessPushProperties.java`、`FcmPushSender.java`、`ApnsPushSender.java`、对应测试、`application.yml`、`resources/business-push/` 九个按端命名的凭据文件、`spec.md`/`tasks.md`/`quickstart.md`。
+
+- [x] 先写失败测试:三端 classpath 凭据可独立读取;APNs 配置的 topic/密码用于证书和请求;缺失/无效资源原生模式启动失败,默认 uni 不加载。
+- [x] 从用户给出的三端文件各复制一份 FCM JSON、一份普通 `.p12`、一份配置 JSON 到资源目录,名字含接收端;`ios.p12` 与 `iosdev.p12` 内容相同,不重复提交,不使用 VoIP 证书。
+- [x] 保留外部文件路径兼容,增加 classpath InputStream 加载;`application.yml` 默认指向按端命名资源且模式为 `uni`。不更改并行地图配置。
+- [x] JDK 21 运行定向测试、模块构建,核对 JAR 中的九个资源和 Git 暂存范围;文档说明凭据入库风险及真机未验证,形成单独提交。

+ 17 - 9
specs/033-native-push-routing/quickstart.md

@@ -4,29 +4,37 @@
 
 ## 配置与凭据
 
-`ruoyi-admin/src/main/resources/application.yml` 使用下列环境变量。凭据文件应放在受保护的部署目录,运行账号只需读取权限;不要把 JSON、`.p12`、密码或真实 token 提交到仓库、放进镜像公开层或写入日志。
+三端真实凭据已按用户明确要求提交在 `ruoyi-admin/src/main/resources/business-push/`,并随 JAR 打包;每端只保留一份普通 APNs `.p12`,开发/正式只切换网关。任何能读取 Git 历史、JAR 或包含资源的镜像的人都能取得服务账号私钥与证书密码。须限制仓库及构建产物访问,不把密钥内容写入日志或公开分发;如发生泄露,应轮换 Firebase 服务账号与 APNs 证书,并重新构建发布。
+
+| 接收端 | FCM 服务账号 | APNs 普通证书 | APNs topic/密码配置 |
+| --- | --- | --- | --- |
+| 用户 | `user-fcm-service-account.json` | `user-apns.p12` | `user-apns-config.json` |
+| 商家(含夜市及分管账号) | `merchant-fcm-service-account.json` | `merchant-apns.p12` | `merchant-apns-config.json` |
+| 骑手 | `rider-fcm-service-account.json` | `rider-apns.p12` | `rider-apns-config.json` |
+
+APNs 配置 JSON 使用原文件的 `packetName` 作为 topic、`pwd` 解锁同端 `.p12`;三端 Firebase 项目分别对应各自 App,不能共享 `server.json`。不使用 VoIP 证书。`application.yml` 默认指向上述 classpath 文件,仍保留显式文件路径及 APNs 密码/topic 覆盖能力,以兼容原部署方式。
 
 | 设置 | 环境变量 | 默认/要求 |
 | --- | --- | --- |
 | 通道 | `BUSINESS_PUSH_MODE` | `uni`;可选 `native`,重启后生效 |
 | APNs 环境 | `BUSINESS_PUSH_APNS_ENVIRONMENT` | `production`;测试包用 `development` |
-| 用户端 FCM 服务账号文件 | `BUSINESS_PUSH_USER_FCM_CREDENTIALS_PATH` | 原生模式必填 |
-| 商家端 FCM 服务账号文件 | `BUSINESS_PUSH_MERCHANT_FCM_CREDENTIALS_PATH` | 原生模式必填 |
-| 骑手端 FCM 服务账号文件 | `BUSINESS_PUSH_RIDER_FCM_CREDENTIALS_PATH` | 原生模式必填 |
+| 三端 FCM 路径覆盖 | `BUSINESS_PUSH_{USER|MERCHANT|RIDER}_FCM_CREDENTIALS_PATH` | 可选,默认各自的 classpath JSON |
+| 三端 APNs 证书路径覆盖 | `BUSINESS_PUSH_{USER|MERCHANT|RIDER}_APNS_CERTIFICATE_PATH` | 可选,默认各自的 classpath `.p12` |
+| 三端 APNs 配置路径覆盖 | `BUSINESS_PUSH_{USER|MERCHANT|RIDER}_APNS_CONFIG_PATH` | 可选,默认各自的 classpath 配置 JSON |
 
-用户、商家、骑手三端还分别需要 `BUSINESS_PUSH_{USER|MERCHANT|RIDER}_APNS_CERTIFICATE_PATH`、`BUSINESS_PUSH_{USER|MERCHANT|RIDER}_APNS_CERTIFICATE_PASSWORD`、`BUSINESS_PUSH_{USER|MERCHANT|RIDER}_APNS_TOPIC`。路径指向各 App 对应的**普通推送** PKCS#12 (`.p12`) 证书;topic 为对应 App 的 Bundle ID,不是 VoIP topic。三端 Firebase 服务账号也必须分别属于对应 App 的 Firebase 项目。默认 `uni` 模式不需要这些凭据;`native` 模式启动时会校验三端配置并加载文件,缺失、不可读或证书无私钥/已过期时启动失败。
+如需覆盖 APNs 配置 JSON,也可分别设置原有的 `BUSINESS_PUSH_{USER|MERCHANT|RIDER}_APNS_CERTIFICATE_PASSWORD` 与 `BUSINESS_PUSH_{USER|MERCHANT|RIDER}_APNS_TOPIC`;两项都显式提供时不读取配置 JSON。默认 `uni` 模式不读取原生凭据;`native` 模式启动时校验三端配置并加载文件,缺失、不可读或证书无私钥/已过期时启动失败。
 
-`BUSINESS_PUSH_APNS_ENVIRONMENT=development` 使用 APNs sandbox 网关,`production` 使用正式网关。所部署证书必须支持所选环境并与 topic 匹配;这两项对应关系仍需部署者核实,配置校验无法替代 Apple 端的实际发送验证。证书到期前应定期检查并更新三端证书,更新后重启服务,再做各端测试。
+`BUSINESS_PUSH_APNS_ENVIRONMENT=development` 使用 APNs sandbox 网关,`production` 使用正式网关;两种环境读取同端同一份证书。已核对三端 `packetName` 与证书主题相符,但证书能否在两个网关实际投递仍需真机验证。三端证书到期日为 2027-07-30;更新任一资源后必须重新构建并部署 JAR,再做各端测试。
 
 ## 启用前条件
 
 1. App 端分别上报真实的 Android FCM token 或 iOS APNs device token 至 `info_user.deviceToken`,并把 `cidType` 设置为 `android` 或 `ios`;`cid` 只用于 uniPush,不能复制到 `deviceToken`。本次交付不实现 App 端上报。
 2. 确认新手机登录后旧手机会话失效,数据库中账号只保留当前设备 token;原生发送时服务端重新读取该账号的当前 `cidType/deviceToken`。
-3. 在非生产环境配置六套凭据、对应 APNs 环境,设 `BUSINESS_PUSH_MODE=native` 并重启。先检查服务启动,再用三端 Android/iOS 各一台真机触发订单、闪送和支付通知;核对通知栏、点击 payload、消息中心、语言与接收人。iOS 骑手使用 `pushsound.caf`;Android 骑手 FCM 带 `qsRing` 通知渠道标识,须由 App 创建并配置该渠道才能保证自定义铃声,本次不修改 App。门店订单有可用分管账号时只通知分管账号,没有时才回退主账号。
+3. 在非生产环境确认私有 JAR 包含九个按端命名的资源,设置对应 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 端配套和真机验收。使用包含上述资源的受控构建,将 `BUSINESS_PUSH_MODE` 设为 `native`,按实例逐一重启并检查启动及推送错误日志;一条通知只会选择当前实例的一个通道,滚动重启期间不同实例可能短暂处于不同模式。若原生投递异常,改回 `BUSINESS_PUSH_MODE=uni` 并逐一重启;之后的新通知重新使用 `cid`,已失败的通知不会由服务端自动补发。回退时确认 App 仍维护有效的 uniPush CID。
 
-自动化测试只验证路由、请求构造和错误隔离;真实 Firebase 项目、APNs 凭据、设备 token、通知展示与回退操作仍需部署环境验证。
+自动化测试验证三端 classpath 凭据可加载、路由、请求构造和错误隔离;真实设备 token、FCM/APNs 外部投递、通知展示与回退操作仍需部署环境验证。

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

@@ -4,7 +4,7 @@
 
 **Created**: 2026-09-29
 
-**Status**: 服务端已实现并通过定向测试;默认 `uni`,原生通道未启用且真机投递未验证
+**Status**: 服务端路由与随包 classpath 凭据已实现;默认 `uni`,原生通道未启用且真机投递未验证
 
 **Input**: 为现有普通业务通知增加 Android FCM 与 iOS APNs 原生发送能力,以服务端配置切换,重启后生效;交付时继续默认使用 uniPush,不启用原生通道。仅涉及服务端;音视频通话推送不在范围内。
 
@@ -60,7 +60,7 @@
 **Acceptance Scenarios**:
 
 1. **Given** 通道为 `native`、接收方系统为 iOS 且有有效 `deviceToken`,**When** 普通业务通知触发,**Then** 通过该接收端对应的 APNs 普通通知证书向 `deviceToken` 发送一次;不得使用 `voIPToken`。
-2. **Given** 运行环境为开发或生产,**When** 发送 iOS 通知,**Then** 选择相应 APNs 环境及证书,不混用开发与生产凭据。
+2. **Given** 运行环境为开发或生产,**When** 发送 iOS 通知,**Then** 选择相应 APNs 网关并使用该接收端对应的同一份普通推送证书,不混用三端凭据。
 3. **Given** 原有通知带有订单标识、业务类型或点击跳转所需 payload,**When** 改走 APNs,**Then** 这些业务字段仍可供接收端识别。
 
 ---
@@ -69,7 +69,7 @@
 
 运维能通过修改服务端配置并重启,在 uniPush 与原生通道间切换。单次通知只选择一个通道;发送失败不影响订单等业务操作。
 
-**Why this priority**: 切换期间必须避免重复通知、业务流程被第三方推送故障阻断,以及凭据误入仓库。
+**Why this priority**: 切换期间必须避免重复通知、业务流程被第三方推送故障阻断,以及随包凭据被写入日志或向非授权方分发。
 
 **Independent Test**: 用缺失 token、原生服务异常、缺失凭据及配置回退场景验证发送结果、业务结果与消息中心记录。
 
@@ -99,7 +99,8 @@
 - **FR-003a**: 门店订单通知 MUST 保持现有“符合条件的分管账号优先、没有时主账号兜底”规则,不得因切换通道而同时推送两类账号;分管账号的发送地址资格按当前通道判断。
 - **FR-004**: 原生发送 MUST 只使用 `deviceToken`,uniPush MUST 只使用 `cid`;不得相互复制或替代,`voIPToken` 不得用于普通通知。
 - **FR-005**: 用户、商家、骑手三套 Firebase 项目及 APNs 普通通知证书 MUST 独立配置并正确选择;iOS MUST 区分开发与生产环境。
-- **FR-006**: 原生凭据及证书密码 MUST 从服务端受保护的外部配置读取,不得写入源码、规格、日志或打包进公开构件。
+- **FR-006**: 三端 FCM 服务账号、普通 APNs 证书及其 `packetName/pwd` 配置 MUST 分别以 `user`、`merchant`、`rider` 明确命名并放在 `ruoyi-admin/src/main/resources/business-push/`,由 classpath 读取。用户已明确要求这些真实凭据提交 Git 并随 JAR 打包;不得把私钥或密码写入日志、规格正文或推送响应。仓库和构建产物必须按含密钥材料管理。
+- **FR-006a**: 每端 `ios.p12` 与 `iosdev.p12` 经核对文件完全相同,资源中每端只保留一份 APNs 普通推送 `.p12`。`development`/`production` 只切换 APNs 网关,不切换证书文件;三端证书、Firebase 项目和 topic 不得混用。
 - **FR-007**: 原生模式的必要凭据不完整时 MUST 在启动阶段明确失败;默认 uni 模式 MUST 可在无原生凭据时正常启动。
 - **FR-008**: 所有当前在用的普通业务推送路径 MUST 使用一致的通道选择规则,包括适用的单发、批量及直接调用现有底层方法的路径;不改变业务触发时机、文案和 payload 语义。临时测试推送接口不在范围内。
 - **FR-009**: 单次业务通知 MUST 只使用一个外部发送通道;原生发送失败或缺少 token 时 MUST NOT 自动回退到 uniPush,且 MUST NOT 使原业务操作失败。
@@ -112,7 +113,7 @@
 - **接收账号**:已有账号记录,包含接收端类型、`cid`、`cidType`、`deviceToken`;同一账号仅保留当前手机的一组推送标识。
 - **普通业务通知**:已有业务事件生成的标题、正文、payload 与接收方;发送渠道是可替换属性,不改变业务含义。
 - **消息中心记录**:现有 `push_message`,继续按原路径写入,不作为外部发送成功的证明。
-- **通道配置与凭据**:服务端全局 `uni`/`native` 配置,以及三端各自的 FCM/APNs 外部凭据;不属于业务数据库数据。
+- **通道配置与凭据**:服务端全局 `uni`/`native` 配置,以及已提交资源目录的三端 FCM/APNs 凭据;不属于业务数据库数据。
 
 ## Success Criteria *(mandatory)*
 
@@ -128,6 +129,6 @@
 
 - 用户已确认同一账号只允许一台手机登录,且新手机登录后旧手机登录状态失效;本功能依赖现有机制,不重新实现登录互斥。
 - App 端会上报真实 FCM/APNs `deviceToken` 与正确的 `cidType`;App 端变更和真机展示不包含在此次服务端交付范围。
-- 原生能力交付后仍保持 `uni` 为默认值;只有运维部署完整凭据、修改服务端配置并重启,才会启用原生通道。
+- 原生能力交付后仍保持 `uni` 为默认值;凭据虽随包提供,只有运维确认 App 端 token、将服务端配置切为 `native` 并重启,才会启用原生通道。
 - 音视频通话使用其他推送流程,不受本次普通业务通知通道开关控制;`voIPToken` 及 VoIP 证书不用于本功能。
-- 证书与 Firebase 服务账号由部署方安全提供;本仓库只包含不涉及密钥内容的配置模板和操作文档。
+- 证书与 Firebase 服务账号由用户提供并提交到本仓库;仓库访问、构建产物分发和凭据轮换由部署方按含密钥材料管理。默认 `uni` 下不加载这些凭据。

+ 7 - 1
specs/033-native-push-routing/tasks.md

@@ -22,9 +22,15 @@
 
 ## Phase 4: 文档与验证
 
-- [x] T008 在 `specs/033-native-push-routing/quickstart.md` 记录三端外部凭据配置、默认关闭、启用/回退、真机检查;更新 `specs/012-im-user-integration/code-status.md` 与 `docs/order-push-overview.md`。
+- [x] T008 在 `specs/033-native-push-routing/quickstart.md` 记录三端凭据配置、默认关闭、启用/回退、真机检查;更新 `specs/012-im-user-integration/code-status.md` 与 `docs/order-push-overview.md`。
 - [x] T009 使用 JDK 21 运行定向测试和 `ruoyi-admin` 模块构建;核对完整 diff、暂存范围及未执行的外部 FCM/APNs 真机验证,再提交文档收口。
 
 ## Dependencies
 
 T001–T002 → T003/T004 → T005–T006 → T007 → T008–T009。FCM 与 APNs 发送器可以独立评审,但本任务按当前会话顺序执行,不启动 Agent。
+
+## 2026-09-29 追加:随包凭据(默认通道不变)
+
+- [x] T010 更新三端资源命名和配置:每端一份独立 Firebase `server.json`、一份 APNs 普通证书、一份 `packetName/pwd` 配置,名称含 `user`/`merchant`/`rider`;实际文件提交 Git,默认 `uni` 不加载。
+- [x] T011 测试先行支持 classpath 读取 FCM JSON、APNs 配置和 `.p12`,保持原有外部路径兼容;开发/正式只切换 APNs 网关。
+- [x] T012 更新部署说明并运行 JDK 21 定向测试、模块构建和资源打包检查;仅暂存本功能代码、文档与三端凭据后提交,真机外发仍单独验证。