Explorar o código

feat(push): 新增推送统计同步功能并优化厂商推送结果处理

- 在 CommonPushSendResultDto 中新增 vendorTaskIds 字段存储各厂商任务ID
- 修改 CommonPushVendorHttpClient 返回详细的推送结果对象而非布尔值
- 添加 CommonPushVendorSendResult 类封装推送结果和厂商任务ID
- 实现 syncPushTaskStatistics 方法同步已发送任务的厂商推送统计数据
- 新增 CommonPushChannelStatsDto 用于厂商推送统计查询结果
- 添加各厂商推送统计查询接口实现(OPPO、vivo、小米、华为、荣耀)
- 优化推送任务执行逻辑以收集和保存厂商特定的任务ID
- 更新推送验证错误提示信息为"验证码错误,请重新输入"
fcw hai 4 horas
pai
achega
d104a4dfbb
Modificáronse 16 ficheiros con 794 adicións e 97 borrados
  1. 4 0
      alien-entity/src/main/java/shop/alien/entity/store/CommonPushTask.java
  2. 1 1
      alien-gateway/src/main/java/shop/alien/gateway/controller/LifeUserController.java
  3. 8 0
      alien-job/src/main/java/shop/alien/job/feign/AlienStoreFeign.java
  4. 38 0
      alien-job/src/main/java/shop/alien/job/store/CommonPushTaskStatsJob.java
  5. 9 0
      alien-store/src/main/java/shop/alien/store/controller/CommonPushTaskJobController.java
  6. 18 0
      alien-store/src/main/java/shop/alien/store/dto/CommonPushChannelStatsDto.java
  7. 5 0
      alien-store/src/main/java/shop/alien/store/dto/CommonPushSendResultDto.java
  8. 37 0
      alien-store/src/main/java/shop/alien/store/dto/CommonPushVendorSendResult.java
  9. 8 0
      alien-store/src/main/java/shop/alien/store/service/CommonPushTaskService.java
  10. 19 0
      alien-store/src/main/java/shop/alien/store/service/CommonPushTaskStatsService.java
  11. 278 86
      alien-store/src/main/java/shop/alien/store/service/channel/CommonPushVendorHttpClient.java
  12. 17 10
      alien-store/src/main/java/shop/alien/store/service/impl/CommonPushSendServiceImpl.java
  13. 10 0
      alien-store/src/main/java/shop/alien/store/service/impl/CommonPushTaskServiceImpl.java
  14. 199 0
      alien-store/src/main/java/shop/alien/store/service/impl/CommonPushTaskStatsServiceImpl.java
  15. 51 0
      alien-store/src/main/java/shop/alien/store/util/CommonPushPhoneType.java
  16. 92 0
      alien-store/src/main/java/shop/alien/store/util/CommonPushVendorTaskIdUtil.java

+ 4 - 0
alien-entity/src/main/java/shop/alien/entity/store/CommonPushTask.java

@@ -127,6 +127,10 @@ public class CommonPushTask implements Serializable {
     @TableField("template_id")
     private Long templateId;
 
+    @ApiModelProperty("各厂商推送任务ID,格式:oppo_task_id: 1001, honor_task_id: 10056")
+    @TableField("vendor_task_ids")
+    private String vendorTaskIds;
+
     @ApiModelProperty("删除标记,0:未删除,1:已删除")
     @TableField("delete_flag")
     @TableLogic

+ 1 - 1
alien-gateway/src/main/java/shop/alien/gateway/controller/LifeUserController.java

@@ -56,7 +56,7 @@ public class LifeUserController {
             return R.fail("当前验证码过期或未发送");
         }
         if (!cacheCode.trim().equals(code.trim())) {
-            return R.fail("验证码错误");
+            return R.fail("验证码错误,请重新输入");
         }
 
         if(StringUtils.isNotBlank(inviteCode)){

+ 8 - 0
alien-job/src/main/java/shop/alien/job/feign/AlienStoreFeign.java

@@ -118,4 +118,12 @@ public interface AlienStoreFeign {
      */
     @PostMapping("/commonPushTask/job/executeScheduled")
     R<Integer> executeScheduledPushTasks();
+
+    /**
+     * 同步已发送任务的厂商推送统计数据到 common_push_task_num
+     *
+     * @return R.data 为本次成功更新的任务数
+     */
+    @PostMapping("/commonPushTask/job/syncStatistics")
+    R<Integer> syncPushTaskStatistics();
 }

+ 38 - 0
alien-job/src/main/java/shop/alien/job/store/CommonPushTaskStatsJob.java

@@ -0,0 +1,38 @@
+package shop.alien.job.store;
+
+import com.xxl.job.core.context.XxlJobHelper;
+import com.xxl.job.core.handler.annotation.XxlJob;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+import shop.alien.entity.result.R;
+import shop.alien.job.feign.AlienStoreFeign;
+
+/**
+ * 推送任务厂商统计同步。
+ * <p>
+ * 在 XXL-JOB 管理台配置任务:JobHandler = commonPushTaskStatsSync,Cron 建议 0 0/30 * * * ?(每 30 分钟)。
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class CommonPushTaskStatsJob {
+
+    private final AlienStoreFeign alienStoreFeign;
+
+    @XxlJob("commonPushTaskStatsSync")
+    public void commonPushTaskStatsSync() {
+        log.info("【定时任务】推送统计同步:开始执行");
+        XxlJobHelper.log("【定时任务】推送统计同步:开始执行");
+        try {
+            R<Integer> result = alienStoreFeign.syncPushTaskStatistics();
+            int count = (result != null && result.getData() != null) ? result.getData() : 0;
+            log.info("【定时任务】推送统计同步:执行完成,成功更新任务数={}", count);
+            XxlJobHelper.log("【定时任务】推送统计同步:执行完成,成功更新任务数=" + count);
+        } catch (Exception e) {
+            log.error("【定时任务】推送统计同步:执行异常", e);
+            XxlJobHelper.log("【定时任务】推送统计同步:执行异常 " + e.getMessage());
+            throw e;
+        }
+    }
+}

+ 9 - 0
alien-store/src/main/java/shop/alien/store/controller/CommonPushTaskJobController.java

@@ -30,4 +30,13 @@ public class CommonPushTaskJobController {
         log.info("commonPushTask job: executeScheduled 结束,成功发送任务数={}", count);
         return R.data(count);
     }
+
+    @ApiOperation("同步已发送任务的厂商推送统计数据")
+    @PostMapping("/syncStatistics")
+    public R<Integer> syncStatistics() {
+        log.info("commonPushTask job: syncStatistics 开始");
+        int count = commonPushTaskService.syncPushTaskStatistics();
+        log.info("commonPushTask job: syncStatistics 结束,成功更新任务数={}", count);
+        return R.data(count);
+    }
 }

+ 18 - 0
alien-store/src/main/java/shop/alien/store/dto/CommonPushChannelStatsDto.java

@@ -0,0 +1,18 @@
+package shop.alien.store.dto;
+
+import lombok.Data;
+
+/**
+ * 厂商推送统计查询结果
+ */
+@Data
+public class CommonPushChannelStatsDto {
+
+    private String realSend;
+
+    private String realDelivered;
+
+    private String clickSum;
+
+    private String showSum;
+}

+ 5 - 0
alien-store/src/main/java/shop/alien/store/dto/CommonPushSendResultDto.java

@@ -3,7 +3,9 @@ package shop.alien.store.dto;
 import lombok.Data;
 
 import java.util.ArrayList;
+import java.util.LinkedHashMap;
 import java.util.List;
+import java.util.Map;
 
 /**
  * 推送发送结果
@@ -22,4 +24,7 @@ public class CommonPushSendResultDto {
     private List<CommonPushTargetDto> sentTargets = new ArrayList<>();
 
     private List<CommonPushTargetDto> failedTargets = new ArrayList<>();
+
+    /** 各厂商 task_id,key 如 oppo_task_id、oppo_message_id */
+    private Map<String, String> vendorTaskIds = new LinkedHashMap<>();
 }

+ 37 - 0
alien-store/src/main/java/shop/alien/store/dto/CommonPushVendorSendResult.java

@@ -0,0 +1,37 @@
+package shop.alien.store.dto;
+
+import lombok.Data;
+import org.apache.commons.lang3.StringUtils;
+
+/**
+ * 厂商推送单次发送结果(含用于统计查询的 task_id)
+ */
+@Data
+public class CommonPushVendorSendResult {
+
+    private boolean success;
+
+    /** 厂商返回的任务 ID,如 vivo taskId、OPPO task_id、小米 msg_id */
+    private String vendorTaskId;
+
+    /** 附加 ID,如 OPPO 统计所需的 message_id */
+    private String extraTaskId;
+
+    public static CommonPushVendorSendResult fail() {
+        CommonPushVendorSendResult result = new CommonPushVendorSendResult();
+        result.setSuccess(false);
+        return result;
+    }
+
+    public static CommonPushVendorSendResult ok(String vendorTaskId) {
+        return ok(vendorTaskId, null);
+    }
+
+    public static CommonPushVendorSendResult ok(String vendorTaskId, String extraTaskId) {
+        CommonPushVendorSendResult result = new CommonPushVendorSendResult();
+        result.setSuccess(StringUtils.isNotBlank(vendorTaskId));
+        result.setVendorTaskId(vendorTaskId);
+        result.setExtraTaskId(extraTaskId);
+        return result;
+    }
+}

+ 8 - 0
alien-store/src/main/java/shop/alien/store/service/CommonPushTaskService.java

@@ -45,5 +45,13 @@ public interface CommonPushTaskService extends IService<CommonPushTask> {
      * @return 本次成功发送的任务数
      */
     int executeScheduledPushTasks();
+
+    /**
+     * 同步已发送任务的厂商推送统计数据(供 XXL-JOB 调用)。
+     *
+     * @return 本次成功更新的任务数
+     */
+    int syncPushTaskStatistics();
+
     R<CommonPushReportVo> report(Integer reportType);
 }

+ 19 - 0
alien-store/src/main/java/shop/alien/store/service/CommonPushTaskStatsService.java

@@ -0,0 +1,19 @@
+package shop.alien.store.service;
+
+/**
+ * 推送任务厂商统计同步服务
+ */
+public interface CommonPushTaskStatsService {
+
+    /**
+     * 同步已发送任务的厂商统计数据到 common_push_task_num。
+     *
+     * @return 本次成功更新的任务数
+     */
+    int syncPushTaskStatistics();
+
+    /**
+     * 发送成功后初始化 common_push_task_num 记录。
+     */
+    void initTaskNumRecords(Long pushTaskId, java.util.Map<String, String> vendorTaskIds, Integer estimatedCount);
+}

+ 278 - 86
alien-store/src/main/java/shop/alien/store/service/channel/CommonPushVendorHttpClient.java

@@ -17,7 +17,9 @@ import org.springframework.web.client.RestTemplate;
 import shop.alien.entity.store.CommonPushChannelConfig;
 import shop.alien.entity.store.CommonPushTask;
 import shop.alien.store.config.CommonPushProperties;
+import shop.alien.store.dto.CommonPushChannelStatsDto;
 import shop.alien.store.dto.CommonPushTargetDto;
+import shop.alien.store.dto.CommonPushVendorSendResult;
 
 import java.net.URLEncoder;
 import java.nio.charset.StandardCharsets;
@@ -57,185 +59,200 @@ public class CommonPushVendorHttpClient {
     /**
      * OPPO 全量广播推送(target_type=1),用于 targetType=1 全量场景。
      */
-    public boolean sendOppoBroadcast(CommonPushChannelConfig config, CommonPushTask task) {
+    public CommonPushVendorSendResult sendOppoBroadcast(CommonPushChannelConfig config, CommonPushTask task) {
         if (config == null || task == null) {
-            return false;
+            return CommonPushVendorSendResult.fail();
         }
         JSONObject credential = parseCredential(config.getCredentialJson());
         if (credential == null) {
-            return false;
+            return CommonPushVendorSendResult.fail();
         }
         try {
             String authToken = obtainOppoAuthToken(credential);
             if (StringUtils.isBlank(authToken)) {
-                return false;
+                return CommonPushVendorSendResult.fail();
             }
             String messageId = saveOppoMessageContent(authToken, task, null);
             if (StringUtils.isBlank(messageId)) {
-                return false;
+                return CommonPushVendorSendResult.fail();
             }
-            return broadcastOppoMessage(authToken, messageId);
+            String taskId = broadcastOppoMessage(authToken, messageId);
+            if (StringUtils.isBlank(taskId)) {
+                return CommonPushVendorSendResult.fail();
+            }
+            return CommonPushVendorSendResult.ok(taskId, messageId);
         } catch (Exception e) {
             log.error("OPPO 广播推送失败, taskNo={}, err={}", task.getTaskNo(), e.getMessage(), e);
-            return false;
+            return CommonPushVendorSendResult.fail();
         }
     }
 
     /**
      * 小米全量广播推送,用于 targetType=1 全量场景。
      */
-    public boolean sendXiaomiBroadcast(CommonPushChannelConfig config, CommonPushTask task) {
+    public CommonPushVendorSendResult sendXiaomiBroadcast(CommonPushChannelConfig config, CommonPushTask task) {
         if (config == null || task == null) {
-            return false;
+            return CommonPushVendorSendResult.fail();
         }
         JSONObject credential = parseCredential(config.getCredentialJson());
         if (credential == null) {
-            return false;
+            return CommonPushVendorSendResult.fail();
         }
         String appSecret = credential.getString("appSecret");
         if (StringUtils.isBlank(appSecret)) {
-            return false;
+            return CommonPushVendorSendResult.fail();
         }
         if (!validateXiaomiCredential(credential, task.getTaskNo())) {
-            return false;
+            return CommonPushVendorSendResult.fail();
         }
         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(), "全量广播");
+            return parseXiaomiSendResult(resp, task.getTaskNo(), "全量广播");
         } catch (Exception e) {
             log.error("小米广播推送失败, taskNo={}, err={}", task.getTaskNo(), e.getMessage(), e);
-            return false;
+            return CommonPushVendorSendResult.fail();
         }
     }
 
     /**
      * vivo 全量广播推送,用于 targetType=1 全量场景。
      */
-    public boolean sendVivoBroadcast(CommonPushChannelConfig config, CommonPushTask task) {
+    public CommonPushVendorSendResult sendVivoBroadcast(CommonPushChannelConfig config, CommonPushTask task) {
         if (config == null || task == null) {
-            return false;
+            return CommonPushVendorSendResult.fail();
         }
         JSONObject credential = parseCredential(config.getCredentialJson());
         if (credential == null) {
-            return false;
+            return CommonPushVendorSendResult.fail();
         }
         try {
             String authToken = obtainVivoAuthToken(credential);
             if (StringUtils.isBlank(authToken)) {
-                return false;
+                return CommonPushVendorSendResult.fail();
             }
             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(), "全量广播");
+            return parseVivoSendResult(sendResp, task.getTaskNo(), "全量广播");
         } catch (Exception e) {
             log.error("vivo 广播推送失败, taskNo={}, err={}", task.getTaskNo(), e.getMessage(), e);
-            return false;
+            return CommonPushVendorSendResult.fail();
         }
     }
 
     /**
      * 华为全量推送:官方无 /all 接口,按 token 批量下发(每批最多 1000 个)。
      */
-    public boolean sendHuaweiBroadcast(CommonPushChannelConfig config, CommonPushTask task,
+    public CommonPushVendorSendResult sendHuaweiBroadcast(CommonPushChannelConfig config, CommonPushTask task,
                                        List<CommonPushTargetDto> targets) {
         if (config == null || task == null) {
-            return false;
+            return CommonPushVendorSendResult.fail();
         }
         JSONObject credential = parseCredential(config.getCredentialJson());
         if (credential == null) {
-            return false;
+            return CommonPushVendorSendResult.fail();
         }
         List<String> deviceIds = extractDeviceIds(targets);
         if (deviceIds.isEmpty()) {
             log.warn("华为全量推送无可用 deviceId, taskNo={}", task.getTaskNo());
-            return false;
+            return CommonPushVendorSendResult.fail();
         }
         try {
             int successBatches = 0;
             int totalBatches = 0;
+            String requestId = null;
             for (List<String> batch : partitionList(deviceIds, HUAWEI_BATCH_TOKEN_LIMIT)) {
                 totalBatches++;
-                if (sendHuaweiLike(credential, task, batch)) {
+                CommonPushVendorSendResult batchResult = sendHuaweiLike(credential, task, batch);
+                if (batchResult.isSuccess()) {
                     successBatches++;
+                    if (requestId == null) {
+                        requestId = batchResult.getVendorTaskId();
+                    }
                 }
             }
             log.info("华为全量批量推送完成, taskNo={}, devices={}, batches={}/{}",
                     task.getTaskNo(), deviceIds.size(), successBatches, totalBatches);
-            return successBatches > 0;
+            return successBatches > 0 ? CommonPushVendorSendResult.ok(requestId) : CommonPushVendorSendResult.fail();
         } catch (Exception e) {
             log.error("华为全量推送失败, taskNo={}, err={}", task.getTaskNo(), e.getMessage(), e);
-            return false;
+            return CommonPushVendorSendResult.fail();
         }
     }
 
     /**
      * 荣耀全量推送:官方无 /all 接口,按 token 批量下发(每批最多 1000 个)。
      */
-    public boolean sendHonorBroadcast(CommonPushChannelConfig config, CommonPushTask task,
+    public CommonPushVendorSendResult sendHonorBroadcast(CommonPushChannelConfig config, CommonPushTask task,
                                       List<CommonPushTargetDto> targets) {
         if (config == null || task == null) {
-            return false;
+            return CommonPushVendorSendResult.fail();
         }
         JSONObject credential = parseCredential(config.getCredentialJson());
         if (credential == null) {
-            return false;
+            return CommonPushVendorSendResult.fail();
         }
         List<String> deviceIds = extractDeviceIds(targets);
         if (deviceIds.isEmpty()) {
             log.warn("荣耀全量推送无可用 deviceId, taskNo={}", task.getTaskNo());
-            return false;
+            return CommonPushVendorSendResult.fail();
         }
         try {
             int successBatches = 0;
             int totalBatches = 0;
+            String requestId = null;
             for (List<String> batch : partitionList(deviceIds, HUAWEI_BATCH_TOKEN_LIMIT)) {
                 totalBatches++;
-                if (sendHonorLike(credential, task, batch)) {
+                CommonPushVendorSendResult batchResult = sendHonorLike(credential, task, batch);
+                if (batchResult.isSuccess()) {
                     successBatches++;
+                    if (requestId == null) {
+                        requestId = batchResult.getVendorTaskId();
+                    }
                 }
             }
             log.info("荣耀全量批量推送完成, taskNo={}, devices={}, batches={}/{}",
                     task.getTaskNo(), deviceIds.size(), successBatches, totalBatches);
-            return successBatches > 0;
+            return successBatches > 0 ? CommonPushVendorSendResult.ok(requestId) : CommonPushVendorSendResult.fail();
         } catch (Exception e) {
             log.error("荣耀全量推送失败, taskNo={}, err={}", task.getTaskNo(), e.getMessage(), e);
-            return false;
+            return CommonPushVendorSendResult.fail();
         }
     }
 
     /**
      * APNs 全量推送:按 iOS deviceToken 逐条下发(Apple 无 /all 接口)。
      */
-    public boolean sendApnsBroadcast(CommonPushChannelConfig config, CommonPushTask task,
+    public CommonPushVendorSendResult sendApnsBroadcast(CommonPushChannelConfig config, CommonPushTask task,
                                      List<CommonPushTargetDto> targets) {
         if (config == null || task == null) {
-            return false;
+            return CommonPushVendorSendResult.fail();
         }
         JSONObject credential = parseCredential(config.getCredentialJson());
         if (credential == null || !apnsPushClient.validateCredential(credential)) {
-            return false;
+            return CommonPushVendorSendResult.fail();
         }
         List<CommonPushTargetDto> apnsTargets = ApnsPushClient.filterApnsTargets(targets, targets);
         if (apnsTargets.isEmpty()) {
             log.warn("APNs 全量推送无 iOS 设备 token, taskNo={}", task.getTaskNo());
-            return false;
+            return CommonPushVendorSendResult.fail();
         }
-        return apnsPushClient.sendBroadcast(credential, task, apnsTargets);
+        boolean ok = apnsPushClient.sendBroadcast(credential, task, apnsTargets);
+        return ok ? CommonPushVendorSendResult.ok(task.getTaskNo()) : CommonPushVendorSendResult.fail();
     }
 
-    public boolean send(CommonPushChannelConfig config, CommonPushTask task, CommonPushTargetDto target) {
+    public CommonPushVendorSendResult send(CommonPushChannelConfig config, CommonPushTask task, CommonPushTargetDto target) {
         if (config == null || task == null || target == null || StringUtils.isBlank(target.getDeviceId())) {
-            return false;
+            return CommonPushVendorSendResult.fail();
         }
         String deviceId = target.getDeviceId();
         JSONObject credential = parseCredential(config.getCredentialJson());
         if (credential == null) {
-            return false;
+            return CommonPushVendorSendResult.fail();
         }
         String channelCode = StringUtils.lowerCase(StringUtils.trim(config.getChannelCode()));
         try {
@@ -247,7 +264,8 @@ public class CommonPushVendorHttpClient {
                 case "oppo":
                     return sendOppo(credential, task, target);
                 case "samsung":
-                    return sendSamsung(credential, task, target);
+                    return sendSamsung(credential, task, target) ? CommonPushVendorSendResult.ok(task.getTaskNo())
+                            : CommonPushVendorSendResult.fail();
                 case "huawei":
                     return sendHuawei(credential, task, deviceId);
                 case "honor":
@@ -256,29 +274,29 @@ public class CommonPushVendorHttpClient {
                     return sendApns(credential, task, deviceId);
                 default:
                     log.warn("不支持的推送渠道: {}", channelCode);
-                    return false;
+                    return CommonPushVendorSendResult.fail();
             }
         } catch (Exception e) {
             log.error("渠道推送失败, channelCode={}, deviceId={}, err={}", channelCode, deviceId, e.getMessage(), e);
-            return false;
+            return CommonPushVendorSendResult.fail();
         }
     }
 
-    private boolean sendVivo(JSONObject credential, CommonPushTask task, CommonPushTargetDto target) {
+    private CommonPushVendorSendResult sendVivo(JSONObject credential, CommonPushTask task, CommonPushTargetDto target) {
         String deviceId = target.getDeviceId();
         if (StringUtils.isBlank(deviceId)) {
-            return false;
+            return CommonPushVendorSendResult.fail();
         }
         String authToken = obtainVivoAuthToken(credential);
         if (StringUtils.isBlank(authToken)) {
-            return false;
+            return CommonPushVendorSendResult.fail();
         }
         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(), "单推");
+        return parseVivoSendResult(sendResp, task.getTaskNo(), "单推");
     }
 
     private String obtainVivoAuthToken(JSONObject credential) {
@@ -345,34 +363,35 @@ public class CommonPushVendorHttpClient {
         return param.toJSONString();
     }
 
-    private boolean isVivoSendSuccess(String resp, String taskNo, String scene) {
+    private CommonPushVendorSendResult parseVivoSendResult(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;
+            String taskId = StringUtils.defaultIfBlank(json.getString("taskId"), json.getString("task_id"));
+            return CommonPushVendorSendResult.ok(taskId);
         }
         if (json != null) {
             log.error("vivo{}失败, taskNo={}, result={}, desc={}",
                     scene, taskNo, json.getIntValue("result"), json.getString("desc"));
         }
-        return false;
+        return CommonPushVendorSendResult.fail();
     }
 
-    private boolean sendXiaomi(JSONObject credential, CommonPushTask task, CommonPushTargetDto target) {
+    private CommonPushVendorSendResult sendXiaomi(JSONObject credential, CommonPushTask task, CommonPushTargetDto target) {
         String deviceId = target.getDeviceId();
         String appSecret = credential.getString("appSecret");
         if (StringUtils.isAnyBlank(appSecret, deviceId)) {
-            return false;
+            return CommonPushVendorSendResult.fail();
         }
         if (!validateXiaomiCredential(credential, task.getTaskNo())) {
-            return false;
+            return CommonPushVendorSendResult.fail();
         }
         MultiValueMap<String, String> form = buildXiaomiMessageForm(credential, task, target);
         form.add("registration_id", deviceId);
         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(), "单推");
+        return parseXiaomiSendResult(resp, task.getTaskNo(), "单推");
     }
 
     private boolean validateXiaomiCredential(JSONObject credential, String taskNo) {
@@ -398,17 +417,32 @@ public class CommonPushVendorHttpClient {
                 StringUtils.trimToNull(credential.getString("channel_id")));
     }
 
-    private boolean isXiaomiSendSuccess(String resp, String taskNo, String scene) {
+    private CommonPushVendorSendResult parseXiaomiSendResult(String resp, String taskNo, String scene) {
         log.info("小米{}响应: {}", scene, resp);
         JSONObject json = JSONObject.parseObject(resp);
         if (json != null && "ok".equalsIgnoreCase(json.getString("result"))) {
-            return true;
+            String msgId = extractXiaomiMessageId(json);
+            return CommonPushVendorSendResult.ok(msgId);
         }
         if (json != null) {
             log.error("小米{}失败, taskNo={}, code={}, reason={}, description={}",
                     scene, taskNo, json.getString("code"), json.getString("reason"), json.getString("description"));
         }
-        return false;
+        return CommonPushVendorSendResult.fail();
+    }
+
+    private String extractXiaomiMessageId(JSONObject json) {
+        if (json == null) {
+            return null;
+        }
+        JSONObject data = json.getJSONObject("data");
+        if (data != null) {
+            String id = StringUtils.defaultIfBlank(data.getString("id"), data.getString("msg_id"));
+            if (StringUtils.isNotBlank(id)) {
+                return id;
+            }
+        }
+        return StringUtils.defaultIfBlank(json.getString("messageId"), json.getString("msg_id"));
     }
 
     private MultiValueMap<String, String> buildXiaomiMessageForm(JSONObject credential, CommonPushTask task,
@@ -461,11 +495,11 @@ public class CommonPushVendorHttpClient {
     /**
      * OPPO 厂商直连:使用 RegistrationID(非 UniPush CID),并设置送达回执回调。
      */
-    private boolean sendOppo(JSONObject credential, CommonPushTask task, CommonPushTargetDto target) {
+    private CommonPushVendorSendResult sendOppo(JSONObject credential, CommonPushTask task, CommonPushTargetDto target) {
         String deviceId = target.getDeviceId();
         String authToken = obtainOppoAuthToken(credential);
         if (StringUtils.isBlank(authToken)) {
-            return false;
+            return CommonPushVendorSendResult.fail();
         }
 
         JSONObject notification = buildOppoNotification(task, target);
@@ -489,12 +523,25 @@ public class CommonPushVendorHttpClient {
             );
             String body = response.getBody();
             log.info("OPPO 单推响应: {}", body);
-            JSONObject resp = JSONObject.parseObject(body);
-            return resp != null && resp.getIntValue("code") == 0;
+            return parseOppoSendResult(body);
         } catch (Exception e) {
             log.error("OPPO 单推请求异常: {}", e.getMessage(), e);
-            return false;
+            return CommonPushVendorSendResult.fail();
+        }
+    }
+
+    private CommonPushVendorSendResult parseOppoSendResult(String resp) {
+        JSONObject json = JSONObject.parseObject(resp);
+        if (json == null || json.getIntValue("code") != 0) {
+            return CommonPushVendorSendResult.fail();
+        }
+        JSONObject data = json.getJSONObject("data");
+        if (data == null) {
+            return CommonPushVendorSendResult.ok(null);
         }
+        String taskId = StringUtils.defaultIfBlank(data.getString("task_id"), data.getString("message_id"));
+        String messageId = StringUtils.defaultIfBlank(data.getString("message_id"), data.getString("messageId"));
+        return CommonPushVendorSendResult.ok(taskId, messageId);
     }
 
     private String obtainOppoAuthToken(JSONObject credential) {
@@ -553,9 +600,9 @@ public class CommonPushVendorHttpClient {
     }
 
     /**
-     * OPPO 广播下发,target_type=1 表示全量用户。
+     * OPPO 广播下发,target_type=1 表示全量用户,返回 task_id
      */
-    private boolean broadcastOppoMessage(String authToken, String messageId) {
+    private String broadcastOppoMessage(String authToken, String messageId) {
         MultiValueMap<String, String> form = new LinkedMultiValueMap<>();
         form.add("auth_token", authToken);
         form.add("message_id", messageId);
@@ -563,7 +610,14 @@ public class CommonPushVendorHttpClient {
         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;
+        if (json == null || json.getIntValue("code") != 0) {
+            return null;
+        }
+        JSONObject data = json.getJSONObject("data");
+        if (data == null) {
+            return messageId;
+        }
+        return StringUtils.defaultIfBlank(data.getString("task_id"), messageId);
     }
 
     private String resolveCallbackUrl() {
@@ -690,26 +744,26 @@ public class CommonPushVendorHttpClient {
         }
     }
 
-    private boolean sendHuawei(JSONObject credential, CommonPushTask task, String deviceId) {
+    private CommonPushVendorSendResult sendHuawei(JSONObject credential, CommonPushTask task, String deviceId) {
         return sendHuaweiLike(credential, task, Collections.singletonList(deviceId));
     }
 
-    private boolean sendHonor(JSONObject credential, CommonPushTask task, String deviceId) {
+    private CommonPushVendorSendResult sendHonor(JSONObject credential, CommonPushTask task, String deviceId) {
         return sendHonorLike(credential, task, Collections.singletonList(deviceId));
     }
 
-    private boolean sendHuaweiLike(JSONObject credential, CommonPushTask task, List<String> deviceIds) {
+    private CommonPushVendorSendResult sendHuaweiLike(JSONObject credential, CommonPushTask task, List<String> deviceIds) {
         if (deviceIds == null || deviceIds.isEmpty()) {
-            return false;
+            return CommonPushVendorSendResult.fail();
         }
         String appId = credential.getString("appId");
         String appSecret = credential.getString("appSecret");
         if (StringUtils.isAnyBlank(appId, appSecret)) {
-            return false;
+            return CommonPushVendorSendResult.fail();
         }
         String accessToken = obtainHuaweiAccessToken(credential);
         if (StringUtils.isBlank(accessToken)) {
-            return false;
+            return CommonPushVendorSendResult.fail();
         }
 
         JSONObject body = buildHuaweiMessageBody(task, deviceIds);
@@ -718,26 +772,27 @@ public class CommonPushVendorHttpClient {
         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) {
+        String requestId = sendJson != null ? sendJson.getString("requestId") : null;
+        if (StringUtils.isBlank(requestId)) {
             log.error("华为推送失败, taskNo={}, deviceCount={}, resp={}",
                     task.getTaskNo(), deviceIds.size(), sendResp);
+            return CommonPushVendorSendResult.fail();
         }
-        return ok;
+        return CommonPushVendorSendResult.ok(requestId);
     }
 
-    private boolean sendHonorLike(JSONObject credential, CommonPushTask task, List<String> deviceIds) {
+    private CommonPushVendorSendResult sendHonorLike(JSONObject credential, CommonPushTask task, List<String> deviceIds) {
         if (deviceIds == null || deviceIds.isEmpty()) {
-            return false;
+            return CommonPushVendorSendResult.fail();
         }
         String appId = credential.getString("appId");
         String appSecret = credential.getString("appSecret");
         if (StringUtils.isAnyBlank(appId, appSecret)) {
-            return false;
+            return CommonPushVendorSendResult.fail();
         }
         String accessToken = obtainHonorAccessToken(credential);
         if (StringUtils.isBlank(accessToken)) {
-            return false;
+            return CommonPushVendorSendResult.fail();
         }
 
         JSONObject body = buildHuaweiMessageBody(task, deviceIds);
@@ -746,12 +801,13 @@ public class CommonPushVendorHttpClient {
         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) {
+        String requestId = sendJson != null ? sendJson.getString("requestId") : null;
+        if (StringUtils.isBlank(requestId)) {
             log.error("荣耀推送失败, taskNo={}, deviceCount={}, resp={}",
                     task.getTaskNo(), deviceIds.size(), sendResp);
+            return CommonPushVendorSendResult.fail();
         }
-        return ok;
+        return CommonPushVendorSendResult.ok(requestId);
     }
 
     private String obtainHuaweiAccessToken(JSONObject credential) {
@@ -829,12 +885,13 @@ public class CommonPushVendorHttpClient {
         return partitions;
     }
 
-    private boolean sendApns(JSONObject credential, CommonPushTask task, String deviceId) {
+    private CommonPushVendorSendResult sendApns(JSONObject credential, CommonPushTask task, String deviceId) {
         if (!apnsPushClient.validateCredential(credential)) {
             log.warn("APNs 凭证不完整, deviceId={}", deviceId);
-            return false;
+            return CommonPushVendorSendResult.fail();
         }
-        return apnsPushClient.send(credential, task, deviceId);
+        boolean ok = apnsPushClient.send(credential, task, deviceId);
+        return ok ? CommonPushVendorSendResult.ok(task.getTaskNo()) : CommonPushVendorSendResult.fail();
     }
 
     private JSONObject parseCredential(String credentialJson) {
@@ -859,6 +916,141 @@ public class CommonPushVendorHttpClient {
         return payload.toJSONString();
     }
 
+    /**
+     * 查询 OPPO 广播推送统计(需 message_id + task_id)。
+     */
+    public CommonPushChannelStatsDto queryOppoStatistics(JSONObject credential, String messageId, String taskId) {
+        if (credential == null || StringUtils.isAnyBlank(messageId, taskId)) {
+            return null;
+        }
+        try {
+            String authToken = obtainOppoAuthToken(credential);
+            if (StringUtils.isBlank(authToken)) {
+                return null;
+            }
+            MultiValueMap<String, String> form = new LinkedMultiValueMap<>();
+            form.add("auth_token", authToken);
+            form.add("message_id", messageId);
+            form.add("task_id", taskId);
+            String resp = postForm("https://api.push.oppomobile.com/server/v1/message/statistics", form, null);
+            log.info("OPPO 统计查询响应: messageId={}, taskId={}, resp={}", messageId, taskId, resp);
+            JSONObject json = JSONObject.parseObject(resp);
+            if (json == null || json.getIntValue("code") != 0) {
+                return null;
+            }
+            JSONObject data = json.getJSONObject("data");
+            if (data == null) {
+                return null;
+            }
+            CommonPushChannelStatsDto stats = new CommonPushChannelStatsDto();
+            stats.setRealSend(longToString(data.getLong("sendCount")));
+            stats.setRealDelivered(longToString(data.getLong("arriveCount")));
+            stats.setShowSum(longToString(data.getLong("showCount")));
+            stats.setClickSum(longToString(data.getLong("openCount")));
+            return stats;
+        } catch (Exception e) {
+            log.error("OPPO 统计查询失败, messageId={}, taskId={}, err={}", messageId, taskId, e.getMessage(), e);
+            return null;
+        }
+    }
+
+    /**
+     * 查询 vivo 推送统计。
+     */
+    public CommonPushChannelStatsDto queryVivoStatistics(JSONObject credential, String taskId) {
+        if (credential == null || StringUtils.isBlank(taskId)) {
+            return null;
+        }
+        try {
+            String authToken = obtainVivoAuthToken(credential);
+            if (StringUtils.isBlank(authToken)) {
+                return null;
+            }
+            Map<String, String> headers = new HashMap<>();
+            headers.put("authToken", authToken);
+            String url = "https://api-push.vivo.com.cn/report/getStatistics?taskIds=" + urlEncode(taskId);
+            String resp = getJson(url, headers);
+            log.info("vivo 统计查询响应: taskId={}, resp={}", taskId, resp);
+            JSONObject json = JSONObject.parseObject(resp);
+            if (json == null || json.getIntValue("result") != 0) {
+                return null;
+            }
+            com.alibaba.fastjson.JSONArray statistics = json.getJSONArray("statistics");
+            if (statistics == null || statistics.isEmpty()) {
+                return null;
+            }
+            JSONObject item = statistics.getJSONObject(0);
+            CommonPushChannelStatsDto stats = new CommonPushChannelStatsDto();
+            stats.setRealSend(longToString(item.getLong("send")));
+            stats.setRealDelivered(longToString(item.getLong("receive")));
+            stats.setShowSum(longToString(item.getLong("display")));
+            stats.setClickSum(longToString(item.getLong("click")));
+            return stats;
+        } catch (Exception e) {
+            log.error("vivo 统计查询失败, taskId={}, err={}", taskId, e.getMessage(), e);
+            return null;
+        }
+    }
+
+    /**
+     * 查询小米推送统计(msg_id)。
+     */
+    public CommonPushChannelStatsDto queryXiaomiStatistics(JSONObject credential, String msgId) {
+        if (credential == null || StringUtils.isBlank(msgId)) {
+            return null;
+        }
+        String appSecret = credential.getString("appSecret");
+        if (StringUtils.isBlank(appSecret)) {
+            return null;
+        }
+        try {
+            Map<String, String> headers = new HashMap<>();
+            headers.put("Authorization", "key=" + appSecret);
+            String url = "https://api.xmpush.xiaomi.com/v1/trace/message/status?msg_id=" + urlEncode(msgId);
+            String resp = getJson(url, headers);
+            log.info("小米统计查询响应: msgId={}, resp={}", msgId, resp);
+            JSONObject json = JSONObject.parseObject(resp);
+            if (json == null || !"ok".equalsIgnoreCase(json.getString("result"))) {
+                return null;
+            }
+            JSONObject data = json.getJSONObject("data");
+            if (data == null) {
+                return null;
+            }
+            JSONObject status = data.getJSONObject(msgId);
+            if (status == null && !data.isEmpty()) {
+                status = data.getJSONObject(data.keySet().iterator().next());
+            }
+            if (status == null) {
+                return null;
+            }
+            CommonPushChannelStatsDto stats = new CommonPushChannelStatsDto();
+            stats.setRealSend(longToString(status.getLong("msg_send")));
+            stats.setRealDelivered(longToString(status.getLong("delivered")));
+            stats.setShowSum(longToString(status.getLong("display")));
+            stats.setClickSum(longToString(status.getLong("click")));
+            return stats;
+        } catch (Exception e) {
+            log.error("小米统计查询失败, msgId={}, err={}", msgId, e.getMessage(), e);
+            return null;
+        }
+    }
+
+    private String longToString(Long value) {
+        return value == null ? null : String.valueOf(value);
+    }
+
+    private String getJson(String url, Map<String, String> headers) {
+        RestTemplate client = buildRestTemplate();
+        HttpHeaders httpHeaders = new HttpHeaders();
+        if (headers != null) {
+            headers.forEach(httpHeaders::set);
+        }
+        ResponseEntity<String> response = client.exchange(url, org.springframework.http.HttpMethod.GET,
+                new HttpEntity<>(httpHeaders), String.class);
+        return response.getBody();
+    }
+
     private String postJson(String url, String body, Map<String, String> headers) {
         RestTemplate restTemplate = buildRestTemplate();
         HttpHeaders httpHeaders = new HttpHeaders();

+ 17 - 10
alien-store/src/main/java/shop/alien/store/service/impl/CommonPushSendServiceImpl.java

@@ -15,10 +15,12 @@ import shop.alien.mapper.CommonPushChannelConfigMapper;
 import shop.alien.mapper.LifeUserMapper;
 import shop.alien.store.dto.CommonPushSendResultDto;
 import shop.alien.store.dto.CommonPushTargetDto;
+import shop.alien.store.dto.CommonPushVendorSendResult;
 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 shop.alien.store.util.CommonPushVendorTaskIdUtil;
 
 import java.util.ArrayList;
 import java.util.Arrays;
@@ -26,6 +28,7 @@ import java.util.Collections;
 import java.util.Comparator;
 import java.util.HashMap;
 import java.util.HashSet;
+import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
@@ -185,6 +188,7 @@ public class CommonPushSendServiceImpl implements CommonPushSendService {
 
         int sentCount = 0;
         int failedCount = 0;
+        Map<String, String> vendorTaskIds = new LinkedHashMap<>();
         Set<String> broadcastHandledChannels = new HashSet<>();
         if (fullBroadcast) {
             for (String channelCode : FULL_BROADCAST_CHANNELS) {
@@ -197,9 +201,10 @@ public class CommonPushSendServiceImpl implements CommonPushSendService {
                 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);
+                CommonPushVendorSendResult sendResult = invokeFullBroadcast(channelCode, channelConfig, task, broadcastTargets);
+                CommonPushVendorTaskIdUtil.putTaskId(vendorTaskIds, channelCode, sendResult);
+                sentCount += applyFullBroadcastResult(sendResult.isSuccess(), channelCode, grouped, task, result, channelConfig);
+                failedCount += countFullBroadcastFailure(sendResult.isSuccess(), channelCode, grouped);
             }
         }
 
@@ -214,23 +219,25 @@ public class CommonPushSendServiceImpl implements CommonPushSendService {
                 continue;
             }
             for (CommonPushTargetDto target : entry.getValue()) {
-                boolean ok;
+                CommonPushVendorSendResult sendResult;
                 try {
-                    ok = commonPushVendorHttpClient.send(channelConfig, task, target);
+                    sendResult = commonPushVendorHttpClient.send(channelConfig, task, target);
                 } catch (Exception e) {
                     log.error("渠道单推异常, channelCode={}, deviceId={}", entry.getKey(), target.getDeviceId(), e);
-                    ok = false;
+                    sendResult = CommonPushVendorSendResult.fail();
                 }
-                if (ok) {
+                if (sendResult.isSuccess()) {
                     sentCount++;
                     result.getSentTargets().add(target);
                     updateChannelUsage(channelConfig);
+                    CommonPushVendorTaskIdUtil.mergeTaskId(vendorTaskIds, entry.getKey(), sendResult);
                 } else {
                     failedCount++;
                     result.getFailedTargets().add(target);
                 }
             }
         }
+        result.setVendorTaskIds(vendorTaskIds);
         result.setSentCount(sentCount);
         result.setFailedCount(failedCount);
         result.setSuccess(sentCount > 0);
@@ -272,7 +279,7 @@ public class CommonPushSendServiceImpl implements CommonPushSendService {
         return 0;
     }
 
-    private boolean invokeFullBroadcast(String channelCode, CommonPushChannelConfig config, CommonPushTask task,
+    private CommonPushVendorSendResult invokeFullBroadcast(String channelCode, CommonPushChannelConfig config, CommonPushTask task,
                                           List<CommonPushTargetDto> channelTargets) {
         try {
             switch (channelCode) {
@@ -289,11 +296,11 @@ public class CommonPushSendServiceImpl implements CommonPushSendService {
                 case "vivo":
                     return commonPushVendorHttpClient.sendVivoBroadcast(config, task);
                 default:
-                    return false;
+                    return CommonPushVendorSendResult.fail();
             }
         } catch (Exception e) {
             log.error("全量广播推送异常, channelCode={}, taskNo={}", channelCode, task.getTaskNo(), e);
-            return false;
+            return CommonPushVendorSendResult.fail();
         }
     }
 

+ 10 - 0
alien-store/src/main/java/shop/alien/store/service/impl/CommonPushTaskServiceImpl.java

@@ -21,6 +21,8 @@ import shop.alien.store.dto.CommonPushTestSendDto;
 import shop.alien.store.service.CommonPushReviewService;
 import shop.alien.store.service.CommonPushSendService;
 import shop.alien.store.service.CommonPushTaskService;
+import shop.alien.store.service.CommonPushTaskStatsService;
+import shop.alien.store.util.CommonPushVendorTaskIdUtil;
 import shop.alien.store.util.ai.AiContentModerationUtil;
 
 import java.math.BigDecimal;
@@ -46,6 +48,7 @@ public class CommonPushTaskServiceImpl extends ServiceImpl<CommonPushTaskMapper,
     private final CommonPushSendService commonPushSendService;
     private final CommonPushReviewService commonPushReviewService;
     private final AiContentModerationUtil aiContentModerationUtil;
+    private final CommonPushTaskStatsService commonPushTaskStatsService;
 
     @Override
     @Transactional(rollbackFor = Exception.class)
@@ -143,6 +146,11 @@ public class CommonPushTaskServiceImpl extends ServiceImpl<CommonPushTaskMapper,
         return successCount;
     }
 
+    @Override
+    public int syncPushTaskStatistics() {
+        return commonPushTaskStatsService.syncPushTaskStatistics();
+    }
+
     private void markExpiredScheduledTasks(Date now) {
         List<CommonPushTask> expiredTasks = this.list(new LambdaQueryWrapper<CommonPushTask>()
                 .eq(CommonPushTask::getSendType, 2)
@@ -180,7 +188,9 @@ public class CommonPushTaskServiceImpl extends ServiceImpl<CommonPushTaskMapper,
         }
         task.setActualCount(sendResult.getSentCount());
         task.setStatus(CommonPushTaskStatus.SENT);
+        task.setVendorTaskIds(CommonPushVendorTaskIdUtil.format(sendResult.getVendorTaskIds()));
         this.updateById(task);
+        commonPushTaskStatsService.initTaskNumRecords(task.getId(), sendResult.getVendorTaskIds(), task.getEstimatedCount());
         commonPushSendService.saveTaskUserRecords(task.getId(), sendResult);
         return R.success(sendResult.getMessage());
     }

+ 199 - 0
alien-store/src/main/java/shop/alien/store/service/impl/CommonPushTaskStatsServiceImpl.java

@@ -0,0 +1,199 @@
+package shop.alien.store.service.impl;
+
+import com.alibaba.fastjson.JSONObject;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.lang3.StringUtils;
+import org.springframework.stereotype.Service;
+import shop.alien.entity.store.CommonPushChannelConfig;
+import shop.alien.entity.store.CommonPushTask;
+import shop.alien.entity.store.CommonPushTaskNum;
+import shop.alien.entity.store.CommonPushTaskStatus;
+import shop.alien.mapper.CommonPushChannelConfigMapper;
+import shop.alien.mapper.CommonPushTaskMapper;
+import shop.alien.store.dto.CommonPushChannelStatsDto;
+import shop.alien.store.service.CommonPushTaskNumService;
+import shop.alien.store.service.CommonPushTaskStatsService;
+import shop.alien.store.service.channel.CommonPushVendorHttpClient;
+import shop.alien.store.util.CommonPushPhoneType;
+import shop.alien.store.util.CommonPushVendorTaskIdUtil;
+
+import java.util.Arrays;
+import java.util.List;
+import java.util.Map;
+
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class CommonPushTaskStatsServiceImpl implements CommonPushTaskStatsService {
+
+    private static final List<Integer> SYNC_STATUSES = Arrays.asList(
+            CommonPushTaskStatus.SENT,
+            CommonPushTaskStatus.DELIVERED
+    );
+
+    private final CommonPushTaskMapper commonPushTaskMapper;
+    private final CommonPushTaskNumService commonPushTaskNumService;
+    private final CommonPushChannelConfigMapper commonPushChannelConfigMapper;
+    private final CommonPushVendorHttpClient commonPushVendorHttpClient;
+
+    @Override
+    public int syncPushTaskStatistics() {
+        List<CommonPushTask> tasks = commonPushTaskMapper.selectList(new LambdaQueryWrapper<CommonPushTask>()
+                .in(CommonPushTask::getStatus, SYNC_STATUSES)
+                .isNotNull(CommonPushTask::getVendorTaskIds)
+                .ne(CommonPushTask::getVendorTaskIds, ""));
+        if (tasks == null || tasks.isEmpty()) {
+            return 0;
+        }
+        Map<String, CommonPushChannelConfig> channelMap = loadChannelMap();
+        int successCount = 0;
+        for (CommonPushTask task : tasks) {
+            try {
+                if (syncSingleTask(task, channelMap)) {
+                    successCount++;
+                }
+            } catch (Exception e) {
+                log.error("同步推送统计失败, taskId={}, taskNo={}", task.getId(), task.getTaskNo(), e);
+            }
+        }
+        return successCount;
+    }
+
+    private boolean syncSingleTask(CommonPushTask task, Map<String, CommonPushChannelConfig> channelMap) {
+        Map<String, String> vendorTaskIds = CommonPushVendorTaskIdUtil.parse(task.getVendorTaskIds());
+        if (vendorTaskIds.isEmpty()) {
+            return false;
+        }
+        boolean updated = false;
+        for (Map.Entry<String, String> entry : vendorTaskIds.entrySet()) {
+            if (!entry.getKey().endsWith("_task_id")) {
+                continue;
+            }
+            String channelCode = CommonPushPhoneType.channelCodeFromTaskIdKey(entry.getKey());
+            String phoneType = CommonPushPhoneType.fromChannelCode(channelCode);
+            if (StringUtils.isAnyBlank(channelCode, phoneType)) {
+                continue;
+            }
+            CommonPushChannelConfig channelConfig = channelMap.get(channelCode);
+            if (channelConfig == null) {
+                log.warn("统计同步跳过:渠道未配置, taskId={}, channelCode={}", task.getId(), channelCode);
+                continue;
+            }
+            JSONObject credential = parseCredential(channelConfig.getCredentialJson());
+            if (credential == null) {
+                continue;
+            }
+            CommonPushChannelStatsDto stats = queryChannelStats(channelCode, credential, vendorTaskIds, entry.getValue());
+            if (stats == null) {
+                continue;
+            }
+            upsertTaskNum(task, phoneType, stats);
+            updated = true;
+        }
+        return updated;
+    }
+
+    private CommonPushChannelStatsDto queryChannelStats(String channelCode, JSONObject credential,
+                                                          Map<String, String> vendorTaskIds, String taskIdValue) {
+        if (StringUtils.isBlank(taskIdValue)) {
+            return null;
+        }
+        String firstTaskId = taskIdValue.split(",")[0].trim();
+        switch (channelCode) {
+            case "oppo": {
+                String messageId = vendorTaskIds.get("oppo_message_id");
+                return commonPushVendorHttpClient.queryOppoStatistics(credential, messageId, firstTaskId);
+            }
+            case "vivo":
+                return commonPushVendorHttpClient.queryVivoStatistics(credential, firstTaskId);
+            case "xiaomi":
+                return commonPushVendorHttpClient.queryXiaomiStatistics(credential, firstTaskId);
+            default:
+                log.debug("渠道暂无统计查询接口, channelCode={}, taskId={}", channelCode, firstTaskId);
+                return null;
+        }
+    }
+
+    private void upsertTaskNum(CommonPushTask task, String phoneType, CommonPushChannelStatsDto stats) {
+        CommonPushTaskNum existing = commonPushTaskNumService.getOne(new LambdaQueryWrapper<CommonPushTaskNum>()
+                .eq(CommonPushTaskNum::getPushTaskId, task.getId())
+                .eq(CommonPushTaskNum::getPhoneType, phoneType)
+                .last("LIMIT 1"));
+        if (existing == null) {
+            CommonPushTaskNum taskNum = new CommonPushTaskNum();
+            taskNum.setPushTaskId(task.getId());
+            taskNum.setPhoneType(phoneType);
+            taskNum.setExpectedSend(task.getEstimatedCount() == null ? null : String.valueOf(task.getEstimatedCount()));
+            fillStats(taskNum, stats);
+            commonPushTaskNumService.save(taskNum);
+            return;
+        }
+        fillStats(existing, stats);
+        commonPushTaskNumService.updateById(existing);
+    }
+
+    private void fillStats(CommonPushTaskNum taskNum, CommonPushChannelStatsDto stats) {
+        taskNum.setRealSend(stats.getRealSend());
+        taskNum.setRealDelivered(stats.getRealDelivered());
+        taskNum.setClickSum(stats.getClickSum());
+        taskNum.setShowSum(stats.getShowSum());
+    }
+
+    @Override
+    public void initTaskNumRecords(Long pushTaskId, Map<String, String> vendorTaskIds, Integer estimatedCount) {
+        if (pushTaskId == null || vendorTaskIds == null || vendorTaskIds.isEmpty()) {
+            return;
+        }
+        String expectedSend = estimatedCount == null ? null : String.valueOf(estimatedCount);
+        for (Map.Entry<String, String> entry : vendorTaskIds.entrySet()) {
+            if (!entry.getKey().endsWith("_task_id")) {
+                continue;
+            }
+            String channelCode = CommonPushPhoneType.channelCodeFromTaskIdKey(entry.getKey());
+            String phoneType = CommonPushPhoneType.fromChannelCode(channelCode);
+            if (StringUtils.isBlank(phoneType)) {
+                continue;
+            }
+            long count = commonPushTaskNumService.count(new LambdaQueryWrapper<CommonPushTaskNum>()
+                    .eq(CommonPushTaskNum::getPushTaskId, pushTaskId)
+                    .eq(CommonPushTaskNum::getPhoneType, phoneType));
+            if (count > 0) {
+                continue;
+            }
+            CommonPushTaskNum taskNum = new CommonPushTaskNum();
+            taskNum.setPushTaskId(pushTaskId);
+            taskNum.setPhoneType(phoneType);
+            taskNum.setExpectedSend(expectedSend);
+            commonPushTaskNumService.save(taskNum);
+        }
+    }
+
+    private Map<String, CommonPushChannelConfig> loadChannelMap() {
+        List<CommonPushChannelConfig> configs = commonPushChannelConfigMapper.selectList(
+                new LambdaQueryWrapper<CommonPushChannelConfig>()
+                        .eq(CommonPushChannelConfig::getEnable, 1));
+        Map<String, CommonPushChannelConfig> channelMap = new java.util.HashMap<>();
+        if (configs != null) {
+            for (CommonPushChannelConfig config : configs) {
+                if (config != null && StringUtils.isNotBlank(config.getChannelCode())) {
+                    channelMap.put(StringUtils.lowerCase(config.getChannelCode().trim()), config);
+                }
+            }
+        }
+        return channelMap;
+    }
+
+    private JSONObject parseCredential(String credentialJson) {
+        if (StringUtils.isBlank(credentialJson)) {
+            return null;
+        }
+        try {
+            return JSONObject.parseObject(credentialJson);
+        } catch (Exception e) {
+            log.warn("credential_json 解析失败: {}", e.getMessage());
+            return null;
+        }
+    }
+}

+ 51 - 0
alien-store/src/main/java/shop/alien/store/util/CommonPushPhoneType.java

@@ -0,0 +1,51 @@
+package shop.alien.store.util;
+
+import org.apache.commons.lang3.StringUtils;
+
+/**
+ * common_push_task_num.phone_type 与渠道编码映射
+ */
+public final class CommonPushPhoneType {
+
+    public static final String OPPO = "1";
+    public static final String VIVO = "2";
+    public static final String HUAWEI = "3";
+    public static final String APNS = "4";
+    public static final String HONOR = "5";
+    public static final String XIAOMI = "6";
+    public static final String SAMSUNG = "7";
+
+    private CommonPushPhoneType() {
+    }
+
+    public static String fromChannelCode(String channelCode) {
+        if (StringUtils.isBlank(channelCode)) {
+            return null;
+        }
+        switch (StringUtils.lowerCase(channelCode.trim())) {
+            case "oppo":
+                return OPPO;
+            case "vivo":
+                return VIVO;
+            case "huawei":
+                return HUAWEI;
+            case "apns":
+                return APNS;
+            case "honor":
+                return HONOR;
+            case "xiaomi":
+                return XIAOMI;
+            case "samsung":
+                return SAMSUNG;
+            default:
+                return null;
+        }
+    }
+
+    public static String channelCodeFromTaskIdKey(String taskIdKey) {
+        if (StringUtils.isBlank(taskIdKey) || !taskIdKey.endsWith("_task_id")) {
+            return null;
+        }
+        return taskIdKey.substring(0, taskIdKey.length() - "_task_id".length());
+    }
+}

+ 92 - 0
alien-store/src/main/java/shop/alien/store/util/CommonPushVendorTaskIdUtil.java

@@ -0,0 +1,92 @@
+package shop.alien.store.util;
+
+import org.apache.commons.lang3.StringUtils;
+import shop.alien.store.dto.CommonPushVendorSendResult;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+/**
+ * 厂商 task_id 字符串格式化与解析,格式:oppo_task_id: 1001, honor_task_id: 10056
+ */
+public final class CommonPushVendorTaskIdUtil {
+
+    private CommonPushVendorTaskIdUtil() {
+    }
+
+    public static String format(Map<String, String> vendorTaskIds) {
+        if (vendorTaskIds == null || vendorTaskIds.isEmpty()) {
+            return null;
+        }
+        StringBuilder sb = new StringBuilder();
+        for (Map.Entry<String, String> entry : vendorTaskIds.entrySet()) {
+            if (StringUtils.isAnyBlank(entry.getKey(), entry.getValue())) {
+                continue;
+            }
+            if (sb.length() > 0) {
+                sb.append(", ");
+            }
+            sb.append(entry.getKey().trim()).append(": ").append(entry.getValue().trim());
+        }
+        return sb.length() == 0 ? null : sb.toString();
+    }
+
+    public static Map<String, String> parse(String vendorTaskIds) {
+        Map<String, String> result = new LinkedHashMap<>();
+        if (StringUtils.isBlank(vendorTaskIds)) {
+            return result;
+        }
+        String[] parts = vendorTaskIds.split(",");
+        for (String part : parts) {
+            if (StringUtils.isBlank(part)) {
+                continue;
+            }
+            int colonIndex = part.indexOf(':');
+            if (colonIndex <= 0) {
+                continue;
+            }
+            String key = part.substring(0, colonIndex).trim();
+            String value = part.substring(colonIndex + 1).trim();
+            if (StringUtils.isNoneBlank(key, value)) {
+                result.put(key, value);
+            }
+        }
+        return result;
+    }
+
+    public static void putTaskId(Map<String, String> vendorTaskIds, String channelCode,
+                                 CommonPushVendorSendResult sendResult) {
+        if (vendorTaskIds == null || sendResult == null || !sendResult.isSuccess()) {
+            return;
+        }
+        String code = StringUtils.lowerCase(StringUtils.trimToEmpty(channelCode));
+        if (StringUtils.isNotBlank(sendResult.getVendorTaskId())) {
+            vendorTaskIds.put(code + "_task_id", sendResult.getVendorTaskId().trim());
+        }
+        if (StringUtils.isNotBlank(sendResult.getExtraTaskId())) {
+            vendorTaskIds.put(code + "_message_id", sendResult.getExtraTaskId().trim());
+        }
+    }
+
+    public static void mergeTaskId(Map<String, String> vendorTaskIds, String channelCode,
+                                   CommonPushVendorSendResult sendResult) {
+        if (vendorTaskIds == null || sendResult == null || !sendResult.isSuccess()) {
+            return;
+        }
+        String code = StringUtils.lowerCase(StringUtils.trimToEmpty(channelCode));
+        String taskKey = code + "_task_id";
+        String messageKey = code + "_message_id";
+        if (StringUtils.isNotBlank(sendResult.getVendorTaskId())) {
+            String newId = sendResult.getVendorTaskId().trim();
+            String existing = vendorTaskIds.get(taskKey);
+            if (StringUtils.isBlank(existing)) {
+                vendorTaskIds.put(taskKey, newId);
+            } else if (!existing.contains(newId)) {
+                vendorTaskIds.put(taskKey, existing + "," + newId);
+            }
+        }
+        if (StringUtils.isNotBlank(sendResult.getExtraTaskId())) {
+            vendorTaskIds.put(messageKey, sendResult.getExtraTaskId().trim());
+        }
+    }
+}