|
@@ -0,0 +1,279 @@
|
|
|
|
|
+package shop.alien.store.service.channel;
|
|
|
|
|
+
|
|
|
|
|
+import com.alibaba.fastjson.JSONObject;
|
|
|
|
|
+import com.eatthepath.pushy.apns.ApnsClient;
|
|
|
|
|
+import com.eatthepath.pushy.apns.ApnsClientBuilder;
|
|
|
|
|
+import com.eatthepath.pushy.apns.PushNotificationResponse;
|
|
|
|
|
+import com.eatthepath.pushy.apns.auth.ApnsSigningKey;
|
|
|
|
|
+import com.eatthepath.pushy.apns.util.SimpleApnsPushNotification;
|
|
|
|
|
+import com.eatthepath.pushy.apns.util.TokenUtil;
|
|
|
|
|
+import lombok.extern.slf4j.Slf4j;
|
|
|
|
|
+import org.apache.commons.lang3.StringUtils;
|
|
|
|
|
+import org.springframework.stereotype.Component;
|
|
|
|
|
+import shop.alien.entity.store.CommonPushTask;
|
|
|
|
|
+import shop.alien.store.dto.CommonPushTargetDto;
|
|
|
|
|
+
|
|
|
|
|
+import java.io.ByteArrayInputStream;
|
|
|
|
|
+import java.io.IOException;
|
|
|
|
|
+import java.io.InputStream;
|
|
|
|
|
+import java.nio.charset.StandardCharsets;
|
|
|
|
|
+import java.security.GeneralSecurityException;
|
|
|
|
|
+import java.util.ArrayList;
|
|
|
|
|
+import java.util.Base64;
|
|
|
|
|
+import java.util.List;
|
|
|
|
|
+import java.util.concurrent.TimeUnit;
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * Apple APNs HTTP/2 推送客户端,支持 .p12 证书与 .p8 Token 两种鉴权。
|
|
|
|
|
+ */
|
|
|
|
|
+@Slf4j
|
|
|
|
|
+@Component
|
|
|
|
|
+public class ApnsPushClient {
|
|
|
|
|
+
|
|
|
|
|
+ private static final int SEND_TIMEOUT_SECONDS = 10;
|
|
|
|
|
+
|
|
|
|
|
+ public boolean send(JSONObject credential, CommonPushTask task, String deviceId) {
|
|
|
|
|
+ if (credential == null || task == null || StringUtils.isBlank(deviceId)) {
|
|
|
|
|
+ return false;
|
|
|
|
|
+ }
|
|
|
|
|
+ String token = normalizeDeviceToken(deviceId);
|
|
|
|
|
+ if (StringUtils.isBlank(token)) {
|
|
|
|
|
+ return false;
|
|
|
|
|
+ }
|
|
|
|
|
+ ApnsClient client = null;
|
|
|
|
|
+ try {
|
|
|
|
|
+ client = buildClient(credential);
|
|
|
|
|
+ if (client == null) {
|
|
|
|
|
+ return false;
|
|
|
|
|
+ }
|
|
|
|
|
+ String topic = resolveTopic(credential);
|
|
|
|
|
+ SimpleApnsPushNotification notification = new SimpleApnsPushNotification(
|
|
|
|
|
+ token, topic, buildPayload(task));
|
|
|
|
|
+ PushNotificationResponse<SimpleApnsPushNotification> response =
|
|
|
|
|
+ client.sendNotification(notification).get(SEND_TIMEOUT_SECONDS, TimeUnit.SECONDS);
|
|
|
|
|
+ if (response.isAccepted()) {
|
|
|
|
|
+ return true;
|
|
|
|
|
+ }
|
|
|
|
|
+ log.error("APNs 推送被拒, taskNo={}, deviceId={}, reason={}",
|
|
|
|
|
+ task.getTaskNo(), maskToken(token), response.getRejectionReason());
|
|
|
|
|
+ return false;
|
|
|
|
|
+ } catch (Exception e) {
|
|
|
|
|
+ log.error("APNs 推送异常, taskNo={}, deviceId={}, err={}",
|
|
|
|
|
+ task.getTaskNo(), maskToken(deviceId), e.getMessage(), e);
|
|
|
|
|
+ return false;
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ closeClient(client);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ public boolean sendBroadcast(JSONObject credential, CommonPushTask task, List<CommonPushTargetDto> targets) {
|
|
|
|
|
+ if (credential == null || task == null || targets == null || targets.isEmpty()) {
|
|
|
|
|
+ return false;
|
|
|
|
|
+ }
|
|
|
|
|
+ ApnsClient client = null;
|
|
|
|
|
+ int successCount = 0;
|
|
|
|
|
+ int totalCount = 0;
|
|
|
|
|
+ try {
|
|
|
|
|
+ client = buildClient(credential);
|
|
|
|
|
+ if (client == null) {
|
|
|
|
|
+ return false;
|
|
|
|
|
+ }
|
|
|
|
|
+ String topic = resolveTopic(credential);
|
|
|
|
|
+ String payload = buildPayload(task);
|
|
|
|
|
+ for (CommonPushTargetDto target : targets) {
|
|
|
|
|
+ if (target == null || StringUtils.isBlank(target.getDeviceId())) {
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+ String token = normalizeDeviceToken(target.getDeviceId());
|
|
|
|
|
+ if (StringUtils.isBlank(token)) {
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+ totalCount++;
|
|
|
|
|
+ try {
|
|
|
|
|
+ SimpleApnsPushNotification notification = new SimpleApnsPushNotification(token, topic, payload);
|
|
|
|
|
+ PushNotificationResponse<SimpleApnsPushNotification> response =
|
|
|
|
|
+ client.sendNotification(notification).get(SEND_TIMEOUT_SECONDS, TimeUnit.SECONDS);
|
|
|
|
|
+ if (response.isAccepted()) {
|
|
|
|
|
+ successCount++;
|
|
|
|
|
+ } else {
|
|
|
|
|
+ log.warn("APNs 全量推送被拒, taskNo={}, deviceId={}, reason={}",
|
|
|
|
|
+ task.getTaskNo(), maskToken(token), response.getRejectionReason());
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (Exception e) {
|
|
|
|
|
+ log.warn("APNs 全量单条失败, taskNo={}, deviceId={}, err={}",
|
|
|
|
|
+ task.getTaskNo(), maskToken(token), e.getMessage());
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ log.info("APNs 全量推送完成, taskNo={}, success={}/{}", task.getTaskNo(), successCount, totalCount);
|
|
|
|
|
+ return successCount > 0;
|
|
|
|
|
+ } catch (Exception e) {
|
|
|
|
|
+ log.error("APNs 全量推送异常, taskNo={}, err={}", task.getTaskNo(), e.getMessage(), e);
|
|
|
|
|
+ return false;
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ closeClient(client);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ public boolean validateCredential(JSONObject credential) {
|
|
|
|
|
+ if (credential == null) {
|
|
|
|
|
+ return false;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (StringUtils.isBlank(resolveTopic(credential))) {
|
|
|
|
|
+ return false;
|
|
|
|
|
+ }
|
|
|
|
|
+ return hasP12Credential(credential) || hasTokenCredential(credential);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private ApnsClient buildClient(JSONObject credential) throws IOException, GeneralSecurityException {
|
|
|
|
|
+ String apnsHost = isProduction(credential)
|
|
|
|
|
+ ? ApnsClientBuilder.PRODUCTION_APNS_HOST
|
|
|
|
|
+ : ApnsClientBuilder.DEVELOPMENT_APNS_HOST;
|
|
|
|
|
+ if (hasP12Credential(credential)) {
|
|
|
|
|
+ byte[] p12Bytes = decodeBase64(credential.getString("p12Base64"));
|
|
|
|
|
+ if (p12Bytes == null) {
|
|
|
|
|
+ log.error("APNs p12Base64 解码失败");
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+ String password = StringUtils.defaultString(credential.getString("p12Password"));
|
|
|
|
|
+ try (InputStream inputStream = new ByteArrayInputStream(p12Bytes)) {
|
|
|
|
|
+ return new ApnsClientBuilder()
|
|
|
|
|
+ .setApnsServer(apnsHost)
|
|
|
|
|
+ .setClientCredentials(inputStream, password)
|
|
|
|
|
+ .build();
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ if (hasTokenCredential(credential)) {
|
|
|
|
|
+ ApnsSigningKey signingKey = ApnsSigningKey.loadFromInputStream(
|
|
|
|
|
+ new ByteArrayInputStream(credential.getString("privateKey").getBytes(StandardCharsets.UTF_8)),
|
|
|
|
|
+ credential.getString("teamId"),
|
|
|
|
|
+ credential.getString("keyId"));
|
|
|
|
|
+ return new ApnsClientBuilder()
|
|
|
|
|
+ .setApnsServer(apnsHost)
|
|
|
|
|
+ .setSigningKey(signingKey)
|
|
|
|
|
+ .build();
|
|
|
|
|
+ }
|
|
|
|
|
+ log.error("APNs 凭证不完整,需配置 p12Base64+p12Password(证书方式)或 teamId+keyId+privateKey(Token 方式)");
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private boolean hasP12Credential(JSONObject credential) {
|
|
|
|
|
+ return StringUtils.isNotBlank(credential.getString("p12Base64"));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private boolean hasTokenCredential(JSONObject credential) {
|
|
|
|
|
+ return StringUtils.isNoneBlank(
|
|
|
|
|
+ credential.getString("teamId"),
|
|
|
|
|
+ credential.getString("keyId"),
|
|
|
|
|
+ credential.getString("privateKey"));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private String resolveTopic(JSONObject credential) {
|
|
|
|
|
+ return StringUtils.trimToNull(credential.getString("bundleId"));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private boolean isProduction(JSONObject credential) {
|
|
|
|
|
+ if (credential.containsKey("production")) {
|
|
|
|
|
+ return credential.getBooleanValue("production");
|
|
|
|
|
+ }
|
|
|
|
|
+ return true;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private String buildPayload(CommonPushTask task) {
|
|
|
|
|
+ JSONObject alert = new JSONObject();
|
|
|
|
|
+ alert.put("title", task.getTitle());
|
|
|
|
|
+ alert.put("body", task.getContent());
|
|
|
|
|
+
|
|
|
|
|
+ JSONObject aps = new JSONObject();
|
|
|
|
|
+ aps.put("alert", alert);
|
|
|
|
|
+ aps.put("sound", "default");
|
|
|
|
|
+
|
|
|
|
|
+ JSONObject root = new JSONObject();
|
|
|
|
|
+ root.put("aps", aps);
|
|
|
|
|
+ if (StringUtils.isNotBlank(task.getJumpUrl())) {
|
|
|
|
|
+ root.put("jumpUrl", task.getJumpUrl());
|
|
|
|
|
+ }
|
|
|
|
|
+ if (task.getId() != null) {
|
|
|
|
|
+ root.put("p", task.getId());
|
|
|
|
|
+ } else if (StringUtils.isNotBlank(task.getTaskNo())) {
|
|
|
|
|
+ root.put("t", task.getTaskNo());
|
|
|
|
|
+ }
|
|
|
|
|
+ return root.toJSONString();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private String normalizeDeviceToken(String deviceId) {
|
|
|
|
|
+ if (StringUtils.isBlank(deviceId)) {
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+ try {
|
|
|
|
|
+ return TokenUtil.sanitizeTokenString(deviceId.trim());
|
|
|
|
|
+ } catch (Exception e) {
|
|
|
|
|
+ log.warn("APNs deviceToken 非法: {}", maskToken(deviceId));
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private byte[] decodeBase64(String value) {
|
|
|
|
|
+ if (StringUtils.isBlank(value)) {
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+ try {
|
|
|
|
|
+ return Base64.getDecoder().decode(value.replaceAll("\\s", ""));
|
|
|
|
|
+ } catch (IllegalArgumentException e) {
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private void closeClient(ApnsClient client) {
|
|
|
|
|
+ if (client == null) {
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ try {
|
|
|
|
|
+ client.close().get(5, TimeUnit.SECONDS);
|
|
|
|
|
+ } catch (Exception e) {
|
|
|
|
|
+ log.warn("关闭 APNs 客户端异常: {}", e.getMessage());
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private String maskToken(String token) {
|
|
|
|
|
+ if (StringUtils.isBlank(token) || token.length() <= 8) {
|
|
|
|
|
+ return "****";
|
|
|
|
|
+ }
|
|
|
|
|
+ return token.substring(0, 4) + "****" + token.substring(token.length() - 4);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ public static List<CommonPushTargetDto> filterApnsTargets(List<CommonPushTargetDto> channelTargets,
|
|
|
|
|
+ List<CommonPushTargetDto> allTargets) {
|
|
|
|
|
+ if (channelTargets != null && !channelTargets.isEmpty()) {
|
|
|
|
|
+ return channelTargets;
|
|
|
|
|
+ }
|
|
|
|
|
+ List<CommonPushTargetDto> result = new ArrayList<>();
|
|
|
|
|
+ if (allTargets == null) {
|
|
|
|
|
+ return result;
|
|
|
|
|
+ }
|
|
|
|
|
+ for (CommonPushTargetDto target : allTargets) {
|
|
|
|
|
+ if (target == null) {
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (isIosPlatform(target.getPlatform()) || isLikelyApnsToken(target.getDeviceId())) {
|
|
|
|
|
+ result.add(target);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return result;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private static boolean isIosPlatform(String platform) {
|
|
|
|
|
+ if (StringUtils.isBlank(platform)) {
|
|
|
|
|
+ return false;
|
|
|
|
|
+ }
|
|
|
|
|
+ String normalized = StringUtils.lowerCase(platform.trim());
|
|
|
|
|
+ return normalized.contains("ios") || "iphone".equals(normalized);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ static boolean isLikelyApnsToken(String deviceId) {
|
|
|
|
|
+ if (StringUtils.isBlank(deviceId)) {
|
|
|
|
|
+ return false;
|
|
|
|
|
+ }
|
|
|
|
|
+ String normalized = deviceId.trim().replace(" ", "").replace("<", "").replace(">", "");
|
|
|
|
|
+ return normalized.matches("^[a-fA-F0-9]+$") && normalized.length() >= 64;
|
|
|
|
|
+ }
|
|
|
|
|
+}
|