Forráskód Böngészése

feat(push): 添加推送服务厂商全量广播支持及优化推送逻辑

- 更新荣耀推送API域名从 huawei.com 到 honor.com
- 新增 targetType=1 全量广播功能,支持华为、荣耀、小米、OPPO、vivo、APNs等渠道
- 重构推送发送逻辑,增加全量广播和单推分离处理机制
- 添加 APNs 推送客户端集成和 iOS 设备过滤功能
- 增强推送异常处理,捕获渠道推送异常并记录日志
- 优化推送结果统计,区分部分完成和全部失败状态
- 添加华为、荣耀批量推送支持,按设备ID分批处理
- 修复 OPPO 推送认证和消息体构建逻辑
- 优化小米推送参数验证和回调参数构建
- 添加 vivo 推送认证和通知体构建改进
- 优化推送任务用户记录保存逻辑,支持广播推送记录
fcw 8 órája
szülő
commit
760ccee523

+ 279 - 0
alien-store/src/main/java/shop/alien/store/service/channel/ApnsPushClient.java

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