Ver código fonte

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

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

+ 7 - 0
alien-store/pom.xml

@@ -347,6 +347,13 @@
             <version>1.1.6</version>
         </dependency>
 
+        <!-- Apple APNs HTTP/2 -->
+        <dependency>
+            <groupId>com.eatthepath</groupId>
+            <artifactId>pushy</artifactId>
+            <version>0.13.11</version>
+        </dependency>
+
     </dependencies>
 
     <build>

+ 3 - 0
alien-store/src/main/java/shop/alien/store/dto/CommonPushTargetDto.java

@@ -15,4 +15,7 @@ public class CommonPushTargetDto {
 
     /** ios / android */
     private String platform;
+
+    /** 是否厂商广播推送(如 OPPO target_type=1 全量) */
+    private Boolean broadcast;
 }

+ 528 - 84
alien-store/src/main/java/shop/alien/store/service/channel/CommonPushVendorHttpClient.java

@@ -23,8 +23,13 @@ import java.net.URLEncoder;
 import java.nio.charset.StandardCharsets;
 import java.security.MessageDigest;
 import java.security.NoSuchAlgorithmException;
+import java.util.ArrayList;
+import java.util.Collections;
 import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
 import java.util.Map;
+import java.util.Set;
 
 /**
  * 各厂商推送 HTTP 客户端,凭证从配置表 credential_json 读取。
@@ -38,10 +43,191 @@ public class CommonPushVendorHttpClient {
     /** OPPO call_back_parameter 最大 100 字符 */
     private static final int OPPO_CALLBACK_PARAM_MAX_LEN = 100;
 
+    private static final int HUAWEI_BATCH_TOKEN_LIMIT = 1000;
+    private static final String HUAWEI_PUSH_BASE_URL = "https://push-api.cloud.huawei.com";
+    private static final String HONOR_PUSH_BASE_URL = "https://push-api.cloud.honor.com";
+    private static final String HONOR_AUTH_URL =
+            "https://auth.honor.com/auth/realms/developer/protocol/openid-connect/token";
+
     private final CommonPushProperties commonPushProperties;
+    private final ApnsPushClient apnsPushClient;
 
     private static final RestTemplate restTemplate = new RestTemplate();
 
+    /**
+     * OPPO 全量广播推送(target_type=1),用于 targetType=1 全量场景。
+     */
+    public boolean sendOppoBroadcast(CommonPushChannelConfig config, CommonPushTask task) {
+        if (config == null || task == null) {
+            return false;
+        }
+        JSONObject credential = parseCredential(config.getCredentialJson());
+        if (credential == null) {
+            return false;
+        }
+        try {
+            String authToken = obtainOppoAuthToken(credential);
+            if (StringUtils.isBlank(authToken)) {
+                return false;
+            }
+            String messageId = saveOppoMessageContent(authToken, task, null);
+            if (StringUtils.isBlank(messageId)) {
+                return false;
+            }
+            return broadcastOppoMessage(authToken, messageId);
+        } catch (Exception e) {
+            log.error("OPPO 广播推送失败, taskNo={}, err={}", task.getTaskNo(), e.getMessage(), e);
+            return false;
+        }
+    }
+
+    /**
+     * 小米全量广播推送,用于 targetType=1 全量场景。
+     */
+    public boolean sendXiaomiBroadcast(CommonPushChannelConfig config, CommonPushTask task) {
+        if (config == null || task == null) {
+            return false;
+        }
+        JSONObject credential = parseCredential(config.getCredentialJson());
+        if (credential == null) {
+            return false;
+        }
+        String appSecret = credential.getString("appSecret");
+        if (StringUtils.isBlank(appSecret)) {
+            return false;
+        }
+        if (!validateXiaomiCredential(credential, task.getTaskNo())) {
+            return false;
+        }
+        try {
+            MultiValueMap<String, String> form = buildXiaomiMessageForm(credential, task, null);
+            Map<String, String> headers = new HashMap<>();
+            headers.put("Authorization", "key=" + appSecret);
+            String resp = postForm("https://api.xmpush.xiaomi.com/v3/message/all", form, headers);
+            return isXiaomiSendSuccess(resp, task.getTaskNo(), "全量广播");
+        } catch (Exception e) {
+            log.error("小米广播推送失败, taskNo={}, err={}", task.getTaskNo(), e.getMessage(), e);
+            return false;
+        }
+    }
+
+    /**
+     * vivo 全量广播推送,用于 targetType=1 全量场景。
+     */
+    public boolean sendVivoBroadcast(CommonPushChannelConfig config, CommonPushTask task) {
+        if (config == null || task == null) {
+            return false;
+        }
+        JSONObject credential = parseCredential(config.getCredentialJson());
+        if (credential == null) {
+            return false;
+        }
+        try {
+            String authToken = obtainVivoAuthToken(credential);
+            if (StringUtils.isBlank(authToken)) {
+                return false;
+            }
+            JSONObject sendBody = buildVivoNotificationBody(credential, task, null);
+            Map<String, String> headers = new HashMap<>();
+            headers.put("authToken", authToken);
+            String sendResp = postJson("https://api-push.vivo.com.cn/message/all", sendBody.toJSONString(), headers);
+            return isVivoSendSuccess(sendResp, task.getTaskNo(), "全量广播");
+        } catch (Exception e) {
+            log.error("vivo 广播推送失败, taskNo={}, err={}", task.getTaskNo(), e.getMessage(), e);
+            return false;
+        }
+    }
+
+    /**
+     * 华为全量推送:官方无 /all 接口,按 token 批量下发(每批最多 1000 个)。
+     */
+    public boolean sendHuaweiBroadcast(CommonPushChannelConfig config, CommonPushTask task,
+                                       List<CommonPushTargetDto> targets) {
+        if (config == null || task == null) {
+            return false;
+        }
+        JSONObject credential = parseCredential(config.getCredentialJson());
+        if (credential == null) {
+            return false;
+        }
+        List<String> deviceIds = extractDeviceIds(targets);
+        if (deviceIds.isEmpty()) {
+            log.warn("华为全量推送无可用 deviceId, taskNo={}", task.getTaskNo());
+            return false;
+        }
+        try {
+            int successBatches = 0;
+            int totalBatches = 0;
+            for (List<String> batch : partitionList(deviceIds, HUAWEI_BATCH_TOKEN_LIMIT)) {
+                totalBatches++;
+                if (sendHuaweiLike(credential, task, batch)) {
+                    successBatches++;
+                }
+            }
+            log.info("华为全量批量推送完成, taskNo={}, devices={}, batches={}/{}",
+                    task.getTaskNo(), deviceIds.size(), successBatches, totalBatches);
+            return successBatches > 0;
+        } catch (Exception e) {
+            log.error("华为全量推送失败, taskNo={}, err={}", task.getTaskNo(), e.getMessage(), e);
+            return false;
+        }
+    }
+
+    /**
+     * 荣耀全量推送:官方无 /all 接口,按 token 批量下发(每批最多 1000 个)。
+     */
+    public boolean sendHonorBroadcast(CommonPushChannelConfig config, CommonPushTask task,
+                                      List<CommonPushTargetDto> targets) {
+        if (config == null || task == null) {
+            return false;
+        }
+        JSONObject credential = parseCredential(config.getCredentialJson());
+        if (credential == null) {
+            return false;
+        }
+        List<String> deviceIds = extractDeviceIds(targets);
+        if (deviceIds.isEmpty()) {
+            log.warn("荣耀全量推送无可用 deviceId, taskNo={}", task.getTaskNo());
+            return false;
+        }
+        try {
+            int successBatches = 0;
+            int totalBatches = 0;
+            for (List<String> batch : partitionList(deviceIds, HUAWEI_BATCH_TOKEN_LIMIT)) {
+                totalBatches++;
+                if (sendHonorLike(credential, task, batch)) {
+                    successBatches++;
+                }
+            }
+            log.info("荣耀全量批量推送完成, taskNo={}, devices={}, batches={}/{}",
+                    task.getTaskNo(), deviceIds.size(), successBatches, totalBatches);
+            return successBatches > 0;
+        } catch (Exception e) {
+            log.error("荣耀全量推送失败, taskNo={}, err={}", task.getTaskNo(), e.getMessage(), e);
+            return false;
+        }
+    }
+
+    /**
+     * APNs 全量推送:按 iOS deviceToken 逐条下发(Apple 无 /all 接口)。
+     */
+    public boolean sendApnsBroadcast(CommonPushChannelConfig config, CommonPushTask task,
+                                     List<CommonPushTargetDto> targets) {
+        if (config == null || task == null) {
+            return false;
+        }
+        JSONObject credential = parseCredential(config.getCredentialJson());
+        if (credential == null || !apnsPushClient.validateCredential(credential)) {
+            return false;
+        }
+        List<CommonPushTargetDto> apnsTargets = ApnsPushClient.filterApnsTargets(targets, targets);
+        if (apnsTargets.isEmpty()) {
+            log.warn("APNs 全量推送无 iOS 设备 token, taskNo={}", task.getTaskNo());
+            return false;
+        }
+        return apnsPushClient.sendBroadcast(credential, task, apnsTargets);
+    }
+
     public boolean send(CommonPushChannelConfig config, CommonPushTask task, CommonPushTargetDto target) {
         if (config == null || task == null || target == null || StringUtils.isBlank(target.getDeviceId())) {
             return false;
@@ -80,11 +266,27 @@ public class CommonPushVendorHttpClient {
 
     private boolean sendVivo(JSONObject credential, CommonPushTask task, CommonPushTargetDto target) {
         String deviceId = target.getDeviceId();
+        if (StringUtils.isBlank(deviceId)) {
+            return false;
+        }
+        String authToken = obtainVivoAuthToken(credential);
+        if (StringUtils.isBlank(authToken)) {
+            return false;
+        }
+        JSONObject sendBody = buildVivoNotificationBody(credential, task, target);
+        sendBody.put("regId", deviceId);
+        Map<String, String> headers = new HashMap<>();
+        headers.put("authToken", authToken);
+        String sendResp = postJson("https://api-push.vivo.com.cn/message/send", sendBody.toJSONString(), headers);
+        return isVivoSendSuccess(sendResp, task.getTaskNo(), "单推");
+    }
+
+    private String obtainVivoAuthToken(JSONObject credential) {
         String appId = credential.getString("appId");
         String appKey = credential.getString("appKey");
         String appSecret = credential.getString("appSecret");
         if (StringUtils.isAnyBlank(appId, appKey, appSecret)) {
-            return false;
+            return null;
         }
         long timestamp = System.currentTimeMillis();
         String sign = md5Hex(appId + appKey + timestamp + appSecret);
@@ -96,99 +298,188 @@ public class CommonPushVendorHttpClient {
         String authResp = postJson("https://api-push.vivo.com.cn/message/auth", authBody.toJSONString(), null);
         JSONObject authJson = JSONObject.parseObject(authResp);
         if (authJson == null || authJson.getIntValue("result") != 0) {
-            return false;
+            log.warn("vivo 鉴权失败: {}", authResp);
+            return null;
         }
-        String authToken = authJson.getString("authToken");
+        return authJson.getString("authToken");
+    }
 
+    private JSONObject buildVivoNotificationBody(JSONObject credential, CommonPushTask task,
+                                                    CommonPushTargetDto target) {
         JSONObject sendBody = new JSONObject();
-        sendBody.put("appId", appId);
-        sendBody.put("regId", deviceId);
+        sendBody.put("appId", credential.getString("appId"));
         sendBody.put("notifyType", 1);
         sendBody.put("title", task.getTitle());
         sendBody.put("content", task.getContent());
-        sendBody.put("skipType", 1);
         if (StringUtils.isNotBlank(task.getJumpUrl())) {
+            sendBody.put("skipType", 2);
             sendBody.put("skipContent", task.getJumpUrl());
+        } else {
+            sendBody.put("skipType", 1);
         }
-        Map<String, String> headers = new HashMap<>();
-        headers.put("authToken", authToken);
-        String sendResp = postJson("https://api-push.vivo.com.cn/message/send", sendBody.toJSONString(), headers);
-        JSONObject sendJson = JSONObject.parseObject(sendResp);
-        return sendJson != null && sendJson.getIntValue("result") == 0;
+        sendBody.put("requestId", buildVivoRequestId(task));
+        JSONObject extra = new JSONObject();
+        extra.put("callback", resolveCallbackUrl());
+        extra.put("callback.param", buildVivoCallbackParameter(task, target));
+        sendBody.put("extra", extra);
+        return sendBody;
+    }
+
+    private String buildVivoRequestId(CommonPushTask task) {
+        String base = StringUtils.defaultIfBlank(task.getTaskNo(), "TASK");
+        String requestId = base + "_" + System.currentTimeMillis();
+        return requestId.length() > 64 ? requestId.substring(0, 64) : requestId;
+    }
+
+    private String buildVivoCallbackParameter(CommonPushTask task, CommonPushTargetDto target) {
+        JSONObject param = new JSONObject(true);
+        if (task.getId() != null) {
+            param.put("p", task.getId());
+        } else if (StringUtils.isNotBlank(task.getTaskNo())) {
+            param.put("t", task.getTaskNo().trim());
+        }
+        if (target != null && target.getUserId() != null) {
+            param.put("u", target.getUserId());
+        }
+        param.put("c", "vivo");
+        return param.toJSONString();
+    }
+
+    private boolean isVivoSendSuccess(String resp, String taskNo, String scene) {
+        log.info("vivo{}响应: {}", scene, resp);
+        JSONObject json = JSONObject.parseObject(resp);
+        if (json != null && json.getIntValue("result") == 0) {
+            return true;
+        }
+        if (json != null) {
+            log.error("vivo{}失败, taskNo={}, result={}, desc={}",
+                    scene, taskNo, json.getIntValue("result"), json.getString("desc"));
+        }
+        return false;
     }
 
     private boolean sendXiaomi(JSONObject credential, CommonPushTask task, CommonPushTargetDto target) {
         String deviceId = target.getDeviceId();
         String appSecret = credential.getString("appSecret");
-        if (StringUtils.isBlank(appSecret)) {
+        if (StringUtils.isAnyBlank(appSecret, deviceId)) {
             return false;
         }
-        MultiValueMap<String, String> form = new LinkedMultiValueMap<>();
+        if (!validateXiaomiCredential(credential, task.getTaskNo())) {
+            return false;
+        }
+        MultiValueMap<String, String> form = buildXiaomiMessageForm(credential, task, target);
         form.add("registration_id", deviceId);
-        form.add("title", task.getTitle());
-        form.add("description", task.getContent());
-        form.add("payload", buildPayload(task));
         Map<String, String> headers = new HashMap<>();
         headers.put("Authorization", "key=" + appSecret);
         String resp = postForm("https://api.xmpush.xiaomi.com/v3/message/regid", form, headers);
+        return isXiaomiSendSuccess(resp, task.getTaskNo(), "单推");
+    }
+
+    private boolean validateXiaomiCredential(JSONObject credential, String taskNo) {
+        String channelId = resolveXiaomiChannelId(credential);
+        if (StringUtils.isBlank(channelId)) {
+            log.error("小米推送缺少 channelId, taskNo={}。小米运营平台已创建 channel 后,还需将 channel_id 写入本系统 common_push_channel_config.credentialJson.channelId",
+                    taskNo);
+            return false;
+        }
+        if (StringUtils.isBlank(credential.getString("packageName"))) {
+            log.warn("小米推送未配置 packageName, taskNo={}, channelId={},请确认与小米应用信息中的包名一致",
+                    taskNo, channelId);
+        }
+        return true;
+    }
+
+    private String resolveXiaomiChannelId(JSONObject credential) {
+        if (credential == null) {
+            return null;
+        }
+        return StringUtils.defaultIfBlank(
+                StringUtils.trimToNull(credential.getString("channelId")),
+                StringUtils.trimToNull(credential.getString("channel_id")));
+    }
+
+    private boolean isXiaomiSendSuccess(String resp, String taskNo, String scene) {
+        log.info("小米{}响应: {}", scene, resp);
         JSONObject json = JSONObject.parseObject(resp);
-        return json != null && "ok".equalsIgnoreCase(json.getString("result"));
+        if (json != null && "ok".equalsIgnoreCase(json.getString("result"))) {
+            return true;
+        }
+        if (json != null) {
+            log.error("小米{}失败, taskNo={}, code={}, reason={}, description={}",
+                    scene, taskNo, json.getString("code"), json.getString("reason"), json.getString("description"));
+        }
+        return false;
+    }
+
+    private MultiValueMap<String, String> buildXiaomiMessageForm(JSONObject credential, CommonPushTask task,
+                                                                   CommonPushTargetDto target) {
+        MultiValueMap<String, String> form = new LinkedMultiValueMap<>();
+        form.add("title", task.getTitle());
+        form.add("description", task.getContent());
+        form.add("payload", buildPayload(task));
+        String packageName = credential.getString("packageName");
+        if (StringUtils.isNotBlank(packageName)) {
+            form.add("restricted_package_name", packageName.trim());
+        } else {
+            log.warn("小米推送未配置 packageName,多包名应用可能返回 22022 或 27001");
+        }
+        appendXiaomiExtraParams(form, credential, task, target);
+        return form;
+    }
+
+    /**
+     * 小米 HTTP API 要求以 extra.xxx 形式传参(非 extra JSON 字符串)。
+     */
+    private void appendXiaomiExtraParams(MultiValueMap<String, String> form, JSONObject credential,
+                                           CommonPushTask task, CommonPushTargetDto target) {
+        String channelId = resolveXiaomiChannelId(credential);
+        if (StringUtils.isNotBlank(channelId)) {
+            form.add("extra.channel_id", channelId);
+        }
+        form.add("extra.callback", resolveCallbackUrl());
+        form.add("extra.callback.param", buildXiaomiCallbackParameter(task, target));
+        if (StringUtils.isNotBlank(task.getJumpUrl())) {
+            form.add("extra.notify_effect", "3");
+            form.add("extra.web_uri", task.getJumpUrl());
+        }
+    }
+
+    private String buildXiaomiCallbackParameter(CommonPushTask task, CommonPushTargetDto target) {
+        JSONObject param = new JSONObject(true);
+        if (task.getId() != null) {
+            param.put("p", task.getId());
+        } else if (StringUtils.isNotBlank(task.getTaskNo())) {
+            param.put("t", task.getTaskNo().trim());
+        }
+        if (target != null && target.getUserId() != null) {
+            param.put("u", target.getUserId());
+        }
+        param.put("c", "xiaomi");
+        return param.toJSONString();
     }
 
     /**
      * OPPO 厂商直连:使用 RegistrationID(非 UniPush CID),并设置送达回执回调。
      */
-    private boolean sendOppo(JSONObject credential, CommonPushTask task, CommonPushTargetDto target) throws NoSuchAlgorithmException {
+    private boolean sendOppo(JSONObject credential, CommonPushTask task, CommonPushTargetDto target) {
         String deviceId = target.getDeviceId();
-        String appKey = credential.getString("appKey");
-        String masterSecret = credential.getString("appSecret");
-        if (StringUtils.isAnyBlank(appKey, masterSecret)) {
+        String authToken = obtainOppoAuthToken(credential);
+        if (StringUtils.isBlank(authToken)) {
             return false;
         }
-        long timestamp = System.currentTimeMillis();
-//        String sign = md5Hex(appKey + timestamp + masterSecret);
-        String sign = cn.hutool.crypto.SecureUtil.sha256(appKey + timestamp + masterSecret);
-        // 正确的签名生成
-//        String signStr = appKey + timestamp + masterSecret;
-//        MessageDigest digest = MessageDigest.getInstance("SHA-256");
-//        byte[] hash = digest.digest(signStr.getBytes(StandardCharsets.UTF_8));
-//        // 转小写十六进制字符串
-//        String sign = Hex.encodeHexString(hash);
-        MultiValueMap<String, String> authForm = new LinkedMultiValueMap<>();
-        authForm.add("app_key", appKey);
-        authForm.add("timestamp", String.valueOf(timestamp));
-        authForm.add("sign", sign);
-        String authResp = postForm("https://api.push.oppomobile.com/server/v1/auth", authForm, null);
-        JSONObject authJson = JSONObject.parseObject(authResp);
-        if (authJson == null || authJson.getJSONObject("data") == null) {
-            return false;
-        }
-        String authToken = authJson.getJSONObject("data").getString("auth_token");
 
-        JSONObject notification = new JSONObject();
-        notification.put("title", task.getTitle());
-        notification.put("content", task.getContent());
-        notification.put("call_back_url", resolveCallbackUrl());
-        notification.put("call_back_parameter", buildCallbackParameter(task, target, "oppo"));
-        if (StringUtils.isNotBlank(task.getJumpUrl())) {
-            notification.put("click_action_url", task.getJumpUrl());
-        }
-// 2. 构造最外层message完整JSON(官方要求顶层结构)
+        JSONObject notification = buildOppoNotification(task, target);
         JSONObject messageRoot = new JSONObject();
-//        messageRoot.put("auth_token", authToken);
-        messageRoot.put("target_type", 2); // 固定2=registration_id单设备推送
+        messageRoot.put("target_type", 2);
         messageRoot.put("target_value", deviceId);
         messageRoot.put("notification", notification);
 
-        // 3. 表单只放一个key:message,值为完整JSON字符串
         MultiValueMap<String, String> sendForm = new LinkedMultiValueMap<>();
-        sendForm.add("auth_token", authToken); // 鉴权令牌放在表单顶层
+        sendForm.add("auth_token", authToken);
         sendForm.add("message", messageRoot.toJSONString());
 
         HttpHeaders headers = new HttpHeaders();
-//        // 显式指定UTF-8编码,避免中文乱码导致JSON解析失败
         headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
-////        headers.setContentType(MediaType.parseMediaType("application/x-www-form-urlencoded;charset=UTF-8"));
         HttpEntity<MultiValueMap<String, String>> entity = new HttpEntity<>(sendForm, headers);
         try {
             ResponseEntity<String> response = restTemplate.postForEntity(
@@ -197,17 +488,82 @@ public class CommonPushVendorHttpClient {
                     String.class
             );
             String body = response.getBody();
-            log.info("OPPO 推响应: {}", body);
+            log.info("OPPO 推响应: {}", body);
             JSONObject resp = JSONObject.parseObject(body);
             return resp != null && resp.getIntValue("code") == 0;
         } catch (Exception e) {
-            log.error("OPPO 推请求异常: {}", e.getMessage(), e);
+            log.error("OPPO 推请求异常: {}", e.getMessage(), e);
             return false;
         }
+    }
+
+    private String obtainOppoAuthToken(JSONObject credential) {
+        String appKey = credential.getString("appKey");
+        String masterSecret = credential.getString("appSecret");
+        if (StringUtils.isAnyBlank(appKey, masterSecret)) {
+            return null;
+        }
+        long timestamp = System.currentTimeMillis();
+        String sign = cn.hutool.crypto.SecureUtil.sha256(appKey + timestamp + masterSecret);
+        MultiValueMap<String, String> authForm = new LinkedMultiValueMap<>();
+        authForm.add("app_key", appKey);
+        authForm.add("timestamp", String.valueOf(timestamp));
+        authForm.add("sign", sign);
+        String authResp = postForm("https://api.push.oppomobile.com/server/v1/auth", authForm, null);
+        JSONObject authJson = JSONObject.parseObject(authResp);
+        if (authJson == null || authJson.getJSONObject("data") == null) {
+            log.warn("OPPO 鉴权失败: {}", authResp);
+            return null;
+        }
+        return authJson.getJSONObject("data").getString("auth_token");
+    }
 
-//        String sendResp = postForm("https://api.push.oppomobile.com/server/v1/message/notification/unicast", sendForm, null);
-//        JSONObject sendJson = JSONObject.parseObject(sendResp);
-//        return sendJson != null && sendJson.getIntValue("code") == 0;
+    private JSONObject buildOppoNotification(CommonPushTask task, CommonPushTargetDto target) {
+        JSONObject notification = new JSONObject();
+        notification.put("title", task.getTitle());
+        notification.put("content", task.getContent());
+        notification.put("call_back_url", resolveCallbackUrl());
+        notification.put("call_back_parameter", buildCallbackParameter(task, target));
+        if (StringUtils.isNotBlank(task.getJumpUrl())) {
+            notification.put("click_action_url", task.getJumpUrl());
+        }
+        return notification;
+    }
+
+    /**
+     * 保存 OPPO 广播消息体,返回 message_id。
+     */
+    private String saveOppoMessageContent(String authToken, CommonPushTask task, CommonPushTargetDto target) {
+        MultiValueMap<String, String> form = new LinkedMultiValueMap<>();
+        form.add("auth_token", authToken);
+        form.add("title", task.getTitle());
+        form.add("content", task.getContent());
+        form.add("call_back_url", resolveCallbackUrl());
+        form.add("call_back_parameter", buildCallbackParameter(task, target));
+        if (StringUtils.isNotBlank(task.getJumpUrl())) {
+            form.add("click_action_url", task.getJumpUrl());
+        }
+        String resp = postForm("https://api.push.oppomobile.com/server/v1/message/notification/save_message_content", form, null);
+        log.info("OPPO 保存广播消息体响应: {}", resp);
+        JSONObject json = JSONObject.parseObject(resp);
+        if (json == null || json.getJSONObject("data") == null) {
+            return null;
+        }
+        return json.getJSONObject("data").getString("message_id");
+    }
+
+    /**
+     * OPPO 广播下发,target_type=1 表示全量用户。
+     */
+    private boolean broadcastOppoMessage(String authToken, String messageId) {
+        MultiValueMap<String, String> form = new LinkedMultiValueMap<>();
+        form.add("auth_token", authToken);
+        form.add("message_id", messageId);
+        form.add("target_type", "1");
+        String resp = postForm("https://api.push.oppomobile.com/server/v1/message/notification/broadcast", form, null);
+        log.info("OPPO 广播推送响应: {}", resp);
+        JSONObject json = JSONObject.parseObject(resp);
+        return json != null && json.getIntValue("code") == 0;
     }
 
     private String resolveCallbackUrl() {
@@ -218,14 +574,14 @@ public class CommonPushVendorHttpClient {
     /**
      * OPPO call_back_parameter 限 100 字符,仅传 pushTaskId + userId 短键,deviceId 由回执 registrationIds 带回。
      */
-    private String buildCallbackParameter(CommonPushTask task, CommonPushTargetDto target, String channelCode) {
+    private String buildCallbackParameter(CommonPushTask task, CommonPushTargetDto target) {
         JSONObject param = new JSONObject(true);
         if (task.getId() != null) {
             param.put("p", task.getId());
         } else if (StringUtils.isNotBlank(task.getTaskNo())) {
             param.put("t", task.getTaskNo().trim());
         }
-        if (target.getUserId() != null) {
+        if (target != null && target.getUserId() != null) {
             param.put("u", target.getUserId());
         }
         String json = param.toJSONString();
@@ -335,30 +691,102 @@ public class CommonPushVendorHttpClient {
     }
 
     private boolean sendHuawei(JSONObject credential, CommonPushTask task, String deviceId) {
-        return sendHuaweiLike(credential, task, deviceId, "https://push-api.cloud.huawei.com");
+        return sendHuaweiLike(credential, task, Collections.singletonList(deviceId));
     }
 
     private boolean sendHonor(JSONObject credential, CommonPushTask task, String deviceId) {
-        return sendHuaweiLike(credential, task, deviceId, "https://push-api.cloud.huawei.com");
+        return sendHonorLike(credential, task, Collections.singletonList(deviceId));
+    }
+
+    private boolean sendHuaweiLike(JSONObject credential, CommonPushTask task, List<String> deviceIds) {
+        if (deviceIds == null || deviceIds.isEmpty()) {
+            return false;
+        }
+        String appId = credential.getString("appId");
+        String appSecret = credential.getString("appSecret");
+        if (StringUtils.isAnyBlank(appId, appSecret)) {
+            return false;
+        }
+        String accessToken = obtainHuaweiAccessToken(credential);
+        if (StringUtils.isBlank(accessToken)) {
+            return false;
+        }
+
+        JSONObject body = buildHuaweiMessageBody(task, deviceIds);
+        Map<String, String> headers = new HashMap<>();
+        headers.put("Authorization", "Bearer " + accessToken);
+        String sendResp = postJson(HUAWEI_PUSH_BASE_URL + "/v1/" + appId + "/messages:send",
+                body.toJSONString(), headers);
+        JSONObject sendJson = JSONObject.parseObject(sendResp);
+        boolean ok = sendJson != null && StringUtils.isNotBlank(sendJson.getString("requestId"));
+        if (!ok) {
+            log.error("华为推送失败, taskNo={}, deviceCount={}, resp={}",
+                    task.getTaskNo(), deviceIds.size(), sendResp);
+        }
+        return ok;
     }
 
-    private boolean sendHuaweiLike(JSONObject credential, CommonPushTask task, String deviceId, String baseUrl) {
+    private boolean sendHonorLike(JSONObject credential, CommonPushTask task, List<String> deviceIds) {
+        if (deviceIds == null || deviceIds.isEmpty()) {
+            return false;
+        }
         String appId = credential.getString("appId");
         String appSecret = credential.getString("appSecret");
         if (StringUtils.isAnyBlank(appId, appSecret)) {
             return false;
         }
+        String accessToken = obtainHonorAccessToken(credential);
+        if (StringUtils.isBlank(accessToken)) {
+            return false;
+        }
+
+        JSONObject body = buildHuaweiMessageBody(task, deviceIds);
+        Map<String, String> headers = new HashMap<>();
+        headers.put("Authorization", "Bearer " + accessToken);
+        String sendResp = postJson(HONOR_PUSH_BASE_URL + "/v1/" + appId + "/messages:send",
+                body.toJSONString(), headers);
+        JSONObject sendJson = JSONObject.parseObject(sendResp);
+        boolean ok = sendJson != null && StringUtils.isNotBlank(sendJson.getString("requestId"));
+        if (!ok) {
+            log.error("荣耀推送失败, taskNo={}, deviceCount={}, resp={}",
+                    task.getTaskNo(), deviceIds.size(), sendResp);
+        }
+        return ok;
+    }
+
+    private String obtainHuaweiAccessToken(JSONObject credential) {
+        String appId = credential.getString("appId");
+        String appSecret = credential.getString("appSecret");
         MultiValueMap<String, String> tokenForm = new LinkedMultiValueMap<>();
         tokenForm.add("grant_type", "client_credentials");
         tokenForm.add("client_id", appId);
         tokenForm.add("client_secret", appSecret);
-        String tokenResp = postForm(baseUrl + "/oauth2/v2/token", tokenForm, null);
+        String tokenResp = postForm(HUAWEI_PUSH_BASE_URL + "/oauth2/v2/token", tokenForm, null);
         JSONObject tokenJson = JSONObject.parseObject(tokenResp);
         if (tokenJson == null || StringUtils.isBlank(tokenJson.getString("access_token"))) {
-            return false;
+            log.warn("华为鉴权失败: {}", tokenResp);
+            return null;
+        }
+        return tokenJson.getString("access_token");
+    }
+
+    private String obtainHonorAccessToken(JSONObject credential) {
+        String appId = credential.getString("appId");
+        String appSecret = credential.getString("appSecret");
+        MultiValueMap<String, String> tokenForm = new LinkedMultiValueMap<>();
+        tokenForm.add("grant_type", "client_credentials");
+        tokenForm.add("client_id", appId);
+        tokenForm.add("client_secret", appSecret);
+        String tokenResp = postForm(HONOR_AUTH_URL, tokenForm, null);
+        JSONObject tokenJson = JSONObject.parseObject(tokenResp);
+        if (tokenJson == null || StringUtils.isBlank(tokenJson.getString("access_token"))) {
+            log.warn("荣耀鉴权失败: {}", tokenResp);
+            return null;
         }
-        String accessToken = tokenJson.getString("access_token");
+        return tokenJson.getString("access_token");
+    }
 
+    private JSONObject buildHuaweiMessageBody(CommonPushTask task, List<String> deviceIds) {
         JSONObject body = new JSONObject();
         JSONObject message = new JSONObject();
         JSONObject android = new JSONObject();
@@ -368,29 +796,45 @@ public class CommonPushVendorHttpClient {
         android.put("notification", notification);
         message.put("android", android);
         body.put("message", message);
-        body.put("token", new String[]{deviceId});
+        body.put("token", deviceIds.toArray(new String[0]));
+        return body;
+    }
 
-        Map<String, String> headers = new HashMap<>();
-        headers.put("Authorization", "Bearer " + accessToken);
-        String sendResp = postJson(baseUrl + "/v1/" + appId + "/messages:send", body.toJSONString(), headers);
-        JSONObject sendJson = JSONObject.parseObject(sendResp);
-        return sendJson != null && StringUtils.isNotBlank(sendJson.getString("requestId"));
+    private List<String> extractDeviceIds(List<CommonPushTargetDto> targets) {
+        if (targets == null || targets.isEmpty()) {
+            return Collections.emptyList();
+        }
+        List<String> deviceIds = new ArrayList<>();
+        Set<String> exists = new HashSet<>();
+        for (CommonPushTargetDto target : targets) {
+            if (target == null || StringUtils.isBlank(target.getDeviceId())) {
+                continue;
+            }
+            String deviceId = target.getDeviceId().trim();
+            if (exists.add(deviceId)) {
+                deviceIds.add(deviceId);
+            }
+        }
+        return deviceIds;
+    }
+
+    private List<List<String>> partitionList(List<String> list, int batchSize) {
+        List<List<String>> partitions = new ArrayList<>();
+        if (list == null || list.isEmpty() || batchSize <= 0) {
+            return partitions;
+        }
+        for (int i = 0; i < list.size(); i += batchSize) {
+            partitions.add(list.subList(i, Math.min(i + batchSize, list.size())));
+        }
+        return partitions;
     }
 
-    /**
-     * APNs 需 HTTP/2 + ES256 JWT,当前环境未接入专用客户端,仅校验凭证字段完整性。
-     */
     private boolean sendApns(JSONObject credential, CommonPushTask task, String deviceId) {
-        String bundleId = credential.getString("bundleId");
-        String teamId = credential.getString("teamId");
-        String keyId = credential.getString("keyId");
-        String privateKey = credential.getString("privateKey");
-        if (StringUtils.isAnyBlank(bundleId, teamId, keyId, privateKey)) {
+        if (!apnsPushClient.validateCredential(credential)) {
             log.warn("APNs 凭证不完整, deviceId={}", deviceId);
             return false;
         }
-        log.warn("APNs 推送需 HTTP/2 客户端支持,当前跳过实际下发, deviceId={}, title={}", deviceId, task.getTitle());
-        return false;
+        return apnsPushClient.send(credential, task, deviceId);
     }
 
     private JSONObject parseCredential(String credentialJson) {

+ 1 - 1
alien-store/src/main/java/shop/alien/store/service/impl/CommonPushChannelConfigServiceImpl.java

@@ -32,7 +32,7 @@ public class CommonPushChannelConfigServiceImpl extends ServiceImpl<CommonPushCh
         CHANNEL_TEST_HOST.put("xiaomi", "api.xmpush.xiaomi.com");
         CHANNEL_TEST_HOST.put("oppo", "api.push.oppomobile.com");
         CHANNEL_TEST_HOST.put("vivo", "api-push.vivo.com.cn");
-        CHANNEL_TEST_HOST.put("honor", "push-api.cloud.huawei.com");
+        CHANNEL_TEST_HOST.put("honor", "push-api.cloud.honor.com");
         CHANNEL_TEST_HOST.put("samsung", "apchina.push.samsungosp.com.cn");
     }
 

+ 137 - 6
alien-store/src/main/java/shop/alien/store/service/impl/CommonPushSendServiceImpl.java

@@ -17,9 +17,11 @@ import shop.alien.store.dto.CommonPushSendResultDto;
 import shop.alien.store.dto.CommonPushTargetDto;
 import shop.alien.store.service.CommonPushSendService;
 import shop.alien.store.service.CommonPushTaskUserService;
+import shop.alien.store.service.channel.ApnsPushClient;
 import shop.alien.store.service.channel.CommonPushVendorHttpClient;
 
 import java.util.ArrayList;
+import java.util.Arrays;
 import java.util.Collections;
 import java.util.Comparator;
 import java.util.HashMap;
@@ -34,6 +36,10 @@ import java.util.stream.Collectors;
 @RequiredArgsConstructor
 public class CommonPushSendServiceImpl implements CommonPushSendService {
 
+    /** targetType=1 时走厂商全量广播 API 的渠道(按顺序执行,互不影响) */
+    private static final List<String> FULL_BROADCAST_CHANNELS =
+            Arrays.asList("apns", "huawei", "honor", "xiaomi", "oppo", "vivo");
+
     private final CommonPushChannelConfigMapper commonPushChannelConfigMapper;
     private final LifeUserMapper lifeUserMapper;
     private final CommonPushVendorHttpClient commonPushVendorHttpClient;
@@ -161,18 +167,46 @@ public class CommonPushSendServiceImpl implements CommonPushSendService {
             result.setMessage("无可用推送渠道,请检查渠道配置是否启用且凭证完整");
             return result;
         }
-        if (targets == null || targets.isEmpty()) {
+        Map<String, CommonPushChannelConfig> channelMap = channels.stream()
+                .collect(Collectors.toMap(c -> StringUtils.lowerCase(c.getChannelCode()), c -> c, (a, b) -> a));
+
+        boolean fullBroadcast = Integer.valueOf(1).equals(task.getTargetType());
+        boolean anyFullBroadcast = fullBroadcast && FULL_BROADCAST_CHANNELS.stream().anyMatch(channelMap::containsKey);
+        if ((targets == null || targets.isEmpty()) && !anyFullBroadcast) {
             result.setSuccess(false);
             result.setMessage("未找到可推送的目标设备");
             return result;
         }
-        Map<String, CommonPushChannelConfig> channelMap = channels.stream()
-                .collect(Collectors.toMap(c -> StringUtils.lowerCase(c.getChannelCode()), c -> c, (a, b) -> a));
+        if (targets == null) {
+            targets = Collections.emptyList();
+        }
+
         Map<String, List<CommonPushTargetDto>> grouped = groupTargetsByChannel(targets, channelMap, task.getTargetConfig());
 
         int sentCount = 0;
         int failedCount = 0;
+        Set<String> broadcastHandledChannels = new HashSet<>();
+        if (fullBroadcast) {
+            for (String channelCode : FULL_BROADCAST_CHANNELS) {
+                if (!channelMap.containsKey(channelCode)) {
+                    continue;
+                }
+                broadcastHandledChannels.add(channelCode);
+                CommonPushChannelConfig channelConfig = channelMap.get(channelCode);
+                List<CommonPushTargetDto> channelTargets = grouped.getOrDefault(channelCode, Collections.emptyList());
+                List<CommonPushTargetDto> broadcastTargets = "apns".equals(channelCode)
+                        ? ApnsPushClient.filterApnsTargets(channelTargets, targets)
+                        : channelTargets;
+                boolean ok = invokeFullBroadcast(channelCode, channelConfig, task, broadcastTargets);
+                sentCount += applyFullBroadcastResult(ok, channelCode, grouped, task, result, channelConfig);
+                failedCount += countFullBroadcastFailure(ok, channelCode, grouped);
+            }
+        }
+
         for (Map.Entry<String, List<CommonPushTargetDto>> entry : grouped.entrySet()) {
+            if (broadcastHandledChannels.contains(entry.getKey())) {
+                continue;
+            }
             CommonPushChannelConfig channelConfig = channelMap.get(entry.getKey());
             if (channelConfig == null) {
                 failedCount += entry.getValue().size();
@@ -180,7 +214,13 @@ public class CommonPushSendServiceImpl implements CommonPushSendService {
                 continue;
             }
             for (CommonPushTargetDto target : entry.getValue()) {
-                boolean ok = commonPushVendorHttpClient.send(channelConfig, task, target);
+                boolean ok;
+                try {
+                    ok = commonPushVendorHttpClient.send(channelConfig, task, target);
+                } catch (Exception e) {
+                    log.error("渠道单推异常, channelCode={}, deviceId={}", entry.getKey(), target.getDeviceId(), e);
+                    ok = false;
+                }
                 if (ok) {
                     sentCount++;
                     result.getSentTargets().add(target);
@@ -194,10 +234,86 @@ public class CommonPushSendServiceImpl implements CommonPushSendService {
         result.setSentCount(sentCount);
         result.setFailedCount(failedCount);
         result.setSuccess(sentCount > 0);
-        result.setMessage(sentCount > 0 ? "推送完成" : "推送失败,请检查渠道凭证与设备 token");
+        if (sentCount > 0 && failedCount > 0) {
+            result.setMessage("推送部分完成,成功 " + sentCount + ",失败 " + failedCount);
+        } else {
+            result.setMessage(sentCount > 0 ? "推送完成" : "推送失败,请检查渠道凭证与设备 token");
+        }
         return result;
     }
 
+    private int defaultCount(Integer count) {
+        return count == null || count <= 0 ? 1 : count;
+    }
+
+    private int applyFullBroadcastResult(boolean ok, String channelCode,
+                                         Map<String, List<CommonPushTargetDto>> grouped,
+                                         CommonPushTask task, CommonPushSendResultDto result,
+                                         CommonPushChannelConfig channelConfig) {
+        List<CommonPushTargetDto> channelTargets = grouped.getOrDefault(channelCode, Collections.emptyList());
+        if (ok) {
+            int broadcastCount = channelTargets.isEmpty()
+                    ? defaultCount(task.getEstimatedCount())
+                    : channelTargets.size();
+            if (channelTargets.isEmpty()) {
+                CommonPushTargetDto broadcastTarget = new CommonPushTargetDto();
+                broadcastTarget.setPlatform("android");
+                broadcastTarget.setBroadcast(true);
+                result.getSentTargets().add(broadcastTarget);
+            } else {
+                result.getSentTargets().addAll(channelTargets);
+            }
+            updateChannelUsageSafely(channelConfig, channelCode);
+            return broadcastCount;
+        }
+        if (!channelTargets.isEmpty()) {
+            result.getFailedTargets().addAll(channelTargets);
+        }
+        return 0;
+    }
+
+    private boolean invokeFullBroadcast(String channelCode, CommonPushChannelConfig config, CommonPushTask task,
+                                          List<CommonPushTargetDto> channelTargets) {
+        try {
+            switch (channelCode) {
+                case "apns":
+                    return commonPushVendorHttpClient.sendApnsBroadcast(config, task, channelTargets);
+                case "huawei":
+                    return commonPushVendorHttpClient.sendHuaweiBroadcast(config, task, channelTargets);
+                case "honor":
+                    return commonPushVendorHttpClient.sendHonorBroadcast(config, task, channelTargets);
+                case "xiaomi":
+                    return commonPushVendorHttpClient.sendXiaomiBroadcast(config, task);
+                case "oppo":
+                    return commonPushVendorHttpClient.sendOppoBroadcast(config, task);
+                case "vivo":
+                    return commonPushVendorHttpClient.sendVivoBroadcast(config, task);
+                default:
+                    return false;
+            }
+        } catch (Exception e) {
+            log.error("全量广播推送异常, channelCode={}, taskNo={}", channelCode, task.getTaskNo(), e);
+            return false;
+        }
+    }
+
+    private void updateChannelUsageSafely(CommonPushChannelConfig config, String channelCode) {
+        try {
+            updateChannelUsage(config);
+        } catch (Exception e) {
+            log.warn("更新渠道用量失败, channelCode={}", channelCode, e);
+        }
+    }
+
+    private int countFullBroadcastFailure(boolean ok, String channelCode,
+                                          Map<String, List<CommonPushTargetDto>> grouped) {
+        if (ok) {
+            return 0;
+        }
+        List<CommonPushTargetDto> channelTargets = grouped.getOrDefault(channelCode, Collections.emptyList());
+        return channelTargets.isEmpty() ? 1 : channelTargets.size();
+    }
+
     @Override
     public void saveTaskUserRecords(Long pushTaskId, CommonPushSendResultDto sendResult) {
         if (pushTaskId == null || sendResult == null) {
@@ -210,12 +326,27 @@ public class CommonPushSendServiceImpl implements CommonPushSendService {
         allTargets.addAll(sendResult.getSentTargets());
         allTargets.addAll(sendResult.getFailedTargets());
         for (CommonPushTargetDto target : allTargets) {
+            if (Boolean.TRUE.equals(target.getBroadcast()) || StringUtils.isBlank(target.getDeviceId())) {
+                continue;
+            }
             CommonPushTaskUser taskUser = new CommonPushTaskUser();
             taskUser.setPushTaskId(pushTaskId);
             taskUser.setUserId(target.getUserId());
             taskUser.setDeviceId(target.getDeviceId());
             taskUser.setRelType(1);
-            taskUser.setStatus(sentDeviceIds.contains(target.getDeviceId()) ? 0 : 0);
+            taskUser.setStatus(sentDeviceIds.contains(target.getDeviceId()) ? 2 : 0);
+            taskUser.setShowInfo(0);
+            taskUser.setUserAdd(0);
+            commonPushTaskUserService.save(taskUser);
+        }
+        for (CommonPushTargetDto target : sendResult.getSentTargets()) {
+            if (!Boolean.TRUE.equals(target.getBroadcast())) {
+                continue;
+            }
+            CommonPushTaskUser taskUser = new CommonPushTaskUser();
+            taskUser.setPushTaskId(pushTaskId);
+            taskUser.setRelType(1);
+            taskUser.setStatus(2);
             taskUser.setShowInfo(0);
             taskUser.setUserAdd(0);
             commonPushTaskUserService.save(taskUser);

+ 9 - 1
alien-store/src/main/java/shop/alien/store/service/impl/CommonPushTaskServiceImpl.java

@@ -164,7 +164,15 @@ public class CommonPushTaskServiceImpl extends ServiceImpl<CommonPushTaskMapper,
     }
 
     private R<String> doSendTask(CommonPushTask task) {
-        CommonPushSendResultDto sendResult = commonPushSendService.send(task);
+        CommonPushSendResultDto sendResult;
+        try {
+            sendResult = commonPushSendService.send(task);
+        } catch (Exception e) {
+            log.error("推送任务执行异常, taskId={}, taskNo={}", task.getId(), task.getTaskNo(), e);
+            task.setStatus(CommonPushTaskStatus.FAILED);
+            this.updateById(task);
+            return R.fail("推送失败:" + e.getMessage());
+        }
         if (!sendResult.isSuccess()) {
             task.setStatus(CommonPushTaskStatus.FAILED);
             this.updateById(task);