|
|
@@ -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;
|
|
|
+ }
|
|
|
}
|
|
|
}
|